From 4adf8706c3befd99ace8f02dc2a1350428d4a163 Mon Sep 17 00:00:00 2001 From: Danish Arora <35004822+danisharora099@users.noreply.github.com> Date: Mon, 7 Apr 2025 16:04:06 +0530 Subject: [PATCH] feat(rln): create `CredentialsManager` without Zerokit (#2295) * chore: update ABI * chore: update contract address and chain ID to Linea Sepolia * chore: improve error handling * fix: bigint conversion * chore: update tests * chore: clean comments * chore: export keystore types * chore: update README with contract address * tests: add reusable mock functions * chore: LINEA_CONTRACT instead of SEPOLIA_CONTRACT * chore: add linea to cspell * chore: add rateLimit to MembershipInfo * fix: determine start options * feat: RLNLight * chore: fix * chore: export * fix: returns for ratelimit * chore: big number conversions * chore: rename RLNLight to CredentialsManager, add logs * chore: setup and use rln_base_contract * chore: use CredentialsManager for rln.ts * chore: public methods written above private methods * fix: rate limit getter * chore: simplify getters/setters * chore: insert empty line --- package-lock.json | 34 + packages/rln/package.json | 1 + .../rln/src/contract/rln_base_contract.ts | 707 ++++++++++++++++++ .../rln/src/contract/rln_contract.spec.ts | 2 +- packages/rln/src/contract/rln_contract.ts | 672 +---------------- packages/rln/src/contract/types.ts | 48 ++ packages/rln/src/create.ts | 2 +- packages/rln/src/credentials_manager.ts | 282 +++++++ packages/rln/src/index.ts | 4 + packages/rln/src/rln.ts | 337 ++------- packages/rln/src/types.ts | 31 + packages/rln/src/zerokit.ts | 6 +- 12 files changed, 1194 insertions(+), 932 deletions(-) create mode 100644 packages/rln/src/contract/rln_base_contract.ts create mode 100644 packages/rln/src/contract/types.ts create mode 100644 packages/rln/src/credentials_manager.ts create mode 100644 packages/rln/src/types.ts 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_base_contract.ts b/packages/rln/src/contract/rln_base_contract.ts new file mode 100644 index 0000000000..2c46810fbe --- /dev/null +++ b/packages/rln/src/contract/rln_base_contract.ts @@ -0,0 +1,707 @@ +import { Logger } from "@waku/utils"; +import { ethers } from "ethers"; + +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:base"); + +export class RLNBaseContract { + public contract: ethers.Contract; + private deployBlock: undefined | number; + private rateLimit: number; + + protected _members: Map = new Map(); + private _membersFilter: ethers.EventFilter; + private _membershipErasedFilter: ethers.EventFilter; + private _membersExpiredFilter: ethers.EventFilter; + + /** + * Constructor for RLNBaseContract. + * Allows injecting a mocked contract for testing purposes. + */ + public constructor(options: RLNContractInitOptions) { + // Initialize members and subscriptions + this.fetchMembers() + .then(() => { + this.subscribeToMembers(); + }) + .catch((error) => { + log.error("Failed to initialize members", { error }); + }); + + const { + address, + signer, + rateLimit = DEFAULT_RATE_LIMIT, + 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(); + } + + /** + * 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; + } + + public async fetchMembers(options: FetchMembersOptions = {}): Promise { + 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, + ...removedMemberEvents, + ...expiredMemberEvents + ]; + 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(); + + 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 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, + ( + _idCommitment: string, + _membershipRateLimit: ethers.BigNumber, + _index: ethers.BigNumber, + event: ethers.Event + ) => { + this.processEvents([event]); + } + ); + + this.contract.on( + this.membershipErasedFilter, + ( + _idCommitment: string, + _membershipRateLimit: ethers.BigNumber, + _index: ethers.BigNumber, + event: ethers.Event + ) => { + this.processEvents([event]); + } + ); + + this.contract.on( + this.membersExpiredFilter, + ( + _idCommitment: string, + _membershipRateLimit: ethers.BigNumber, + _index: ethers.BigNumber, + event: ethers.Event + ) => { + this.processEvents([event]); + } + ); + } + + /** + * 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 { + 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 + }); + } + } + } + + 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, + rateLimit: decodedData.membershipRateLimit.toNumber() + } + }; + } catch (error) { + log.error( + `Error in registerWithPermitAndErase: ${(error as Error).message}` + ); + return undefined; + } + } + + /** + * 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` + ); + } + } + + 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( + 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; + } + } +} 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 863a9ee12c..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 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.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/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..fa3b9cee80 --- /dev/null +++ b/packages/rln/src/credentials_manager.ts @@ -0,0 +1,282 @@ +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; + + public contract: undefined | RLNBaseContract; + public signer: undefined | ethers.Signer; + + protected keystore = Keystore.create(); + public credentials: undefined | DecryptedCredentials; + + public zerokit: undefined | Zerokit; + + public constructor(zerokit?: Zerokit) { + log.info("RLNCredentialsManager initialized"); + this.zerokit = zerokit; + } + + 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?.rateLimit + }); + + 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 da12a61ca3..73791f7938 100644 --- a/packages/rln/src/index.ts +++ b/packages/rln/src/index.ts @@ -1,7 +1,9 @@ import { RLNDecoder, RLNEncoder } from "./codec.js"; import { RLN_ABI } from "./contract/abi.js"; import { LINEA_CONTRACT, RLNContract } from "./contract/index.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"; @@ -10,6 +12,8 @@ import { MerkleRootTracker } from "./root_tracker.js"; import { extractMetaMaskSigner } from "./utils/index.js"; export { + RLNCredentialsManager, + RLNBaseContract, createRLN, Keystore, RLNInstance, diff --git a/packages/rln/src/rln.ts b/packages/rln/src/rln.ts index e908fa01fe..ba709ed57c 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,52 @@ 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 } = 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: options.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(); - - 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 +68,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 +86,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; - 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 +94,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/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 }; diff --git a/packages/rln/src/zerokit.ts b/packages/rln/src/zerokit.ts index afcb6d4862..6fd9bd45f1 100644 --- a/packages/rln/src/zerokit.ts +++ b/packages/rln/src/zerokit.ts @@ -16,7 +16,7 @@ export class Zerokit { public constructor( private readonly zkRLN: number, private readonly witnessCalculator: WitnessCalculator, - private readonly rateLimit: number = DEFAULT_RATE_LIMIT + private readonly _rateLimit: number = DEFAULT_RATE_LIMIT ) {} public get getZkRLN(): number { @@ -27,8 +27,8 @@ export class Zerokit { return this.witnessCalculator; } - public get getRateLimit(): number { - return this.rateLimit; + public get rateLimit(): number { + return this._rateLimit; } public generateIdentityCredentials(): IdentityCredential {