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 2205c704f9..9393476709 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,6 +49,22 @@ export class RLNLightContract { contract } = options; + this.validateRateLimit(rateLimit); + + this.contract = contract || new ethers.Contract(address, RLN_ABI, signer); + this.rateLimit = rateLimit; + + // 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 @@ -94,16 +73,6 @@ export class RLNLightContract { `Rate limit must be between ${RATE_LIMIT_PARAMS.MIN_RATE} and ${RATE_LIMIT_PARAMS.MAX_RATE} messages per epoch` ); } - - this.rateLimit = rateLimit; - - // Use the injected contract if provided; otherwise, instantiate a new one. - this.contract = contract || new ethers.Contract(address, RLN_ABI, signer); - - // Initialize event filters - this._membersFilter = this.contract.filters.MembershipRegistered(); - this._membershipErasedFilter = this.contract.filters.MembershipErased(); - this._membersExpiredFilter = this.contract.filters.MembershipExpired(); } /** @@ -151,7 +120,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 +129,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 +141,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 +149,7 @@ export class RLNLightContract { * @param newRateLimit The new rate limit to use */ public async setRateLimit(newRateLimit: number): Promise { + this.validateRateLimit(newRateLimit); this.rateLimit = newRateLimit; } @@ -214,21 +182,30 @@ export class RLNLightContract { } 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 +215,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 +309,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 +394,133 @@ 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 + ); + } + + 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; + } + } + + 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 +633,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: { @@ -538,188 +704,4 @@ export class RLNLightContract { return undefined; } } - - 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 { - 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, []); - } - - 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/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 0aa0d81fb8..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 = Number(decodedData.index); - - return { - identity, - membership: { - address, - treeIndex: membershipId, - chainId: network.chainId, - 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 = ethers.BigNumber.from(decodedData.index).toNumber(); - - return { - identity, - membership: { - address, - treeIndex: membershipId, - chainId: network.chainId, - 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/credentials_manager.ts b/packages/rln/src/credentials_manager.ts index c0100e7486..a4e7681c55 100644 --- a/packages/rln/src/credentials_manager.ts +++ b/packages/rln/src/credentials_manager.ts @@ -4,7 +4,7 @@ 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 { RLNBaseContract } from "./contract/rln_base_contract.js"; import { IdentityCredential } from "./identity.js"; import { Keystore } from "./keystore/index.js"; import type { @@ -29,7 +29,7 @@ export class RLNCredentialsManager { private started = false; private starting = false; - private _contract: undefined | RLNLightContract; + private _contract: undefined | RLNBaseContract; private _signer: undefined | ethers.Signer; private keystore = Keystore.create(); @@ -39,7 +39,7 @@ export class RLNCredentialsManager { log.info("RLNCredentialsManager initialized"); } - public get contract(): undefined | RLNLightContract { + public get contract(): undefined | RLNBaseContract { return this._contract; } @@ -80,7 +80,7 @@ export class RLNCredentialsManager { this._credentials = credentials; this._signer = signer!; - this._contract = await RLNLightContract.init({ + this._contract = new RLNBaseContract({ address: address!, signer: signer!, rateLimit: rateLimit diff --git a/packages/rln/src/index.ts b/packages/rln/src/index.ts index 0d7e825e3d..73791f7938 100644 --- a/packages/rln/src/index.ts +++ b/packages/rln/src/index.ts @@ -1,7 +1,7 @@ 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"; @@ -13,7 +13,7 @@ import { extractMetaMaskSigner } from "./utils/index.js"; export { RLNCredentialsManager, - RLNLightContract, + RLNBaseContract, createRLN, Keystore, RLNInstance,