diff --git a/package-lock.json b/package-lock.json index c6559abab1..e6df9fbf92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6915,6 +6915,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.1.tgz", "integrity": "sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg==", + "dev": true, "license": "ISC", "dependencies": { "semver": "^7.3.5" @@ -14138,6 +14139,7 @@ "version": "18.0.4", "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.4.tgz", "integrity": "sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ==", + "dev": true, "license": "ISC", "dependencies": { "@npmcli/fs": "^3.1.0", @@ -14161,6 +14163,7 @@ "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -14181,12 +14184,14 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, "license": "ISC" }, "node_modules/cacache/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -14202,6 +14207,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, "license": "MIT", "dependencies": { "aggregate-error": "^3.0.0" @@ -14677,6 +14683,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -20169,6 +20176,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, "license": "ISC", "dependencies": { "minipass": "^7.0.3" @@ -21068,6 +21076,7 @@ "version": "3.0.8", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-3.0.8.tgz", "integrity": "sha512-aXpmwoOhRBrw6X3j0h5RloK4x1OzsxMPyxqIHyNfSe2pypkVTZFpEiRoSipPEPlMrh0HW/XsjkJ5WgnCirpNUw==", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" @@ -21080,6 +21089,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -27183,6 +27193,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, "license": "ISC", "dependencies": { "minipass": "^7.0.3" @@ -27195,6 +27206,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, "license": "ISC", "dependencies": { "minipass": "^3.0.0" @@ -27207,6 +27219,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -27219,6 +27232,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, "license": "ISC", "dependencies": { "minipass": "^3.0.0" @@ -27231,6 +27245,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -27243,6 +27258,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, "license": "MIT", "dependencies": { "minipass": "^3.0.0", @@ -27256,6 +27272,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -28103,6 +28120,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-7.0.0.tgz", "integrity": "sha512-xXxr8y5U0kl8dVkz2oK7yZjPBvqM2fwaO5l3Yg13p03v8+E3qQcD0JNhHzjL1vyGgxcKkD0cco+NLR72iuPk3g==", + "dev": true, "license": "ISC", "dependencies": { "hosted-git-info": "^3.0.2", @@ -28115,12 +28133,14 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/builtins/-/builtins-1.0.3.tgz", "integrity": "sha512-uYBjakWipfaO/bXI7E8rq6kpwHRZK5cNYrUv2OzZSI/FvmdMyXJ2tG9dKcjEC5YHmHpUAwsargWIZNWdxb/bnQ==", + "dev": true, "license": "MIT" }, "node_modules/npm-package-arg/node_modules/semver": { "version": "5.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver" @@ -28130,6 +28150,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-3.0.0.tgz", "integrity": "sha512-M6w37eVCMMouJ9V/sdPGnC5H4uDr73/+xdq0FBLO3TFFX1+7wiUY6Es328NN+y43tmY+doUdN9g9J21vqB7iLw==", + "dev": true, "license": "ISC", "dependencies": { "builtins": "^1.0.3" @@ -31815,6 +31836,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -31834,6 +31856,7 @@ "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", "deprecated": "This package is no longer supported.", + "dev": true, "license": "ISC", "dependencies": { "os-homedir": "^1.0.0", @@ -34050,6 +34073,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", + "dev": true, "license": "ISC", "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" @@ -34649,6 +34673,7 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.11.0.tgz", "integrity": "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==", + "dev": true, "bin": { "qrcode-terminal": "bin/qrcode-terminal.js" } @@ -38569,6 +38594,7 @@ "version": "10.0.6", "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.6.tgz", "integrity": "sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==", + "dev": true, "license": "ISC", "dependencies": { "minipass": "^7.0.3" @@ -39298,6 +39324,7 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, "license": "ISC", "dependencies": { "chownr": "^2.0.0", @@ -39342,6 +39369,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, "license": "ISC", "dependencies": { "minipass": "^3.0.0" @@ -39354,6 +39382,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -39366,6 +39395,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, "license": "ISC", "engines": { "node": ">=8" @@ -39375,6 +39405,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, "license": "MIT", "bin": { "mkdirp": "bin/cmd.js" @@ -40697,6 +40728,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz", "integrity": "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==", + "dev": true, "license": "ISC", "dependencies": { "unique-slug": "^4.0.0" @@ -40709,6 +40741,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-4.0.0.tgz", "integrity": "sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==", + "dev": true, "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4" @@ -42292,6 +42325,7 @@ "license": "MIT OR Apache-2.0", "dependencies": { "@chainsafe/bls-keystore": "3.0.0", + "@noble/hashes": "^1.2.0", "@waku/core": "^0.0.34", "@waku/utils": "^0.0.22", "@waku/zerokit-rln-wasm": "^0.0.13", diff --git a/packages/rln/package.json b/packages/rln/package.json index a0035b2342..53f89e96d0 100644 --- a/packages/rln/package.json +++ b/packages/rln/package.json @@ -77,6 +77,7 @@ "@chainsafe/bls-keystore": "3.0.0", "@waku/core": "^0.0.34", "@waku/utils": "^0.0.22", + "@noble/hashes": "^1.2.0", "@waku/zerokit-rln-wasm": "^0.0.13", "ethereum-cryptography": "^3.1.0", "ethers": "^5.7.2", diff --git a/packages/rln/src/contract/rln_light_contract.ts b/packages/rln/src/contract/rln_light_contract.ts new file mode 100644 index 0000000000..49f1a89e52 --- /dev/null +++ b/packages/rln/src/contract/rln_light_contract.ts @@ -0,0 +1,724 @@ +import { Logger } from "@waku/utils"; +import { ethers } from "ethers"; + +import type { IdentityCredential } from "../identity.js"; +import type { DecryptedCredentials } from "../keystore/index.js"; +import { RLNLightInstance } from "../rln_light.js"; + +import { RLN_ABI } from "./abi.js"; +import { DEFAULT_RATE_LIMIT, RATE_LIMIT_PARAMS } from "./constants.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 RLNLightContract { + public contract: ethers.Contract; + + 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. + */ + public static async init( + rlnLightInstance: RLNLightInstance, + options: RLNContractInitOptions + ): Promise { + const rlnContract = new RLNLightContract(options); + + await rlnContract.fetchMembers(rlnLightInstance); + rlnContract.subscribeToMembers(rlnLightInstance); + + return rlnContract; + } + + private constructor(options: RLNContractInitOptions) { + const { + address, + signer, + rateLimit = DEFAULT_RATE_LIMIT, + 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.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(); + } + + /** + * 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 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 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.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( + rlnLightInstance: RLNLightInstance, + 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(rlnLightInstance, events); + } + + public processEvents( + rlnLightInstance: RLNLightInstance, + events: ethers.Event[] + ): void { + const toRemoveTable = new Map(); + const toInsertTable = new Map(); + + events.forEach((evt) => { + if (!evt.args) { + return; + } + + if ( + evt.event === "MembershipErased" || + evt.event === "MembershipExpired" + ) { + let index = evt.args.index; + + if (!index) { + return; + } + + if (typeof index === "number" || typeof index === "string") { + index = ethers.BigNumber.from(index); + } + + const toRemoveVal = toRemoveTable.get(evt.blockNumber); + if (toRemoveVal != undefined) { + toRemoveVal.push(index.toNumber()); + toRemoveTable.set(evt.blockNumber, toRemoveVal); + } else { + toRemoveTable.set(evt.blockNumber, [index.toNumber()]); + } + } else if (evt.event === "MembershipRegistered") { + let eventsPerBlock = toInsertTable.get(evt.blockNumber); + if (eventsPerBlock == undefined) { + eventsPerBlock = []; + } + + eventsPerBlock.push(evt); + toInsertTable.set(evt.blockNumber, eventsPerBlock); + } + }); + } + + public subscribeToMembers(rlnLightInstance: RLNLightInstance): void { + this.contract.on( + this.membersFilter, + ( + _idCommitment: string, + _membershipRateLimit: ethers.BigNumber, + _index: ethers.BigNumber, + event: ethers.Event + ) => { + this.processEvents(rlnLightInstance, [event]); + } + ); + + this.contract.on( + this.membershipErasedFilter, + ( + _idCommitment: string, + _membershipRateLimit: ethers.BigNumber, + _index: ethers.BigNumber, + event: ethers.Event + ) => { + this.processEvents(rlnLightInstance, [event]); + } + ); + + this.contract.on( + this.membersExpiredFilter, + ( + _idCommitment: string, + _membershipRateLimit: ethers.BigNumber, + _index: ethers.BigNumber, + event: ethers.Event + ) => { + this.processEvents(rlnLightInstance, [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 + } + }; + } 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 = Number(decodedData.index); + + return { + identity, + membership: { + address, + treeIndex: membershipId, + chainId: network.chainId + } + }; + } catch (error) { + log.error( + `Error in registerWithPermitAndErase: ${(error as Error).message}` + ); + 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/rln_light.ts b/packages/rln/src/rln_light.ts new file mode 100644 index 0000000000..08e76427dc --- /dev/null +++ b/packages/rln/src/rln_light.ts @@ -0,0 +1,235 @@ +import { hmac } from "@noble/hashes/hmac"; +import { sha256 } from "@noble/hashes/sha256"; +import { Logger } from "@waku/utils"; +import { ethers } from "ethers"; + +import { SEPOLIA_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(this, { + 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 || + SEPOLIA_CONTRACT.address; + + if (address === SEPOLIA_CONTRACT.address) { + chainId = SEPOLIA_CONTRACT.chainId; + } + + const signer = options.signer || (await extractMetaMaskSigner()); + const currentChainId = await signer.getChainId(); + + 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); + } +}