diff --git a/packages/rln/src/contract/rln_light_contract.ts b/packages/rln/src/contract/rln_base_contract.ts similarity index 78% rename from packages/rln/src/contract/rln_light_contract.ts rename to packages/rln/src/contract/rln_base_contract.ts index 533c85e203..44aec8e30d 100644 --- a/packages/rln/src/contract/rln_light_contract.ts +++ b/packages/rln/src/contract/rln_base_contract.ts @@ -1,84 +1,47 @@ import { Logger } from "@waku/utils"; import { ethers } from "ethers"; -import type { IdentityCredential } from "../identity.js"; -import type { DecryptedCredentials } from "../keystore/index.js"; +import { IdentityCredential } from "../identity.js"; +import { DecryptedCredentials } from "../keystore/types.js"; import { RLN_ABI } from "./abi.js"; import { DEFAULT_RATE_LIMIT, RATE_LIMIT_PARAMS } from "./constants.js"; +import { + CustomQueryOptions, + FetchMembersOptions, + Member, + MembershipInfo, + MembershipRegisteredEvent, + MembershipState, + RLNContractInitOptions +} from "./types.js"; -const log = new Logger("waku:rln:contract"); +const log = new Logger("waku:rln:contract:base"); -type Member = { - idCommitment: string; - index: ethers.BigNumber; -}; - -interface RLNContractOptions { - signer: ethers.Signer; - address: string; - rateLimit?: number; -} - -interface RLNContractInitOptions extends RLNContractOptions { - contract?: ethers.Contract; -} - -export interface MembershipRegisteredEvent { - idCommitment: string; - membershipRateLimit: ethers.BigNumber; - index: ethers.BigNumber; -} - -type FetchMembersOptions = { - fromBlock?: number; - fetchRange?: number; - fetchChunks?: number; -}; - -export interface MembershipInfo { - index: ethers.BigNumber; - idCommitment: string; - rateLimit: number; - startBlock: number; - endBlock: number; - state: MembershipState; -} - -export enum MembershipState { - Active = "Active", - GracePeriod = "GracePeriod", - Expired = "Expired", - ErasedAwaitsWithdrawal = "ErasedAwaitsWithdrawal" -} - -export class RLNLightContract { +export class RLNBaseContract { public contract: ethers.Contract; - private deployBlock: undefined | number; private rateLimit: number; - private _members: Map = new Map(); + protected _members: Map = new Map(); private _membersFilter: ethers.EventFilter; private _membershipErasedFilter: ethers.EventFilter; private _membersExpiredFilter: ethers.EventFilter; /** - * Asynchronous initializer for RLNContract. + * Constructor for RLNBaseContract. * Allows injecting a mocked contract for testing purposes. */ - public static async init( - options: RLNContractInitOptions - ): Promise { - const rlnContract = new RLNLightContract(options); + public constructor(options: RLNContractInitOptions) { + // Initialize members and subscriptions + this.fetchMembers() + .then(() => { + this.subscribeToMembers(); + }) + .catch((error) => { + log.error("Failed to initialize members", { error }); + }); - await rlnContract.fetchMembers(); - rlnContract.subscribeToMembers(); - - return rlnContract; - } - - private constructor(options: RLNContractInitOptions) { const { address, signer, @@ -86,19 +49,10 @@ export class RLNLightContract { contract } = options; - if ( - rateLimit < RATE_LIMIT_PARAMS.MIN_RATE || - rateLimit > RATE_LIMIT_PARAMS.MAX_RATE - ) { - throw new Error( - `Rate limit must be between ${RATE_LIMIT_PARAMS.MIN_RATE} and ${RATE_LIMIT_PARAMS.MAX_RATE} messages per epoch` - ); - } + this.validateRateLimit(rateLimit); - this.rateLimit = rateLimit; - - // Use the injected contract if provided; otherwise, instantiate a new one. this.contract = contract || new ethers.Contract(address, RLN_ABI, signer); + this.rateLimit = rateLimit; // Initialize event filters this._membersFilter = this.contract.filters.MembershipRegistered(); @@ -151,7 +105,7 @@ export class RLNLightContract { */ public async getMaxTotalRateLimit(): Promise { const maxTotalRate = await this.contract.maxTotalRateLimit(); - return ethers.BigNumber.from(maxTotalRate).toNumber(); + return maxTotalRate.toNumber(); } /** @@ -160,7 +114,7 @@ export class RLNLightContract { */ public async getCurrentTotalRateLimit(): Promise { const currentTotal = await this.contract.currentTotalRateLimit(); - return ethers.BigNumber.from(currentTotal).toNumber(); + return currentTotal.toNumber(); } /** @@ -172,9 +126,7 @@ export class RLNLightContract { this.contract.maxTotalRateLimit(), this.contract.currentTotalRateLimit() ]); - return ethers.BigNumber.from(maxTotal) - .sub(ethers.BigNumber.from(currentTotal)) - .toNumber(); + return Number(maxTotal) - Number(currentTotal); } /** @@ -182,6 +134,7 @@ export class RLNLightContract { * @param newRateLimit The new rate limit to use */ public async setRateLimit(newRateLimit: number): Promise { + this.validateRateLimit(newRateLimit); this.rateLimit = newRateLimit; } @@ -192,43 +145,31 @@ export class RLNLightContract { return sortedMembers; } - private get membersFilter(): ethers.EventFilter { - if (!this._membersFilter) { - throw Error("Members filter was not initialized."); - } - return this._membersFilter; - } - - private get membershipErasedFilter(): ethers.EventFilter { - if (!this._membershipErasedFilter) { - throw Error("MembershipErased filter was not initialized."); - } - return this._membershipErasedFilter; - } - - private get membersExpiredFilter(): ethers.EventFilter { - if (!this._membersExpiredFilter) { - throw Error("MembersExpired filter was not initialized."); - } - return this._membersExpiredFilter; - } - public async fetchMembers(options: FetchMembersOptions = {}): Promise { - const registeredMemberEvents = await queryFilter(this.contract, { - fromBlock: this.deployBlock, - ...options, - membersFilter: this.membersFilter - }); - const removedMemberEvents = await queryFilter(this.contract, { - fromBlock: this.deployBlock, - ...options, - membersFilter: this.membershipErasedFilter - }); - const expiredMemberEvents = await queryFilter(this.contract, { - fromBlock: this.deployBlock, - ...options, - membersFilter: this.membersExpiredFilter - }); + const registeredMemberEvents = await RLNBaseContract.queryFilter( + this.contract, + { + fromBlock: this.deployBlock, + ...options, + membersFilter: this.membersFilter + } + ); + const removedMemberEvents = await RLNBaseContract.queryFilter( + this.contract, + { + fromBlock: this.deployBlock, + ...options, + membersFilter: this.membershipErasedFilter + } + ); + const expiredMemberEvents = await RLNBaseContract.queryFilter( + this.contract, + { + fromBlock: this.deployBlock, + ...options, + membersFilter: this.membersExpiredFilter + } + ); const events = [ ...registeredMemberEvents, @@ -238,6 +179,58 @@ export class RLNLightContract { this.processEvents(events); } + public static async queryFilter( + contract: ethers.Contract, + options: CustomQueryOptions + ): Promise { + const FETCH_CHUNK = 5; + const BLOCK_RANGE = 3000; + + const { + fromBlock, + membersFilter, + fetchRange = BLOCK_RANGE, + fetchChunks = FETCH_CHUNK + } = options; + + if (fromBlock === undefined) { + return contract.queryFilter(membersFilter); + } + + if (!contract.provider) { + throw Error("No provider found on the contract."); + } + + const toBlock = await contract.provider.getBlockNumber(); + + if (toBlock - fromBlock < fetchRange) { + return contract.queryFilter(membersFilter, fromBlock, toBlock); + } + + const events: ethers.Event[][] = []; + const chunks = RLNBaseContract.splitToChunks( + fromBlock, + toBlock, + fetchRange + ); + + for (const portion of RLNBaseContract.takeN<[number, number]>( + chunks, + fetchChunks + )) { + const promises = portion.map(([left, right]) => + RLNBaseContract.ignoreErrors( + contract.queryFilter(membersFilter, left, right), + [] + ) + ); + const fetchedEvents = await Promise.all(promises); + events.push(fetchedEvents.flatMap((v) => v)); + } + + return events.flatMap((v) => v); + } + public processEvents(events: ethers.Event[]): void { const toRemoveTable = new Map(); const toInsertTable = new Map(); @@ -280,6 +273,53 @@ export class RLNLightContract { }); } + public static splitToChunks( + from: number, + to: number, + step: number + ): Array<[number, number]> { + const chunks: Array<[number, number]> = []; + + let left = from; + while (left < to) { + const right = left + step < to ? left + step : to; + + chunks.push([left, right] as [number, number]); + + left = right; + } + + return chunks; + } + + public static *takeN(array: T[], size: number): Iterable { + let start = 0; + + while (start < array.length) { + const portion = array.slice(start, start + size); + + yield portion; + + start += size; + } + } + + public static async ignoreErrors( + promise: Promise, + defaultValue: T + ): Promise { + try { + return await promise; + } catch (err: unknown) { + if (err instanceof Error) { + log.info(`Ignoring an error during query: ${err.message}`); + } else { + log.info(`Ignoring an unknown error during query`); + } + return defaultValue; + } + } + public subscribeToMembers(): void { this.contract.on( this.membersFilter, @@ -318,6 +358,116 @@ export class RLNLightContract { ); } + /** + * Helper method to get remaining messages in current epoch + * @param membershipId The ID of the membership to check + * @returns number of remaining messages allowed in current epoch + */ + public async getRemainingMessages(membershipId: number): Promise { + try { + const [startTime, , rateLimit] = + await this.contract.getMembershipInfo(membershipId); + + // Calculate current epoch + const currentTime = Math.floor(Date.now() / 1000); + const epochsPassed = Math.floor( + (currentTime - startTime) / RATE_LIMIT_PARAMS.EPOCH_LENGTH + ); + const currentEpochStart = + startTime + epochsPassed * RATE_LIMIT_PARAMS.EPOCH_LENGTH; + + // Get message count in current epoch using contract's function + const messageCount = await this.contract.getMessageCount( + membershipId, + currentEpochStart + ); + return Math.max( + 0, + ethers.BigNumber.from(rateLimit) + .sub(ethers.BigNumber.from(messageCount)) + .toNumber() + ); + } catch (error) { + log.error( + `Error getting remaining messages: ${(error as Error).message}` + ); + return 0; // Fail safe: assume no messages remaining on error + } + } + + public async getMembershipInfo( + idCommitment: string + ): Promise { + try { + const [startBlock, endBlock, rateLimit] = + await this.contract.getMembershipInfo(idCommitment); + const currentBlock = await this.contract.provider.getBlockNumber(); + + let state: MembershipState; + if (currentBlock < startBlock) { + state = MembershipState.Active; + } else if (currentBlock < endBlock) { + state = MembershipState.GracePeriod; + } else { + state = MembershipState.Expired; + } + + const index = await this.getMemberIndex(idCommitment); + if (!index) return undefined; + + return { + index, + idCommitment, + rateLimit: rateLimit.toNumber(), + startBlock: startBlock.toNumber(), + endBlock: endBlock.toNumber(), + state + }; + } catch (error) { + return undefined; + } + } + + public async extendMembership( + idCommitment: string + ): Promise { + return this.contract.extendMemberships([idCommitment]); + } + + public async eraseMembership( + idCommitment: string, + eraseFromMembershipSet: boolean = true + ): Promise { + return this.contract.eraseMemberships( + [idCommitment], + eraseFromMembershipSet + ); + } + + public async registerMembership( + idCommitment: string, + rateLimit: number = DEFAULT_RATE_LIMIT + ): Promise { + if ( + rateLimit < RATE_LIMIT_PARAMS.MIN_RATE || + rateLimit > RATE_LIMIT_PARAMS.MAX_RATE + ) { + throw new Error( + `Rate limit must be between ${RATE_LIMIT_PARAMS.MIN_RATE} and ${RATE_LIMIT_PARAMS.MAX_RATE}` + ); + } + return this.contract.register(idCommitment, rateLimit, []); + } + + public async withdraw(token: string, holder: string): Promise { + try { + const tx = await this.contract.withdraw(token, { from: holder }); + await tx.wait(); + } catch (error) { + log.error(`Error in withdraw: ${(error as Error).message}`); + } + } + public async registerWithIdentity( identity: IdentityCredential ): Promise { @@ -430,43 +580,6 @@ export class RLNLightContract { } } - /** - * Helper method to get remaining messages in current epoch - * @param membershipId The ID of the membership to check - * @returns number of remaining messages allowed in current epoch - */ - public async getRemainingMessages(membershipId: number): Promise { - try { - const [startTime, , rateLimit] = - await this.contract.getMembershipInfo(membershipId); - - // Calculate current epoch - const currentTime = Math.floor(Date.now() / 1000); - const epochsPassed = Math.floor( - (currentTime - startTime) / RATE_LIMIT_PARAMS.EPOCH_LENGTH - ); - const currentEpochStart = - startTime + epochsPassed * RATE_LIMIT_PARAMS.EPOCH_LENGTH; - - // Get message count in current epoch using contract's function - const messageCount = await this.contract.getMessageCount( - membershipId, - currentEpochStart - ); - return Math.max( - 0, - ethers.BigNumber.from(rateLimit) - .sub(ethers.BigNumber.from(messageCount)) - .toNumber() - ); - } catch (error) { - log.error( - `Error getting remaining messages: ${(error as Error).message}` - ); - return 0; // Fail safe: assume no messages remaining on error - } - } - public async registerWithPermitAndErase( identity: IdentityCredential, permit: { @@ -539,77 +652,40 @@ export class RLNLightContract { } } - public async withdraw(token: string, holder: string): Promise { - try { - const tx = await this.contract.withdraw(token, { from: holder }); - await tx.wait(); - } catch (error) { - log.error(`Error in withdraw: ${(error as Error).message}`); - } - } - - public async getMembershipInfo( - idCommitment: string - ): Promise { - try { - const [startBlock, endBlock, rateLimit] = - await this.contract.getMembershipInfo(idCommitment); - const currentBlock = await this.contract.provider.getBlockNumber(); - - let state: MembershipState; - if (currentBlock < startBlock) { - state = MembershipState.Active; - } else if (currentBlock < endBlock) { - state = MembershipState.GracePeriod; - } else { - state = MembershipState.Expired; - } - - const index = await this.getMemberIndex(idCommitment); - if (!index) return undefined; - - return { - index, - idCommitment, - rateLimit: rateLimit.toNumber(), - startBlock: startBlock.toNumber(), - endBlock: endBlock.toNumber(), - state - }; - } catch (error) { - return undefined; - } - } - - public async extendMembership( - idCommitment: string - ): Promise { - return this.contract.extendMemberships([idCommitment]); - } - - public async eraseMembership( - idCommitment: string, - eraseFromMembershipSet: boolean = true - ): Promise { - return this.contract.eraseMemberships( - [idCommitment], - eraseFromMembershipSet - ); - } - - public async registerMembership( - idCommitment: string, - rateLimit: number = DEFAULT_RATE_LIMIT - ): Promise { + /** + * Validates that the rate limit is within the allowed range + * @throws Error if the rate limit is outside the allowed range + */ + private validateRateLimit(rateLimit: number): void { if ( rateLimit < RATE_LIMIT_PARAMS.MIN_RATE || rateLimit > RATE_LIMIT_PARAMS.MAX_RATE ) { throw new Error( - `Rate limit must be between ${RATE_LIMIT_PARAMS.MIN_RATE} and ${RATE_LIMIT_PARAMS.MAX_RATE}` + `Rate limit must be between ${RATE_LIMIT_PARAMS.MIN_RATE} and ${RATE_LIMIT_PARAMS.MAX_RATE} messages per epoch` ); } - return this.contract.register(idCommitment, rateLimit, []); + } + + private get membersFilter(): ethers.EventFilter { + if (!this._membersFilter) { + throw Error("Members filter was not initialized."); + } + return this._membersFilter; + } + + private get membershipErasedFilter(): ethers.EventFilter { + if (!this._membershipErasedFilter) { + throw Error("MembershipErased filter was not initialized."); + } + return this._membershipErasedFilter; + } + + private get membersExpiredFilter(): ethers.EventFilter { + if (!this._membersExpiredFilter) { + throw Error("MembersExpired filter was not initialized."); + } + return this._membersExpiredFilter; } private async getMemberIndex( @@ -629,97 +705,3 @@ export class RLNLightContract { } } } - -interface CustomQueryOptions extends FetchMembersOptions { - membersFilter: ethers.EventFilter; -} - -// These values should be tested on other networks -const FETCH_CHUNK = 5; -const BLOCK_RANGE = 3000; - -async function queryFilter( - contract: ethers.Contract, - options: CustomQueryOptions -): Promise { - const { - fromBlock, - membersFilter, - fetchRange = BLOCK_RANGE, - fetchChunks = FETCH_CHUNK - } = options; - - if (fromBlock === undefined) { - return contract.queryFilter(membersFilter); - } - - if (!contract.provider) { - throw Error("No provider found on the contract."); - } - - const toBlock = await contract.provider.getBlockNumber(); - - if (toBlock - fromBlock < fetchRange) { - return contract.queryFilter(membersFilter, fromBlock, toBlock); - } - - const events: ethers.Event[][] = []; - const chunks = splitToChunks(fromBlock, toBlock, fetchRange); - - for (const portion of takeN<[number, number]>(chunks, fetchChunks)) { - const promises = portion.map(([left, right]) => - ignoreErrors(contract.queryFilter(membersFilter, left, right), []) - ); - const fetchedEvents = await Promise.all(promises); - events.push(fetchedEvents.flatMap((v) => v)); - } - - return events.flatMap((v) => v); -} - -function splitToChunks( - from: number, - to: number, - step: number -): Array<[number, number]> { - const chunks: Array<[number, number]> = []; - - let left = from; - while (left < to) { - const right = left + step < to ? left + step : to; - - chunks.push([left, right] as [number, number]); - - left = right; - } - - return chunks; -} - -function* takeN(array: T[], size: number): Iterable { - let start = 0; - - while (start < array.length) { - const portion = array.slice(start, start + size); - - yield portion; - - start += size; - } -} - -async function ignoreErrors( - promise: Promise, - defaultValue: T -): Promise { - try { - return await promise; - } catch (err: unknown) { - if (err instanceof Error) { - log.info(`Ignoring an error during query: ${err.message}`); - } else { - log.info(`Ignoring an unknown error during query`); - } - return defaultValue; - } -} diff --git a/packages/rln/src/contract/rln_contract.spec.ts b/packages/rln/src/contract/rln_contract.spec.ts index c05902e6e5..219b1268be 100644 --- a/packages/rln/src/contract/rln_contract.spec.ts +++ b/packages/rln/src/contract/rln_contract.spec.ts @@ -40,7 +40,7 @@ describe("RLN Contract abstraction - RLN", () => { mockedRegistryContract ); - await rlnContract.fetchMembers(rlnInstance, { + await rlnContract.fetchMembers({ fromBlock: 0, fetchRange: 1000, fetchChunks: 2 diff --git a/packages/rln/src/contract/rln_contract.ts b/packages/rln/src/contract/rln_contract.ts index f08b188501..12b733f6e1 100644 --- a/packages/rln/src/contract/rln_contract.ts +++ b/packages/rln/src/contract/rln_contract.ts @@ -2,72 +2,19 @@ import { Logger } from "@waku/utils"; import { hexToBytes } from "@waku/utils/bytes"; import { ethers } from "ethers"; -import type { IdentityCredential } from "../identity.js"; -import type { DecryptedCredentials } from "../keystore/index.js"; import type { RLNInstance } from "../rln.js"; import { MerkleRootTracker } from "../root_tracker.js"; import { zeroPadLE } from "../utils/bytes.js"; -import { RLN_ABI } from "./abi.js"; -import { DEFAULT_RATE_LIMIT, RATE_LIMIT_PARAMS } from "./constants.js"; +import { RLNBaseContract } from "./rln_base_contract.js"; +import { RLNContractInitOptions } from "./types.js"; const log = new Logger("waku:rln:contract"); -type Member = { - idCommitment: string; - index: ethers.BigNumber; -}; - -interface RLNContractOptions { - signer: ethers.Signer; - address: string; - rateLimit?: number; -} - -interface RLNContractInitOptions extends RLNContractOptions { - contract?: ethers.Contract; -} - -export interface MembershipRegisteredEvent { - idCommitment: string; - membershipRateLimit: ethers.BigNumber; - index: ethers.BigNumber; -} - -type FetchMembersOptions = { - fromBlock?: number; - fetchRange?: number; - fetchChunks?: number; -}; - -export interface MembershipInfo { - index: ethers.BigNumber; - idCommitment: string; - rateLimit: number; - startBlock: number; - endBlock: number; - state: MembershipState; -} - -export enum MembershipState { - Active = "Active", - GracePeriod = "GracePeriod", - Expired = "Expired", - ErasedAwaitsWithdrawal = "ErasedAwaitsWithdrawal" -} - -export class RLNContract { - public contract: ethers.Contract; +export class RLNContract extends RLNBaseContract { + private instance: RLNInstance; private merkleRootTracker: MerkleRootTracker; - private deployBlock: undefined | number; - private rateLimit: number; - - private _members: Map = new Map(); - private _membersFilter: ethers.EventFilter; - private _membershipErasedFilter: ethers.EventFilter; - private _membersExpiredFilter: ethers.EventFilter; - /** * Asynchronous initializer for RLNContract. * Allows injecting a mocked contract for testing purposes. @@ -78,9 +25,6 @@ export class RLNContract { ): Promise { const rlnContract = new RLNContract(rlnInstance, options); - await rlnContract.fetchMembers(rlnInstance); - rlnContract.subscribeToMembers(rlnInstance); - return rlnContract; } @@ -88,178 +32,15 @@ export class RLNContract { rlnInstance: RLNInstance, options: RLNContractInitOptions ) { - const { - address, - signer, - rateLimit = DEFAULT_RATE_LIMIT, - contract - } = options; + super(options); - this.validateRateLimit(rateLimit); - this.rateLimit = rateLimit; + this.instance = rlnInstance; const initialRoot = rlnInstance.zerokit.getMerkleRoot(); - - // Use the injected contract if provided; otherwise, instantiate a new one. - this.contract = contract || new ethers.Contract(address, RLN_ABI, signer); this.merkleRootTracker = new MerkleRootTracker(5, initialRoot); - - // Initialize event filters - this._membersFilter = this.contract.filters.MembershipRegistered(); - this._membershipErasedFilter = this.contract.filters.MembershipErased(); - this._membersExpiredFilter = this.contract.filters.MembershipExpired(); } - /** - * Validates that the rate limit is within the allowed range - * @throws Error if the rate limit is outside the allowed range - */ - private validateRateLimit(rateLimit: number): void { - if ( - rateLimit < RATE_LIMIT_PARAMS.MIN_RATE || - rateLimit > RATE_LIMIT_PARAMS.MAX_RATE - ) { - throw new Error( - `Rate limit must be between ${RATE_LIMIT_PARAMS.MIN_RATE} and ${RATE_LIMIT_PARAMS.MAX_RATE} messages per epoch` - ); - } - } - - /** - * Gets the current rate limit for this contract instance - */ - public getRateLimit(): number { - return this.rateLimit; - } - - /** - * Gets the contract address - */ - public get address(): string { - return this.contract.address; - } - - /** - * Gets the contract provider - */ - public get provider(): ethers.providers.Provider { - return this.contract.provider; - } - - /** - * Gets the minimum allowed rate limit from the contract - * @returns Promise The minimum rate limit in messages per epoch - */ - public async getMinRateLimit(): Promise { - const minRate = await this.contract.minMembershipRateLimit(); - return ethers.BigNumber.from(minRate).toNumber(); - } - - /** - * Gets the maximum allowed rate limit from the contract - * @returns Promise The maximum rate limit in messages per epoch - */ - public async getMaxRateLimit(): Promise { - const maxRate = await this.contract.maxMembershipRateLimit(); - return ethers.BigNumber.from(maxRate).toNumber(); - } - - /** - * Gets the maximum total rate limit across all memberships - * @returns Promise The maximum total rate limit in messages per epoch - */ - public async getMaxTotalRateLimit(): Promise { - const maxTotalRate = await this.contract.maxTotalRateLimit(); - return maxTotalRate.toNumber(); - } - - /** - * Gets the current total rate limit usage across all memberships - * @returns Promise The current total rate limit usage in messages per epoch - */ - public async getCurrentTotalRateLimit(): Promise { - const currentTotal = await this.contract.currentTotalRateLimit(); - return currentTotal.toNumber(); - } - - /** - * Gets the remaining available total rate limit that can be allocated - * @returns Promise The remaining rate limit that can be allocated - */ - public async getRemainingTotalRateLimit(): Promise { - const [maxTotal, currentTotal] = await Promise.all([ - this.contract.maxTotalRateLimit(), - this.contract.currentTotalRateLimit() - ]); - return Number(maxTotal) - Number(currentTotal); - } - - /** - * Updates the rate limit for future registrations - * @param newRateLimit The new rate limit to use - */ - public async setRateLimit(newRateLimit: number): Promise { - this.validateRateLimit(newRateLimit); - this.rateLimit = newRateLimit; - } - - public get members(): Member[] { - const sortedMembers = Array.from(this._members.values()).sort( - (left, right) => left.index.toNumber() - right.index.toNumber() - ); - return sortedMembers; - } - - private get membersFilter(): ethers.EventFilter { - if (!this._membersFilter) { - throw Error("Members filter was not initialized."); - } - return this._membersFilter; - } - - private get membershipErasedFilter(): ethers.EventFilter { - if (!this._membershipErasedFilter) { - throw Error("MembershipErased filter was not initialized."); - } - return this._membershipErasedFilter; - } - - private get membersExpiredFilter(): ethers.EventFilter { - if (!this._membersExpiredFilter) { - throw Error("MembersExpired filter was not initialized."); - } - return this._membersExpiredFilter; - } - - public async fetchMembers( - rlnInstance: RLNInstance, - options: FetchMembersOptions = {} - ): Promise { - const registeredMemberEvents = await queryFilter(this.contract, { - fromBlock: this.deployBlock, - ...options, - membersFilter: this.membersFilter - }); - const removedMemberEvents = await queryFilter(this.contract, { - fromBlock: this.deployBlock, - ...options, - membersFilter: this.membershipErasedFilter - }); - const expiredMemberEvents = await queryFilter(this.contract, { - fromBlock: this.deployBlock, - ...options, - membersFilter: this.membersExpiredFilter - }); - - const events = [ - ...registeredMemberEvents, - ...removedMemberEvents, - ...expiredMemberEvents - ]; - this.processEvents(rlnInstance, events); - } - - public processEvents(rlnInstance: RLNInstance, events: ethers.Event[]): void { + public override processEvents(events: ethers.Event[]): void { const toRemoveTable = new Map(); const toInsertTable = new Map(); @@ -306,8 +87,8 @@ export class RLNContract { } }); - this.removeMembers(rlnInstance, toRemoveTable); - this.insertMembers(rlnInstance, toInsertTable); + this.removeMembers(this.instance, toRemoveTable); + this.insertMembers(this.instance, toInsertTable); } private insertMembers( @@ -360,439 +141,4 @@ export class RLNContract { this.merkleRootTracker.backFill(blockNumber); }); } - - public subscribeToMembers(rlnInstance: RLNInstance): void { - this.contract.on( - this.membersFilter, - ( - _idCommitment: string, - _membershipRateLimit: ethers.BigNumber, - _index: ethers.BigNumber, - event: ethers.Event - ) => { - this.processEvents(rlnInstance, [event]); - } - ); - - this.contract.on( - this.membershipErasedFilter, - ( - _idCommitment: string, - _membershipRateLimit: ethers.BigNumber, - _index: ethers.BigNumber, - event: ethers.Event - ) => { - this.processEvents(rlnInstance, [event]); - } - ); - - this.contract.on( - this.membersExpiredFilter, - ( - _idCommitment: string, - _membershipRateLimit: ethers.BigNumber, - _index: ethers.BigNumber, - event: ethers.Event - ) => { - this.processEvents(rlnInstance, [event]); - } - ); - } - - public async registerWithIdentity( - identity: IdentityCredential - ): Promise { - try { - log.info( - `Registering identity with rate limit: ${this.rateLimit} messages/epoch` - ); - - // Check if the ID commitment is already registered - const existingIndex = await this.getMemberIndex( - identity.IDCommitmentBigInt.toString() - ); - if (existingIndex) { - throw new Error( - `ID commitment is already registered with index ${existingIndex}` - ); - } - - // Check if there's enough remaining rate limit - const remainingRateLimit = await this.getRemainingTotalRateLimit(); - if (remainingRateLimit < this.rateLimit) { - throw new Error( - `Not enough remaining rate limit. Requested: ${this.rateLimit}, Available: ${remainingRateLimit}` - ); - } - - const estimatedGas = await this.contract.estimateGas.register( - identity.IDCommitmentBigInt, - this.rateLimit, - [] - ); - const gasLimit = estimatedGas.add(10000); - - const txRegisterResponse: ethers.ContractTransaction = - await this.contract.register( - identity.IDCommitmentBigInt, - this.rateLimit, - [], - { gasLimit } - ); - - const txRegisterReceipt = await txRegisterResponse.wait(); - - if (txRegisterReceipt.status === 0) { - throw new Error("Transaction failed on-chain"); - } - - const memberRegistered = txRegisterReceipt.events?.find( - (event) => event.event === "MembershipRegistered" - ); - - if (!memberRegistered || !memberRegistered.args) { - log.error( - "Failed to register membership: No MembershipRegistered event found" - ); - return undefined; - } - - const decodedData: MembershipRegisteredEvent = { - idCommitment: memberRegistered.args.idCommitment, - membershipRateLimit: memberRegistered.args.membershipRateLimit, - index: memberRegistered.args.index - }; - - log.info( - `Successfully registered membership with index ${decodedData.index} ` + - `and rate limit ${decodedData.membershipRateLimit}` - ); - - const network = await this.contract.provider.getNetwork(); - const address = this.contract.address; - const membershipId = decodedData.index.toString(); - - return { - identity, - membership: { - address, - treeIndex: parseInt(membershipId), - chainId: network.chainId.toString(), - rateLimit: decodedData.membershipRateLimit.toNumber() - } - }; - } catch (error) { - if (error instanceof Error) { - const errorMessage = error.message; - log.error("registerWithIdentity - error message:", errorMessage); - log.error("registerWithIdentity - error stack:", error.stack); - - // Try to extract more specific error information - if (errorMessage.includes("CannotExceedMaxTotalRateLimit")) { - throw new Error( - "Registration failed: Cannot exceed maximum total rate limit" - ); - } else if (errorMessage.includes("InvalidIdCommitment")) { - throw new Error("Registration failed: Invalid ID commitment"); - } else if (errorMessage.includes("InvalidMembershipRateLimit")) { - throw new Error("Registration failed: Invalid membership rate limit"); - } else if (errorMessage.includes("execution reverted")) { - throw new Error( - "Contract execution reverted. Check contract requirements." - ); - } else { - throw new Error(`Error in registerWithIdentity: ${errorMessage}`); - } - } else { - throw new Error("Unknown error in registerWithIdentity", { - cause: error - }); - } - } - } - - /** - * Helper method to get remaining messages in current epoch - * @param membershipId The ID of the membership to check - * @returns number of remaining messages allowed in current epoch - */ - public async getRemainingMessages(membershipId: number): Promise { - try { - const [startTime, , rateLimit] = - await this.contract.getMembershipInfo(membershipId); - - // Calculate current epoch - const currentTime = Math.floor(Date.now() / 1000); - const epochsPassed = Math.floor( - (currentTime - startTime) / RATE_LIMIT_PARAMS.EPOCH_LENGTH - ); - const currentEpochStart = - startTime + epochsPassed * RATE_LIMIT_PARAMS.EPOCH_LENGTH; - - // Get message count in current epoch using contract's function - const messageCount = await this.contract.getMessageCount( - membershipId, - currentEpochStart - ); - return Math.max(0, rateLimit.sub(messageCount).toNumber()); - } catch (error) { - log.error( - `Error getting remaining messages: ${(error as Error).message}` - ); - return 0; // Fail safe: assume no messages remaining on error - } - } - - public async registerWithPermitAndErase( - identity: IdentityCredential, - permit: { - owner: string; - deadline: number; - v: number; - r: string; - s: string; - }, - idCommitmentsToErase: string[] - ): Promise { - try { - log.info( - `Registering identity with permit and rate limit: ${this.rateLimit} messages/epoch` - ); - - const txRegisterResponse: ethers.ContractTransaction = - await this.contract.registerWithPermit( - permit.owner, - permit.deadline, - permit.v, - permit.r, - permit.s, - identity.IDCommitmentBigInt, - this.rateLimit, - idCommitmentsToErase.map((id) => ethers.BigNumber.from(id)) - ); - const txRegisterReceipt = await txRegisterResponse.wait(); - - const memberRegistered = txRegisterReceipt.events?.find( - (event) => event.event === "MembershipRegistered" - ); - - if (!memberRegistered || !memberRegistered.args) { - log.error( - "Failed to register membership with permit: No MembershipRegistered event found" - ); - return undefined; - } - - const decodedData: MembershipRegisteredEvent = { - idCommitment: memberRegistered.args.idCommitment, - membershipRateLimit: memberRegistered.args.membershipRateLimit, - index: memberRegistered.args.index - }; - - log.info( - `Successfully registered membership with permit. Index: ${decodedData.index}, ` + - `Rate limit: ${decodedData.membershipRateLimit}, Erased ${idCommitmentsToErase.length} commitments` - ); - - const network = await this.contract.provider.getNetwork(); - const address = this.contract.address; - const membershipId = decodedData.index.toString(); - - return { - identity, - membership: { - address, - treeIndex: parseInt(membershipId), - chainId: network.chainId.toString(), - rateLimit: decodedData.membershipRateLimit.toNumber() - } - }; - } catch (error) { - log.error( - `Error in registerWithPermitAndErase: ${(error as Error).message}` - ); - return undefined; - } - } - - public roots(): Uint8Array[] { - return this.merkleRootTracker.roots(); - } - - public async withdraw(token: string, holder: string): Promise { - try { - const tx = await this.contract.withdraw(token, { from: holder }); - await tx.wait(); - } catch (error) { - log.error(`Error in withdraw: ${(error as Error).message}`); - } - } - - public async getMembershipInfo( - idCommitment: string - ): Promise { - try { - const [startBlock, endBlock, rateLimit] = - await this.contract.getMembershipInfo(idCommitment); - const currentBlock = await this.contract.provider.getBlockNumber(); - - let state: MembershipState; - if (currentBlock < startBlock) { - state = MembershipState.Active; - } else if (currentBlock < endBlock) { - state = MembershipState.GracePeriod; - } else { - state = MembershipState.Expired; - } - - const index = await this.getMemberIndex(idCommitment); - if (!index) return undefined; - - return { - index, - idCommitment, - rateLimit: rateLimit.toNumber(), - startBlock: startBlock.toNumber(), - endBlock: endBlock.toNumber(), - state - }; - } catch (error) { - return undefined; - } - } - - public async extendMembership( - idCommitment: string - ): Promise { - return this.contract.extendMemberships([idCommitment]); - } - - public async eraseMembership( - idCommitment: string, - eraseFromMembershipSet: boolean = true - ): Promise { - return this.contract.eraseMemberships( - [idCommitment], - eraseFromMembershipSet - ); - } - - public async registerMembership( - idCommitment: string, - rateLimit: number = this.rateLimit - ): Promise { - this.validateRateLimit(rateLimit); - return this.contract.register(idCommitment, rateLimit, []); - } - - private async getMemberIndex( - idCommitment: string - ): Promise { - try { - const events = await this.contract.queryFilter( - this.contract.filters.MembershipRegistered(idCommitment) - ); - if (events.length === 0) return undefined; - - // Get the most recent registration event - const event = events[events.length - 1]; - return event.args?.index; - } catch (error) { - return undefined; - } - } -} - -interface CustomQueryOptions extends FetchMembersOptions { - membersFilter: ethers.EventFilter; -} - -// These values should be tested on other networks -const FETCH_CHUNK = 5; -const BLOCK_RANGE = 3000; - -async function queryFilter( - contract: ethers.Contract, - options: CustomQueryOptions -): Promise { - const { - fromBlock, - membersFilter, - fetchRange = BLOCK_RANGE, - fetchChunks = FETCH_CHUNK - } = options; - - if (fromBlock === undefined) { - return contract.queryFilter(membersFilter); - } - - if (!contract.provider) { - throw Error("No provider found on the contract."); - } - - const toBlock = await contract.provider.getBlockNumber(); - - if (toBlock - fromBlock < fetchRange) { - return contract.queryFilter(membersFilter, fromBlock, toBlock); - } - - const events: ethers.Event[][] = []; - const chunks = splitToChunks(fromBlock, toBlock, fetchRange); - - for (const portion of takeN<[number, number]>(chunks, fetchChunks)) { - const promises = portion.map(([left, right]) => - ignoreErrors(contract.queryFilter(membersFilter, left, right), []) - ); - const fetchedEvents = await Promise.all(promises); - events.push(fetchedEvents.flatMap((v) => v)); - } - - return events.flatMap((v) => v); -} - -function splitToChunks( - from: number, - to: number, - step: number -): Array<[number, number]> { - const chunks: Array<[number, number]> = []; - - let left = from; - while (left < to) { - const right = left + step < to ? left + step : to; - - chunks.push([left, right] as [number, number]); - - left = right; - } - - return chunks; -} - -function* takeN(array: T[], size: number): Iterable { - let start = 0; - - while (start < array.length) { - const portion = array.slice(start, start + size); - - yield portion; - - start += size; - } -} - -async function ignoreErrors( - promise: Promise, - defaultValue: T -): Promise { - try { - return await promise; - } catch (err: unknown) { - if (err instanceof Error) { - log.info(`Ignoring an error during query: ${err.message}`); - } else { - log.info(`Ignoring an unknown error during query`); - } - return defaultValue; - } } diff --git a/packages/rln/src/contract/types.ts b/packages/rln/src/contract/types.ts new file mode 100644 index 0000000000..833e2a5c1f --- /dev/null +++ b/packages/rln/src/contract/types.ts @@ -0,0 +1,48 @@ +import { ethers } from "ethers"; + +export interface CustomQueryOptions extends FetchMembersOptions { + membersFilter: ethers.EventFilter; +} + +export type Member = { + idCommitment: string; + index: ethers.BigNumber; +}; + +export interface RLNContractOptions { + signer: ethers.Signer; + address: string; + rateLimit?: number; +} + +export interface RLNContractInitOptions extends RLNContractOptions { + contract?: ethers.Contract; +} + +export interface MembershipRegisteredEvent { + idCommitment: string; + membershipRateLimit: ethers.BigNumber; + index: ethers.BigNumber; +} + +export type FetchMembersOptions = { + fromBlock?: number; + fetchRange?: number; + fetchChunks?: number; +}; + +export interface MembershipInfo { + index: ethers.BigNumber; + idCommitment: string; + rateLimit: number; + startBlock: number; + endBlock: number; + state: MembershipState; +} + +export enum MembershipState { + Active = "Active", + GracePeriod = "GracePeriod", + Expired = "Expired", + ErasedAwaitsWithdrawal = "ErasedAwaitsWithdrawal" +} diff --git a/packages/rln/src/create.ts b/packages/rln/src/create.ts index 3b32302832..24d8b660d3 100644 --- a/packages/rln/src/create.ts +++ b/packages/rln/src/create.ts @@ -5,5 +5,5 @@ export async function createRLN(): Promise { // asynchronously. This file does the single async import, so // that no one else needs to worry about it again. const rlnModule = await import("./rln.js"); - return rlnModule.create(); + return rlnModule.RLNInstance.create(); } diff --git a/packages/rln/src/credentials_manager.ts b/packages/rln/src/credentials_manager.ts new file mode 100644 index 0000000000..1cf0495d59 --- /dev/null +++ b/packages/rln/src/credentials_manager.ts @@ -0,0 +1,306 @@ +import { hmac } from "@noble/hashes/hmac"; +import { sha256 } from "@noble/hashes/sha256"; +import { Logger } from "@waku/utils"; +import { ethers } from "ethers"; + +import { LINEA_CONTRACT } from "./contract/constants.js"; +import { RLNBaseContract } from "./contract/rln_base_contract.js"; +import { IdentityCredential } from "./identity.js"; +import { Keystore } from "./keystore/index.js"; +import type { + DecryptedCredentials, + EncryptedCredentials +} from "./keystore/index.js"; +import { KeystoreEntity, Password } from "./keystore/types.js"; +import { RegisterMembershipOptions, StartRLNOptions } from "./types.js"; +import { + buildBigIntFromUint8Array, + extractMetaMaskSigner +} from "./utils/index.js"; +import { Zerokit } from "./zerokit.js"; + +const log = new Logger("waku:credentials"); + +/** + * Manages credentials for RLN + * This is a lightweight implementation of the RLN contract that doesn't require Zerokit + * It is used to register membership and generate identity credentials + */ +export class RLNCredentialsManager { + protected started = false; + protected starting = false; + + private _contract: undefined | RLNBaseContract; + private _signer: undefined | ethers.Signer; + + protected keystore = Keystore.create(); + private _credentials: undefined | DecryptedCredentials; + + public zerokit: undefined | Zerokit; + + public constructor(zerokit?: Zerokit) { + log.info("RLNCredentialsManager initialized"); + this.zerokit = zerokit; + } + + public get contract(): undefined | RLNBaseContract { + return this._contract; + } + + public set contract(contract: RLNBaseContract | undefined) { + this._contract = contract; + } + + public get signer(): undefined | ethers.Signer { + return this._signer; + } + + public set signer(signer: ethers.Signer | undefined) { + this._signer = signer; + } + + public get credentials(): undefined | DecryptedCredentials { + return this._credentials; + } + + public set credentials(credentials: DecryptedCredentials | undefined) { + this._credentials = credentials; + } + + public get provider(): undefined | ethers.providers.Provider { + return this.contract?.provider; + } + + public async start(options: StartRLNOptions = {}): Promise { + if (this.started || this.starting) { + log.info("RLNCredentialsManager already started or starting"); + return; + } + + log.info("Starting RLNCredentialsManager"); + this.starting = true; + + try { + const { credentials, keystore } = + await RLNCredentialsManager.decryptCredentialsIfNeeded( + options.credentials + ); + + if (credentials) { + log.info("Credentials successfully decrypted"); + } + + const { signer, address, rateLimit } = await this.determineStartOptions( + options, + credentials + ); + + log.info(`Using contract address: ${address}`); + + if (keystore) { + this.keystore = keystore; + log.info("Using provided keystore"); + } + + this._credentials = credentials; + this._signer = signer!; + this._contract = new RLNBaseContract({ + address: address!, + signer: signer!, + rateLimit: rateLimit ?? this.zerokit?.getRateLimit + }); + + log.info("RLNCredentialsManager successfully started"); + this.started = true; + } catch (error) { + log.error("Failed to start RLNCredentialsManager", error); + throw error; + } finally { + this.starting = false; + } + } + + public async registerMembership( + options: RegisterMembershipOptions + ): Promise { + if (!this.contract) { + log.error("RLN Contract is not initialized"); + throw Error("RLN Contract is not initialized."); + } + + log.info("Registering membership"); + let identity = "identity" in options && options.identity; + + if ("signature" in options) { + log.info("Generating identity from signature"); + if (this.zerokit) { + log.info("Using Zerokit to generate identity"); + identity = this.zerokit.generateSeededIdentityCredential( + options.signature + ); + } else { + log.info("Using local implementation to generate identity"); + identity = this.generateSeededIdentityCredential(options.signature); + } + } + + if (!identity) { + log.error("Missing signature or identity to register membership"); + throw Error("Missing signature or identity to register membership."); + } + + log.info("Registering identity with contract"); + return this.contract.registerWithIdentity(identity); + } + + /** + * Changes credentials in use by relying on provided Keystore earlier in rln.start + * @param id: string, hash of credentials to select from Keystore + * @param password: string or bytes to use to decrypt credentials from Keystore + */ + public async useCredentials(id: string, password: Password): Promise { + log.info(`Attempting to use credentials with ID: ${id}`); + this._credentials = await this.keystore?.readCredential(id, password); + if (this._credentials) { + log.info("Successfully loaded credentials"); + } else { + log.warn("Failed to load credentials"); + } + } + + protected async determineStartOptions( + options: StartRLNOptions, + credentials: KeystoreEntity | undefined + ): Promise { + let chainId = credentials?.membership.chainId; + const address = + credentials?.membership.address || + options.address || + LINEA_CONTRACT.address; + + if (address === LINEA_CONTRACT.address) { + chainId = LINEA_CONTRACT.chainId; + log.info(`Using Linea contract with chainId: ${chainId}`); + } + + const signer = options.signer || (await extractMetaMaskSigner()); + const currentChainId = await signer.getChainId(); + log.info(`Current chain ID: ${currentChainId}`); + + if (chainId && chainId !== currentChainId) { + log.error( + `Chain ID mismatch: contract=${chainId}, current=${currentChainId}` + ); + throw Error( + `Failed to start RLN contract, chain ID of contract is different from current one: contract-${chainId}, current network-${currentChainId}` + ); + } + + return { + signer, + address + }; + } + + protected static async decryptCredentialsIfNeeded( + credentials?: EncryptedCredentials | DecryptedCredentials + ): Promise<{ credentials?: DecryptedCredentials; keystore?: Keystore }> { + if (!credentials) { + log.info("No credentials provided"); + return {}; + } + + if ("identity" in credentials) { + log.info("Using already decrypted credentials"); + return { credentials }; + } + + log.info("Attempting to decrypt credentials"); + const keystore = Keystore.fromString(credentials.keystore); + + if (!keystore) { + log.warn("Failed to create keystore from string"); + return {}; + } + + try { + const decryptedCredentials = await keystore.readCredential( + credentials.id, + credentials.password + ); + log.info(`Successfully decrypted credentials with ID: ${credentials.id}`); + + return { + keystore, + credentials: decryptedCredentials + }; + } catch (error) { + log.error("Failed to decrypt credentials", error); + throw error; + } + } + + protected async verifyCredentialsAgainstContract( + credentials: KeystoreEntity + ): Promise { + if (!this.contract) { + throw Error( + "Failed to verify chain coordinates: no contract initialized." + ); + } + + const registryAddress = credentials.membership.address; + const currentRegistryAddress = this.contract.address; + if (registryAddress !== currentRegistryAddress) { + throw Error( + `Failed to verify chain coordinates: credentials contract address=${registryAddress} is not equal to registryContract address=${currentRegistryAddress}` + ); + } + + const chainId = credentials.membership.chainId; + const network = await this.contract.provider.getNetwork(); + const currentChainId = network.chainId; + if (chainId !== currentChainId) { + throw Error( + `Failed to verify chain coordinates: credentials chainID=${chainId} is not equal to registryContract chainID=${currentChainId}` + ); + } + } + + /** + * Generates an identity credential from a seed string + * This is a pure implementation that doesn't rely on Zerokit + * @param seed A string seed to generate the identity from + * @returns IdentityCredential + */ + private generateSeededIdentityCredential(seed: string): IdentityCredential { + log.info("Generating seeded identity credential"); + // Convert the seed to bytes + const encoder = new TextEncoder(); + const seedBytes = encoder.encode(seed); + + // Generate deterministic values using HMAC-SHA256 + // We use different context strings for each component to ensure they're different + const idTrapdoor = hmac(sha256, seedBytes, encoder.encode("IDTrapdoor")); + const idNullifier = hmac(sha256, seedBytes, encoder.encode("IDNullifier")); + + // Generate IDSecretHash as a hash of IDTrapdoor and IDNullifier + const combinedBytes = new Uint8Array([...idTrapdoor, ...idNullifier]); + const idSecretHash = sha256(combinedBytes); + + // Generate IDCommitment as a hash of IDSecretHash + const idCommitment = sha256(idSecretHash); + + // Convert IDCommitment to BigInt + const idCommitmentBigInt = buildBigIntFromUint8Array(idCommitment); + + log.info("Successfully generated identity credential"); + return new IdentityCredential( + idTrapdoor, + idNullifier, + idSecretHash, + idCommitment, + idCommitmentBigInt + ); + } +} diff --git a/packages/rln/src/index.ts b/packages/rln/src/index.ts index aa18f8e89c..73791f7938 100644 --- a/packages/rln/src/index.ts +++ b/packages/rln/src/index.ts @@ -1,19 +1,19 @@ import { RLNDecoder, RLNEncoder } from "./codec.js"; import { RLN_ABI } from "./contract/abi.js"; import { LINEA_CONTRACT, RLNContract } from "./contract/index.js"; -import { RLNLightContract } from "./contract/rln_light_contract.js"; +import { RLNBaseContract } from "./contract/rln_base_contract.js"; import { createRLN } from "./create.js"; +import { RLNCredentialsManager } from "./credentials_manager.js"; import { IdentityCredential } from "./identity.js"; import { Keystore } from "./keystore/index.js"; import { Proof } from "./proof.js"; import { RLNInstance } from "./rln.js"; -import { RLNLightInstance } from "./rln_light.js"; import { MerkleRootTracker } from "./root_tracker.js"; import { extractMetaMaskSigner } from "./utils/index.js"; export { - RLNLightInstance, - RLNLightContract, + RLNCredentialsManager, + RLNBaseContract, createRLN, Keystore, RLNInstance, diff --git a/packages/rln/src/rln.ts b/packages/rln/src/rln.ts index 1610f1c918..98878756aa 100644 --- a/packages/rln/src/rln.ts +++ b/packages/rln/src/rln.ts @@ -7,7 +7,6 @@ import type { import { Logger } from "@waku/utils"; import init from "@waku/zerokit-rln-wasm"; import * as zerokitRLN from "@waku/zerokit-rln-wasm"; -import { ethers } from "ethers"; import { createRLNDecoder, @@ -16,258 +15,51 @@ import { type RLNEncoder } from "./codec.js"; import { DEFAULT_RATE_LIMIT } from "./contract/constants.js"; -import { LINEA_CONTRACT, RLNContract } from "./contract/index.js"; -import { IdentityCredential } from "./identity.js"; -import { Keystore } from "./keystore/index.js"; +import { RLNCredentialsManager } from "./credentials_manager.js"; import type { DecryptedCredentials, EncryptedCredentials } from "./keystore/index.js"; -import { KeystoreEntity, Password } from "./keystore/types.js"; import verificationKey from "./resources/verification_key"; import * as wc from "./resources/witness_calculator"; import { WitnessCalculator } from "./resources/witness_calculator"; -import { extractMetaMaskSigner } from "./utils/index.js"; import { Zerokit } from "./zerokit.js"; const log = new Logger("waku:rln"); -async function loadWitnessCalculator(): Promise { - try { - const url = new URL("./resources/rln.wasm", import.meta.url); - const response = await fetch(url); - - if (!response.ok) { - throw new Error( - `Failed to fetch witness calculator: ${response.status} ${response.statusText}` - ); - } - - return await wc.builder( - new Uint8Array(await response.arrayBuffer()), - false - ); - } catch (error) { - log.error("Error loading witness calculator:", error); - throw new Error( - `Failed to load witness calculator: ${error instanceof Error ? error.message : String(error)}` - ); - } -} - -async function loadZkey(): Promise { - try { - const url = new URL("./resources/rln_final.zkey", import.meta.url); - const response = await fetch(url); - - if (!response.ok) { - throw new Error( - `Failed to fetch zkey: ${response.status} ${response.statusText}` - ); - } - - return new Uint8Array(await response.arrayBuffer()); - } catch (error) { - log.error("Error loading zkey:", error); - throw new Error( - `Failed to load zkey: ${error instanceof Error ? error.message : String(error)}` - ); - } -} - -/** - * Create an instance of RLN - * @returns RLNInstance - */ -export async function create(): Promise { - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (init as any)?.(); - zerokitRLN.init_panic_hook(); - - const witnessCalculator = await loadWitnessCalculator(); - const zkey = await loadZkey(); - - const stringEncoder = new TextEncoder(); - const vkey = stringEncoder.encode(JSON.stringify(verificationKey)); - - const DEPTH = 20; - const zkRLN = zerokitRLN.newRLN(DEPTH, zkey, vkey); - const zerokit = new Zerokit(zkRLN, witnessCalculator, DEFAULT_RATE_LIMIT); - - return new RLNInstance(zerokit); - } catch (error) { - log.error("Failed to initialize RLN:", error); - throw error; - } -} - -type StartRLNOptions = { - /** - * If not set - will extract MetaMask account and get signer from it. - */ - signer?: ethers.Signer; - /** - * If not set - will use default LINEA_CONTRACT address. - */ - address?: string; - /** - * Credentials to use for generating proofs and connecting to the contract and network. - * If provided used for validating the network chainId and connecting to registry contract. - */ - credentials?: EncryptedCredentials | DecryptedCredentials; - /** - * Rate limit for the member. - */ - rateLimit?: number; -}; - -type RegisterMembershipOptions = - | { signature: string } - | { identity: IdentityCredential }; - type WakuRLNEncoderOptions = WakuEncoderOptions & { credentials: EncryptedCredentials | DecryptedCredentials; }; -export class RLNInstance { - private started = false; - private starting = false; - - private _contract: undefined | RLNContract; - private _signer: undefined | ethers.Signer; - - private keystore = Keystore.create(); - private _credentials: undefined | DecryptedCredentials; - - public constructor(public zerokit: Zerokit) {} - - public get contract(): undefined | RLNContract { - return this._contract; - } - - public get signer(): undefined | ethers.Signer { - return this._signer; - } - - public async start(options: StartRLNOptions = {}): Promise { - if (this.started || this.starting) { - return; - } - - this.starting = true; - - try { - const { credentials, keystore } = - await RLNInstance.decryptCredentialsIfNeeded(options.credentials); - const { signer, address, rateLimit } = await this.determineStartOptions( - options, - credentials - ); - - if (keystore) { - this.keystore = keystore; - } - - this._credentials = credentials; - this._signer = signer!; - this._contract = await RLNContract.init(this, { - address: address!, - signer: signer!, - rateLimit: rateLimit ?? this.zerokit.getRateLimit - }); - this.started = true; - } finally { - this.starting = false; - } - } - - private async determineStartOptions( - options: StartRLNOptions, - credentials: KeystoreEntity | undefined - ): Promise { - let chainId = credentials?.membership.chainId; - const address = - credentials?.membership.address || - options.address || - LINEA_CONTRACT.address; - - if (address === LINEA_CONTRACT.address) { - chainId = LINEA_CONTRACT.chainId; - } - - const signer = options.signer || (await extractMetaMaskSigner()); - const currentChainId = (await signer.getChainId()).toString(); - - if (chainId && chainId !== currentChainId) { - throw Error( - `Failed to start RLN contract, chain ID of contract is different from current one: contract-${chainId}, current network-${currentChainId}` - ); - } - - return { - signer, - address - }; - } - - private static async decryptCredentialsIfNeeded( - credentials?: EncryptedCredentials | DecryptedCredentials - ): Promise<{ credentials?: DecryptedCredentials; keystore?: Keystore }> { - if (!credentials) { - return {}; - } - - if ("identity" in credentials) { - return { credentials }; - } - - const keystore = Keystore.fromString(credentials.keystore); - - if (!keystore) { - return {}; - } - - const decryptedCredentials = await keystore.readCredential( - credentials.id, - credentials.password - ); - - return { - keystore, - credentials: decryptedCredentials - }; - } - - public async registerMembership( - options: RegisterMembershipOptions - ): Promise { - if (!this.contract) { - throw Error("RLN Contract is not initialized."); - } - - let identity = "identity" in options && options.identity; - - if ("signature" in options) { - identity = this.zerokit.generateSeededIdentityCredential( - options.signature - ); - } - - if (!identity) { - throw Error("Missing signature or identity to register membership."); - } - - return this.contract.registerWithIdentity(identity); - } - +export class RLNInstance extends RLNCredentialsManager { /** - * Changes credentials in use by relying on provided Keystore earlier in rln.start - * @param id: string, hash of credentials to select from Keystore - * @param password: string or bytes to use to decrypt credentials from Keystore + * Create an instance of RLN + * @returns RLNInstance */ - public async useCredentials(id: string, password: Password): Promise { - this._credentials = await this.keystore?.readCredential(id, password); + public static async create(): Promise { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (init as any)?.(); + zerokitRLN.init_panic_hook(); + + const witnessCalculator = await RLNInstance.loadWitnessCalculator(); + const zkey = await RLNInstance.loadZkey(); + + const stringEncoder = new TextEncoder(); + const vkey = stringEncoder.encode(JSON.stringify(verificationKey)); + + const DEPTH = 20; + const zkRLN = zerokitRLN.newRLN(DEPTH, zkey, vkey); + const zerokit = new Zerokit(zkRLN, witnessCalculator, DEFAULT_RATE_LIMIT); + + return new RLNInstance(zerokit); + } catch (error) { + log.error("Failed to initialize RLN:", error); + throw error; + } + } + private constructor(public zerokit: Zerokit) { + super(zerokit); } public async createEncoder( @@ -275,7 +67,7 @@ export class RLNInstance { ): Promise { const { credentials: decryptedCredentials } = await RLNInstance.decryptCredentialsIfNeeded(options.credentials); - const credentials = decryptedCredentials || this._credentials; + const credentials = decryptedCredentials || this.credentials; if (!credentials) { throw Error( @@ -293,33 +85,6 @@ export class RLNInstance { }); } - private async verifyCredentialsAgainstContract( - credentials: KeystoreEntity - ): Promise { - if (!this._contract) { - throw Error( - "Failed to verify chain coordinates: no contract initialized." - ); - } - - const registryAddress = credentials.membership.address; - const currentRegistryAddress = this._contract.address; - if (registryAddress !== currentRegistryAddress) { - throw Error( - `Failed to verify chain coordinates: credentials contract address=${registryAddress} is not equal to registryContract address=${currentRegistryAddress}` - ); - } - - const chainId = credentials.membership.chainId; - const network = await this._contract.provider.getNetwork(); - const currentChainId = network.chainId.toString(); - if (chainId !== currentChainId) { - throw Error( - `Failed to verify chain coordinates: credentials chainID=${chainId} is not equal to registryContract chainID=${currentChainId}` - ); - } - } - public createDecoder( contentTopic: ContentTopic ): RLNDecoder { @@ -328,4 +93,47 @@ export class RLNInstance { decoder: createDecoder(contentTopic) }); } + + public static async loadWitnessCalculator(): Promise { + try { + const url = new URL("./resources/rln.wasm", import.meta.url); + const response = await fetch(url); + + if (!response.ok) { + throw new Error( + `Failed to fetch witness calculator: ${response.status} ${response.statusText}` + ); + } + + return await wc.builder( + new Uint8Array(await response.arrayBuffer()), + false + ); + } catch (error) { + log.error("Error loading witness calculator:", error); + throw new Error( + `Failed to load witness calculator: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + public static async loadZkey(): Promise { + try { + const url = new URL("./resources/rln_final.zkey", import.meta.url); + const response = await fetch(url); + + if (!response.ok) { + throw new Error( + `Failed to fetch zkey: ${response.status} ${response.statusText}` + ); + } + + return new Uint8Array(await response.arrayBuffer()); + } catch (error) { + log.error("Error loading zkey:", error); + throw new Error( + `Failed to load zkey: ${error instanceof Error ? error.message : String(error)}` + ); + } + } } diff --git a/packages/rln/src/rln_light.ts b/packages/rln/src/rln_light.ts deleted file mode 100644 index 1e9fc185e1..0000000000 --- a/packages/rln/src/rln_light.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { hmac } from "@noble/hashes/hmac"; -import { sha256 } from "@noble/hashes/sha256"; -import { Logger } from "@waku/utils"; -import { ethers } from "ethers"; - -import { LINEA_CONTRACT } from "./contract/constants.js"; -import { RLNLightContract } from "./contract/rln_light_contract.js"; -import { IdentityCredential } from "./identity.js"; -import { Keystore } from "./keystore/index.js"; -import type { - DecryptedCredentials, - EncryptedCredentials -} from "./keystore/index.js"; -import { KeystoreEntity, Password } from "./keystore/types.js"; -import { - buildBigIntFromUint8Array, - extractMetaMaskSigner -} from "./utils/index.js"; - -const log = new Logger("waku:rln"); - -/** - * Create an instance of RLN - * @returns RLNInstance - */ -export async function create(): Promise { - try { - return new RLNLightInstance(); - } catch (error) { - log.error("Failed to initialize RLN:", error); - throw error; - } -} - -type StartRLNOptions = { - /** - * If not set - will extract MetaMask account and get signer from it. - */ - signer?: ethers.Signer; - /** - * If not set - will use default SEPOLIA_CONTRACT address. - */ - address?: string; - /** - * Credentials to use for generating proofs and connecting to the contract and network. - * If provided used for validating the network chainId and connecting to registry contract. - */ - credentials?: EncryptedCredentials | DecryptedCredentials; - /** - * Rate limit for the member. - */ - rateLimit?: number; -}; - -type RegisterMembershipOptions = - | { signature: string } - | { identity: IdentityCredential }; - -export class RLNLightInstance { - private started = false; - private starting = false; - - private _contract: undefined | RLNLightContract; - private _signer: undefined | ethers.Signer; - - private keystore = Keystore.create(); - private _credentials: undefined | DecryptedCredentials; - - public constructor() {} - - public get contract(): undefined | RLNLightContract { - return this._contract; - } - - public get signer(): undefined | ethers.Signer { - return this._signer; - } - - public async start(options: StartRLNOptions = {}): Promise { - if (this.started || this.starting) { - return; - } - - this.starting = true; - - try { - const { credentials, keystore } = - await RLNLightInstance.decryptCredentialsIfNeeded(options.credentials); - const { signer, address, rateLimit } = await this.determineStartOptions( - options, - credentials - ); - - if (keystore) { - this.keystore = keystore; - } - - this._credentials = credentials; - this._signer = signer!; - this._contract = await RLNLightContract.init({ - address: address!, - signer: signer!, - rateLimit: rateLimit - }); - this.started = true; - } finally { - this.starting = false; - } - } - - public get credentials(): DecryptedCredentials | undefined { - return this._credentials; - } - - private async determineStartOptions( - options: StartRLNOptions, - credentials: KeystoreEntity | undefined - ): Promise { - let chainId = credentials?.membership.chainId; - const address = - credentials?.membership.address || - options.address || - LINEA_CONTRACT.address; - - if (address === LINEA_CONTRACT.address) { - chainId = LINEA_CONTRACT.chainId; - } - - const signer = options.signer || (await extractMetaMaskSigner()); - const currentChainId = (await signer.getChainId()).toString(); - - if (chainId && chainId !== currentChainId) { - throw Error( - `Failed to start RLN contract, chain ID of contract is different from current one: contract-${chainId}, current network-${currentChainId}` - ); - } - - return { - signer, - address - }; - } - - private static async decryptCredentialsIfNeeded( - credentials?: EncryptedCredentials | DecryptedCredentials - ): Promise<{ credentials?: DecryptedCredentials; keystore?: Keystore }> { - if (!credentials) { - return {}; - } - - if ("identity" in credentials) { - return { credentials }; - } - - const keystore = Keystore.fromString(credentials.keystore); - - if (!keystore) { - return {}; - } - - const decryptedCredentials = await keystore.readCredential( - credentials.id, - credentials.password - ); - - return { - keystore, - credentials: decryptedCredentials - }; - } - - /** - * Generates an identity credential from a seed string - * This is a pure implementation that doesn't rely on Zerokit - * @param seed A string seed to generate the identity from - * @returns IdentityCredential - */ - private generateSeededIdentityCredential(seed: string): IdentityCredential { - // Convert the seed to bytes - const encoder = new TextEncoder(); - const seedBytes = encoder.encode(seed); - - // Generate deterministic values using HMAC-SHA256 - // We use different context strings for each component to ensure they're different - const idTrapdoor = hmac(sha256, seedBytes, encoder.encode("IDTrapdoor")); - const idNullifier = hmac(sha256, seedBytes, encoder.encode("IDNullifier")); - - // Generate IDSecretHash as a hash of IDTrapdoor and IDNullifier - const combinedBytes = new Uint8Array([...idTrapdoor, ...idNullifier]); - const idSecretHash = sha256(combinedBytes); - - // Generate IDCommitment as a hash of IDSecretHash - const idCommitment = sha256(idSecretHash); - - // Convert IDCommitment to BigInt - const idCommitmentBigInt = buildBigIntFromUint8Array(idCommitment); - - return new IdentityCredential( - idTrapdoor, - idNullifier, - idSecretHash, - idCommitment, - idCommitmentBigInt - ); - } - - public async registerMembership( - options: RegisterMembershipOptions - ): Promise { - if (!this.contract) { - throw Error("RLN Contract is not initialized."); - } - - let identity = "identity" in options && options.identity; - - if ("signature" in options) { - identity = this.generateSeededIdentityCredential(options.signature); - } - - if (!identity) { - throw Error("Missing signature or identity to register membership."); - } - - return this.contract.registerWithIdentity(identity); - } - - /** - * Changes credentials in use by relying on provided Keystore earlier in rln.start - * @param id: string, hash of credentials to select from Keystore - * @param password: string or bytes to use to decrypt credentials from Keystore - */ - public async useCredentials(id: string, password: Password): Promise { - this._credentials = await this.keystore?.readCredential(id, password); - } -} diff --git a/packages/rln/src/types.ts b/packages/rln/src/types.ts new file mode 100644 index 0000000000..7980c8aea9 --- /dev/null +++ b/packages/rln/src/types.ts @@ -0,0 +1,31 @@ +import { ethers } from "ethers"; + +import { IdentityCredential } from "./identity.js"; +import { + DecryptedCredentials, + EncryptedCredentials +} from "./keystore/types.js"; + +export type StartRLNOptions = { + /** + * If not set - will extract MetaMask account and get signer from it. + */ + signer?: ethers.Signer; + /** + * If not set - will use default SEPOLIA_CONTRACT address. + */ + address?: string; + /** + * Credentials to use for generating proofs and connecting to the contract and network. + * If provided used for validating the network chainId and connecting to registry contract. + */ + credentials?: EncryptedCredentials | DecryptedCredentials; + /** + * Rate limit for the member. + */ + rateLimit?: number; +}; + +export type RegisterMembershipOptions = + | { signature: string } + | { identity: IdentityCredential };