diff --git a/.cspell.json b/.cspell.json index 125ad6ab84..1bc9243e54 100644 --- a/.cspell.json +++ b/.cspell.json @@ -9,6 +9,7 @@ "Alives", "alphabeta", "Arraylike", + "arrayify", "asym", "autoshard", "autosharding", diff --git a/package-lock.json b/package-lock.json index 87fe8b8d59..275649e54a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42384,6 +42384,7 @@ "@types/chai-spies": "^1.0.6", "@types/deep-equal-in-any-order": "^1.0.4", "@types/lodash": "^4.17.15", + "@types/sinon": "^17.0.3", "@waku/build-utils": "^1.0.0", "@waku/message-encryption": "^0.0.31", "chai": "^5.1.2", @@ -42392,7 +42393,8 @@ "chai-subset": "^1.6.0", "deep-equal-in-any-order": "^2.0.6", "fast-check": "^3.23.2", - "rollup-plugin-copy": "^3.5.0" + "rollup-plugin-copy": "^3.5.0", + "sinon": "^19.0.2" }, "engines": { "node": ">=20" @@ -42483,6 +42485,16 @@ "@scure/base": "~1.1.0" } }, + "packages/rln/node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, "packages/rln/node_modules/@types/chai": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.0.1.tgz", @@ -42553,6 +42565,16 @@ "node": ">=6" } }, + "packages/rln/node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "packages/rln/node_modules/ethers": { "version": "5.7.2", "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.7.2.tgz", @@ -42618,6 +42640,25 @@ "node": ">= 14.16" } }, + "packages/rln/node_modules/sinon": { + "version": "19.0.2", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-19.0.2.tgz", + "integrity": "sha512-euuToqM+PjO4UgXeLETsfQiuoyPXlqFezr6YZDFwHR3t4qaX0fZUe1MfPMznTL5f8BWrVS89KduLdMUsxFCO6g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.2", + "@sinonjs/samsam": "^8.0.1", + "diff": "^7.0.0", + "nise": "^6.1.1", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, "packages/rln/node_modules/uuid": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", diff --git a/packages/core/src/lib/light_push/utils.ts b/packages/core/src/lib/light_push/utils.ts index 9f33572cff..33b26ad615 100644 --- a/packages/core/src/lib/light_push/utils.ts +++ b/packages/core/src/lib/light_push/utils.ts @@ -1,7 +1,7 @@ // should match nwaku // https://github.com/waku-org/nwaku/blob/c3cb06ac6c03f0f382d3941ea53b330f6a8dd127/waku/waku_rln_relay/rln_relay.nim#L309 // https://github.com/waku-org/nwaku/blob/c3cb06ac6c03f0f382d3941ea53b330f6a8dd127/tests/waku_rln_relay/rln/waku_rln_relay_utils.nim#L20 -const RLN_GENERATION_PREFIX_ERROR = "could not generate rln-v2 proof"; +const RLN_GENERATION_PREFIX_ERROR = "could not generate rln proof"; const RLN_MESSAGE_ID_PREFIX_ERROR = "could not get new message id to generate an rln proof"; diff --git a/packages/rln/karma.conf.cjs b/packages/rln/karma.conf.cjs index b4db5d6c2e..f922aa1d9c 100644 --- a/packages/rln/karma.conf.cjs +++ b/packages/rln/karma.conf.cjs @@ -47,14 +47,15 @@ module.exports = function (config) { client: { mocha: { - timeout: 180000 // 3 minutes + timeout: 300000 // 5 minutes } }, - browserDisconnectTimeout: 180000, // 3 minutes + browserDisconnectTimeout: 300000, // 5 minutes browserDisconnectTolerance: 3, // Number of tries before failing - browserNoActivityTimeout: 180000, // 3 minutes + browserNoActivityTimeout: 300000, // 5 minutes captureTimeout: 300000, // 5 minutes + pingTimeout: 300000, // 5 minutes mime: { "application/wasm": ["wasm"], diff --git a/packages/rln/package.json b/packages/rln/package.json index 48afe9f18d..46ae430bd8 100644 --- a/packages/rln/package.json +++ b/packages/rln/package.json @@ -56,6 +56,7 @@ "@types/chai-spies": "^1.0.6", "@types/deep-equal-in-any-order": "^1.0.4", "@types/lodash": "^4.17.15", + "@types/sinon": "^17.0.3", "@waku/build-utils": "^1.0.0", "@waku/message-encryption": "^0.0.31", "chai": "^5.1.2", @@ -64,7 +65,8 @@ "chai-subset": "^1.6.0", "deep-equal-in-any-order": "^2.0.6", "fast-check": "^3.23.2", - "rollup-plugin-copy": "^3.5.0" + "rollup-plugin-copy": "^3.5.0", + "sinon": "^19.0.2" }, "files": [ "dist", diff --git a/packages/rln/src/contract/abi.ts b/packages/rln/src/contract/abi.ts new file mode 100644 index 0000000000..748e23f04f --- /dev/null +++ b/packages/rln/src/contract/abi.ts @@ -0,0 +1,392 @@ +export const RLN_ABI = [ + { + type: "constructor", + inputs: [], + stateMutability: "nonpayable" + }, + { + type: "error", + name: "DuplicateIdCommitment", + inputs: [] + }, + { + type: "error", + name: "InvalidIdCommitment", + inputs: [ + { + name: "idCommitment", + type: "uint256" + } + ] + }, + { + type: "error", + name: "InvalidPaginationQuery", + inputs: [ + { + name: "startIndex", + type: "uint256" + }, + { + name: "endIndex", + type: "uint256" + } + ] + }, + { + type: "function", + name: "MAX_MEMBERSHIP_SET_SIZE", + inputs: [], + outputs: [ + { + name: "", + type: "uint32" + } + ], + stateMutability: "view" + }, + { + type: "function", + name: "MERKLE_TREE_DEPTH", + inputs: [], + outputs: [ + { + name: "", + type: "uint8" + } + ], + stateMutability: "view" + }, + { + type: "function", + name: "Q", + inputs: [], + outputs: [ + { + name: "", + type: "uint256" + } + ], + stateMutability: "view" + }, + { + type: "function", + name: "activeDurationForNewMemberships", + inputs: [], + outputs: [ + { + name: "", + type: "uint32" + } + ], + stateMutability: "view" + }, + { + type: "function", + name: "currentTotalRateLimit", + inputs: [], + outputs: [ + { + name: "", + type: "uint256" + } + ], + stateMutability: "view" + }, + { + type: "function", + name: "deployedBlockNumber", + inputs: [], + outputs: [ + { + name: "", + type: "uint32" + } + ], + stateMutability: "view" + }, + { + type: "function", + name: "depositsToWithdraw", + inputs: [ + { + name: "holder", + type: "address" + }, + { + name: "token", + type: "address" + } + ], + outputs: [ + { + name: "balance", + type: "uint256" + } + ], + stateMutability: "view" + }, + { + type: "function", + name: "eraseMemberships", + inputs: [ + { + name: "idCommitments", + type: "uint256[]" + } + ], + outputs: [], + stateMutability: "nonpayable" + }, + { + type: "function", + name: "eraseMemberships", + inputs: [ + { + name: "idCommitments", + type: "uint256[]" + }, + { + name: "eraseFromMembershipSet", + type: "bool" + } + ], + outputs: [], + stateMutability: "nonpayable" + }, + { + type: "function", + name: "extendMemberships", + inputs: [ + { + name: "idCommitments", + type: "uint256[]" + } + ], + outputs: [], + stateMutability: "nonpayable" + }, + { + type: "function", + name: "getMembershipInfo", + inputs: [ + { + name: "idCommitment", + type: "uint256" + } + ], + outputs: [ + { + name: "", + type: "uint32" + }, + { + name: "", + type: "uint32" + }, + { + name: "", + type: "uint256" + } + ], + stateMutability: "view" + }, + { + type: "function", + name: "getMerkleProof", + inputs: [ + { + name: "index", + type: "uint40" + } + ], + outputs: [ + { + name: "", + type: "uint256[20]" + } + ], + stateMutability: "view" + }, + { + type: "function", + name: "getRateCommitmentsInRangeBoundsInclusive", + inputs: [ + { + name: "startIndex", + type: "uint32" + }, + { + name: "endIndex", + type: "uint32" + } + ], + outputs: [ + { + name: "", + type: "uint256[]" + } + ], + stateMutability: "view" + }, + { + type: "function", + name: "gracePeriodDurationForNewMemberships", + inputs: [], + outputs: [ + { + name: "", + type: "uint32" + } + ], + stateMutability: "view" + }, + { + type: "function", + name: "initialize", + inputs: [ + { + name: "_priceCalculator", + type: "address" + }, + { + name: "_maxTotalRateLimit", + type: "uint32" + }, + { + name: "_minMembershipRateLimit", + type: "uint32" + }, + { + name: "_maxMembershipRateLimit", + type: "uint32" + }, + { + name: "_activeDuration", + type: "uint32" + }, + { + name: "_gracePeriod", + type: "uint32" + } + ], + outputs: [], + stateMutability: "nonpayable" + }, + { + type: "function", + name: "isExpired", + inputs: [ + { + name: "_idCommitment", + type: "uint256" + } + ], + outputs: [ + { + name: "", + type: "bool" + } + ], + stateMutability: "view" + }, + { + type: "function", + name: "register", + inputs: [ + { + name: "idCommitment", + type: "uint256" + }, + { + name: "rateLimit", + type: "uint32" + }, + { + name: "idCommitmentsToErase", + type: "uint256[]" + } + ], + outputs: [], + stateMutability: "nonpayable" + }, + { + type: "function", + name: "registerWithPermit", + inputs: [ + { + name: "owner", + type: "address" + }, + { + name: "deadline", + type: "uint256" + }, + { + name: "v", + type: "uint8" + }, + { + name: "r", + type: "bytes32" + }, + { + name: "s", + type: "bytes32" + }, + { + name: "idCommitment", + type: "uint256" + }, + { + name: "rateLimit", + type: "uint32" + }, + { + name: "idCommitmentsToErase", + type: "uint256[]" + } + ], + outputs: [], + stateMutability: "nonpayable" + }, + { + type: "event", + name: "MembershipRegistered", + inputs: [ + { + name: "idCommitment", + type: "uint256", + indexed: false + }, + { + name: "rateLimit", + type: "uint32", + indexed: false + }, + { + name: "index", + type: "uint256", + indexed: false + } + ], + anonymous: false + }, + { + type: "event", + name: "MembershipRemoved", + inputs: [ + { + name: "idCommitment", + type: "uint256", + indexed: false + }, + { + name: "index", + type: "uint256", + indexed: false + } + ], + anonymous: false + } +]; diff --git a/packages/rln/src/contract/constants.ts b/packages/rln/src/contract/constants.ts index f884ce7033..3d26b99639 100644 --- a/packages/rln/src/contract/constants.ts +++ b/packages/rln/src/contract/constants.ts @@ -1,68 +1,28 @@ -// ref https://github.com/waku-org/waku-rln-contract/blob/19fded82bca07e7b535b429dc507cfb83f10dfcf/deployments/sepolia/WakuRlnRegistry_Implementation.json#L3 -export const RLN_REGISTRY_ABI = [ - "error IncompatibleStorage()", - "error IncompatibleStorageIndex()", - "error NoStorageContractAvailable()", - "error StorageAlreadyExists(address storageAddress)", - "event AdminChanged(address previousAdmin, address newAdmin)", - "event BeaconUpgraded(address indexed beacon)", - "event Initialized(uint8 version)", - "event NewStorageContract(uint16 index, address storageAddress)", - "event OwnershipTransferred(address indexed previousOwner, address indexed newOwner)", - "event Upgraded(address indexed implementation)", - "function forceProgress()", - "function initialize(address _poseidonHasher)", - "function newStorage()", - "function nextStorageIndex() view returns (uint16)", - "function owner() view returns (address)", - "function poseidonHasher() view returns (address)", - "function proxiableUUID() view returns (bytes32)", - "function register(uint16 storageIndex, uint256 commitment)", - "function register(uint256[] commitments)", - "function register(uint16 storageIndex, uint256[] commitments)", - "function registerStorage(address storageAddress)", - "function renounceOwnership()", - "function storages(uint16) view returns (address)", - "function transferOwnership(address newOwner)", - "function upgradeTo(address newImplementation)", - "function upgradeToAndCall(address newImplementation, bytes data) payable", - "function usingStorageIndex() view returns (uint16)" -]; - -// ref https://github.com/waku-org/waku-rln-contract/blob/19fded82bca07e7b535b429dc507cfb83f10dfcf/deployments/sepolia/WakuRlnStorage_0.json#L3 -export const RLN_STORAGE_ABI = [ - "constructor(address _poseidonHasher, uint16 _contractIndex)", - "error DuplicateIdCommitment()", - "error FullTree()", - "error InvalidIdCommitment(uint256 idCommitment)", - "error NotImplemented()", - "event MemberRegistered(uint256 idCommitment, uint256 index)", - "event MemberWithdrawn(uint256 idCommitment, uint256 index)", - "event OwnershipTransferred(address indexed previousOwner, address indexed newOwner)", - "function DEPTH() view returns (uint256)", - "function MEMBERSHIP_DEPOSIT() view returns (uint256)", - "function SET_SIZE() view returns (uint256)", - "function contractIndex() view returns (uint16)", - "function deployedBlockNumber() view returns (uint32)", - "function idCommitmentIndex() view returns (uint256)", - "function isValidCommitment(uint256 idCommitment) view returns (bool)", - "function memberExists(uint256) view returns (bool)", - "function members(uint256) view returns (uint256)", - "function owner() view returns (address)", - "function poseidonHasher() view returns (address)", - "function register(uint256[] idCommitments)", - "function register(uint256 idCommitment) payable", - "function renounceOwnership()", - "function slash(uint256 idCommitment, address receiver, uint256[8] proof) pure", - "function stakedAmounts(uint256) view returns (uint256)", - "function transferOwnership(address newOwner)", - "function verifier() view returns (address)", - "function withdraw() pure", - "function withdrawalBalance(address) view returns (uint256)" -]; +import { RLN_ABI } from "./abi.js"; export const SEPOLIA_CONTRACT = { chainId: 11155111, - address: "0xF471d71E9b1455bBF4b85d475afb9BB0954A29c4", - abi: RLN_REGISTRY_ABI + address: "0xCB33Aa5B38d79E3D9Fa8B10afF38AA201399a7e3", + abi: RLN_ABI }; + +/** + * Rate limit tiers (messages per epoch) + * Each membership can specify a rate limit within these bounds. + * @see https://github.com/waku-org/specs/blob/master/standards/core/rln-contract.md#implementation-suggestions + */ +export const RATE_LIMIT_TIERS = { + LOW: 20, // Suggested minimum rate - 20 messages per epoch + MEDIUM: 200, + HIGH: 600 // Suggested maximum rate - 600 messages per epoch +} as const; + +// Global rate limit parameters +export const RATE_LIMIT_PARAMS = { + MIN_RATE: RATE_LIMIT_TIERS.LOW, + MAX_RATE: RATE_LIMIT_TIERS.HIGH, + MAX_TOTAL_RATE: 160_000, // Maximum total rate limit across all memberships + EPOCH_LENGTH: 600 // Epoch length in seconds (10 minutes) +} as const; + +export const DEFAULT_RATE_LIMIT = RATE_LIMIT_PARAMS.MAX_RATE; diff --git a/packages/rln/src/contract/rln_contract.spec.ts b/packages/rln/src/contract/rln_contract.spec.ts index 69412b8a64..817444d3ed 100644 --- a/packages/rln/src/contract/rln_contract.spec.ts +++ b/packages/rln/src/contract/rln_contract.spec.ts @@ -1,43 +1,130 @@ -import { expect } from "chai"; +import { hexToBytes } from "@waku/utils/bytes"; +import { expect, use } from "chai"; +import chaiAsPromised from "chai-as-promised"; import * as ethers from "ethers"; +import sinon, { SinonSandbox } from "sinon"; import { createRLN } from "../create.js"; +import type { IdentityCredential } from "../identity.js"; -import { SEPOLIA_CONTRACT } from "./constants.js"; +import { DEFAULT_RATE_LIMIT, SEPOLIA_CONTRACT } from "./constants.js"; import { RLNContract } from "./rln_contract.js"; -describe("RLN Contract abstraction", () => { - it("should be able to fetch members from events and store to rln instance", async () => { - const rlnInstance = await createRLN(); - let insertMemberCalled = false; +use(chaiAsPromised); - // Track if insertMember was called - const originalInsertMember = rlnInstance.zerokit.insertMember; - rlnInstance.zerokit.insertMember = function ( - this: any, - ...args: Parameters - ) { - insertMemberCalled = true; - return originalInsertMember.apply(this, args); +describe("RLN Contract abstraction - RLN", () => { + let sandbox: SinonSandbox; + let rlnInstance: any; + let mockedRegistryContract: any; + + const mockRateLimits = { + minRate: 20, + maxRate: 600, + maxTotalRate: 1000, + currentTotalRate: 500 + }; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + rlnInstance = await createRLN(); + rlnInstance.zerokit.insertMember = () => undefined; + + mockedRegistryContract = { + minMembershipRateLimit: () => + Promise.resolve(ethers.BigNumber.from(mockRateLimits.minRate)), + maxMembershipRateLimit: () => + Promise.resolve(ethers.BigNumber.from(mockRateLimits.maxRate)), + maxTotalRateLimit: () => + Promise.resolve(ethers.BigNumber.from(mockRateLimits.maxTotalRate)), + currentTotalRateLimit: () => + Promise.resolve(ethers.BigNumber.from(mockRateLimits.currentTotalRate)), + queryFilter: () => [mockRLNRegisteredEvent()], + provider: { + getLogs: () => [], + getBlockNumber: () => Promise.resolve(1000), + getNetwork: () => Promise.resolve({ chainId: 11155111 }) + }, + filters: { + MembershipRegistered: () => ({}), + MembershipRemoved: () => ({}) + }, + on: () => ({}) }; - const voidSigner = new ethers.VoidSigner(SEPOLIA_CONTRACT.address); - const rlnContract = new RLNContract(rlnInstance, { - registryAddress: SEPOLIA_CONTRACT.address, - signer: voidSigner + const provider = new ethers.providers.JsonRpcProvider(); + const voidSigner = new ethers.VoidSigner( + SEPOLIA_CONTRACT.address, + provider + ); + await RLNContract.init(rlnInstance, { + address: SEPOLIA_CONTRACT.address, + signer: voidSigner, + rateLimit: DEFAULT_RATE_LIMIT, + contract: mockedRegistryContract as unknown as ethers.Contract + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should fetch members from events and store them in the RLN instance", async () => { + const rlnInstance = await createRLN(); + + const insertMemberSpy = sinon.stub(); + rlnInstance.zerokit.insertMember = insertMemberSpy; + + const membershipRegisteredEvent = mockRLNRegisteredEvent(); + + const queryFilterStub = sinon.stub().returns([membershipRegisteredEvent]); + const mockedRegistryContract = { + queryFilter: queryFilterStub, + provider: { + getLogs: () => [], + getBlockNumber: () => Promise.resolve(1000) + }, + interface: { + getEvent: (eventName: string) => ({ + name: eventName, + format: () => {} + }) + }, + filters: { + MembershipRegistered: () => ({}), + MembershipRemoved: () => ({}) + }, + on: () => ({}), + removeAllListeners: () => ({}) + }; + + const provider = new ethers.providers.JsonRpcProvider(); + const voidSigner = new ethers.VoidSigner( + SEPOLIA_CONTRACT.address, + provider + ); + const rlnContract = await RLNContract.init(rlnInstance, { + address: SEPOLIA_CONTRACT.address, + signer: voidSigner, + rateLimit: DEFAULT_RATE_LIMIT, + contract: mockedRegistryContract as unknown as ethers.Contract }); - rlnContract["storageContract"] = { - queryFilter: () => Promise.resolve([mockEvent()]) - } as unknown as ethers.Contract; - rlnContract["_membersFilter"] = { - address: "", - topics: [] - } as unknown as ethers.EventFilter; + await rlnContract.fetchMembers(rlnInstance, { + fromBlock: 0, + fetchRange: 1000, + fetchChunks: 2 + }); - await rlnContract.fetchMembers(rlnInstance); + expect( + insertMemberSpy.calledWith( + ethers.utils.zeroPad( + hexToBytes(membershipRegisteredEvent.args!.idCommitment), + 32 + ) + ) + ).to.be.true; - expect(insertMemberCalled).to.be.true; + expect(queryFilterStub.called).to.be.true; }); it("should register a member", async () => { @@ -45,38 +132,116 @@ describe("RLN Contract abstraction", () => { "0xdeb8a6b00a8e404deb1f52d3aa72ed7f60a2ff4484c737eedaef18a0aacb2dfb4d5d74ac39bb71fa358cf2eb390565a35b026cc6272f2010d4351e17670311c21c"; const rlnInstance = await createRLN(); - const voidSigner = new ethers.VoidSigner(SEPOLIA_CONTRACT.address); - const rlnContract = new RLNContract(rlnInstance, { - registryAddress: SEPOLIA_CONTRACT.address, - signer: voidSigner + const identity: IdentityCredential = + rlnInstance.zerokit.generateSeededIdentityCredential(mockSignature); + + const insertMemberSpy = sinon.stub(); + rlnInstance.zerokit.insertMember = insertMemberSpy; + + const formatIdCommitment = (idCommitmentBigInt: bigint): string => + "0x" + idCommitmentBigInt.toString(16).padStart(64, "0"); + + const membershipRegisteredEvent = mockRLNRegisteredEvent( + formatIdCommitment(identity.IDCommitmentBigInt) + ); + + const registerStub = sinon.stub().returns({ + wait: () => + Promise.resolve({ + events: [ + { + event: "MembershipRegistered", + args: { + idCommitment: formatIdCommitment(identity.IDCommitmentBigInt), + rateLimit: DEFAULT_RATE_LIMIT, + index: ethers.BigNumber.from(1) + } + } + ] + }) }); - let registerCalled = false; - rlnContract["storageIndex"] = 1; - rlnContract["_membersFilter"] = { - address: "", - topics: [] - } as unknown as ethers.EventFilter; - rlnContract["registryContract"] = { - "register(uint16,uint256)": () => { - registerCalled = true; - return Promise.resolve({ wait: () => Promise.resolve(undefined) }); - } - } as unknown as ethers.Contract; + const mockedRegistryContract = { + register: registerStub, + queryFilter: () => [membershipRegisteredEvent], + provider: { + getLogs: () => [], + getBlockNumber: () => Promise.resolve(1000), + getNetwork: () => Promise.resolve({ chainId: 11155111 }) + }, + address: SEPOLIA_CONTRACT.address, + interface: { + getEvent: (eventName: string) => ({ + name: eventName, + format: () => {} + }) + }, + filters: { + MembershipRegistered: () => ({}), + MembershipRemoved: () => ({}) + }, + on: () => ({}), + removeAllListeners: () => ({}) + }; - const identity = - rlnInstance.zerokit.generateSeededIdentityCredential(mockSignature); - await rlnContract.registerWithIdentity(identity); + const provider = new ethers.providers.JsonRpcProvider(); + const voidSigner = new ethers.VoidSigner( + SEPOLIA_CONTRACT.address, + provider + ); + const rlnContract = await RLNContract.init(rlnInstance, { + signer: voidSigner, + address: SEPOLIA_CONTRACT.address, + rateLimit: DEFAULT_RATE_LIMIT, + contract: mockedRegistryContract as unknown as ethers.Contract + }); - expect(registerCalled).to.be.true; + const decryptedCredentials = + await rlnContract.registerWithIdentity(identity); + + expect(decryptedCredentials).to.not.be.undefined; + if (!decryptedCredentials) { + throw new Error("Decrypted credentials should not be undefined"); + } + + expect( + registerStub.calledWith( + identity.IDCommitmentBigInt, + DEFAULT_RATE_LIMIT, + [], + { + gasLimit: 300000 + } + ) + ).to.be.true; + + expect(decryptedCredentials).to.have.property("identity"); + expect(decryptedCredentials).to.have.property("membership"); + expect(decryptedCredentials.membership).to.include({ + address: SEPOLIA_CONTRACT.address, + treeIndex: 1 + }); + + const expectedIdCommitment = ethers.utils.zeroPad( + hexToBytes(formatIdCommitment(identity.IDCommitmentBigInt)), + 32 + ); + expect(insertMemberSpy.callCount).to.equal(2); + expect(insertMemberSpy.getCall(1).args[0]).to.deep.equal( + expectedIdCommitment + ); }); }); -function mockEvent(): ethers.Event { +function mockRLNRegisteredEvent(idCommitment?: string): ethers.Event { return { args: { - idCommitment: { _hex: "0xb3df1c4e5600ef2b" }, + idCommitment: + idCommitment || + "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + rateLimit: DEFAULT_RATE_LIMIT, index: ethers.BigNumber.from(1) - } + }, + event: "MembershipRegistered" } as unknown as ethers.Event; } diff --git a/packages/rln/src/contract/rln_contract.ts b/packages/rln/src/contract/rln_contract.ts index 1c35c3fa14..c489d36dc5 100644 --- a/packages/rln/src/contract/rln_contract.ts +++ b/packages/rln/src/contract/rln_contract.ts @@ -6,9 +6,10 @@ 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/index.js"; +import { zeroPadLE } from "../utils/bytes.js"; -import { RLN_REGISTRY_ABI, RLN_STORAGE_ABI } from "./constants.js"; +import { RLN_ABI } from "./abi.js"; +import { DEFAULT_RATE_LIMIT, RATE_LIMIT_PARAMS } from "./constants.js"; const log = new Logger("waku:rln:contract"); @@ -17,18 +18,21 @@ type Member = { index: ethers.BigNumber; }; -type Signer = ethers.Signer; +interface RLNContractOptions { + signer: ethers.Signer; + address: string; + rateLimit?: number; +} -type RLNContractOptions = { - signer: Signer; - registryAddress: string; -}; +interface RLNContractInitOptions extends RLNContractOptions { + contract?: ethers.Contract; +} -type RLNStorageOptions = { - storageIndex?: number; -}; - -type RLNContractInitOptions = RLNContractOptions & RLNStorageOptions; +export interface MembershipRegisteredEvent { + idCommitment: string; + rateLimit: number; + index: ethers.BigNumber; +} type FetchMembersOptions = { fromBlock?: number; @@ -36,80 +40,157 @@ type FetchMembersOptions = { 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 { - private registryContract: ethers.Contract; + public contract: ethers.Contract; private merkleRootTracker: MerkleRootTracker; private deployBlock: undefined | number; - private storageIndex: undefined | number; - private storageContract: undefined | ethers.Contract; - private _membersFilter: undefined | ethers.EventFilter; + private rateLimit: number; private _members: Map = new Map(); + private _membersFilter: ethers.EventFilter; + private _membersRemovedFilter: ethers.EventFilter; + /** + * Asynchronous initializer for RLNContract. + * Allows injecting a mocked contract for testing purposes. + */ public static async init( rlnInstance: RLNInstance, options: RLNContractInitOptions ): Promise { const rlnContract = new RLNContract(rlnInstance, options); - await rlnContract.initStorageContract(options.signer); await rlnContract.fetchMembers(rlnInstance); rlnContract.subscribeToMembers(rlnInstance); return rlnContract; } - public constructor( + private constructor( rlnInstance: RLNInstance, - { registryAddress, signer }: RLNContractOptions + 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; + const initialRoot = rlnInstance.zerokit.getMerkleRoot(); - this.registryContract = new ethers.Contract( - registryAddress, - RLN_REGISTRY_ABI, - signer - ); + // 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 for MembershipRegistered and MembershipRemoved + this._membersFilter = this.contract.filters.MembershipRegistered(); + this._membersRemovedFilter = this.contract.filters.MembershipRemoved(); } - private async initStorageContract( - signer: Signer, - options: RLNStorageOptions = {} - ): Promise { - const storageIndex = options?.storageIndex - ? options.storageIndex - : await this.registryContract.usingStorageIndex(); - const storageAddress = await this.registryContract.storages(storageIndex); - - if (!storageAddress || storageAddress === ethers.constants.AddressZero) { - throw Error("No RLN Storage initialized on registry contract."); - } - - this.storageIndex = storageIndex; - this.storageContract = new ethers.Contract( - storageAddress, - RLN_STORAGE_ABI, - signer - ); - this._membersFilter = this.storageContract.filters.MemberRegistered(); - - this.deployBlock = await this.storageContract.deployedBlockNumber(); + /** + * Gets the current rate limit for this contract instance + */ + public getRateLimit(): number { + return this.rateLimit; } - public get registry(): ethers.Contract { - if (!this.registryContract) { - throw Error("Registry contract was not initialized"); - } - return this.registryContract as ethers.Contract; + /** + * Gets the contract address + */ + public get address(): string { + return this.contract.address; } - public get contract(): ethers.Contract { - if (!this.storageContract) { - throw Error("Storage contract was not initialized"); - } - return this.storageContract as ethers.Contract; + /** + * 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 maxTotal.sub(currentTotal).toNumber(); + } + + /** + * 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[] { @@ -123,7 +204,14 @@ export class RLNContract { if (!this._membersFilter) { throw Error("Members filter was not initialized."); } - return this._membersFilter as ethers.EventFilter; + return this._membersFilter; + } + + private get membersRemovedFilter(): ethers.EventFilter { + if (!this._membersRemovedFilter) { + throw Error("MembersRemoved filter was not initialized."); + } + return this._membersRemovedFilter; } public async fetchMembers( @@ -135,7 +223,14 @@ export class RLNContract { ...options, membersFilter: this.membersFilter }); - this.processEvents(rlnInstance, registeredMemberEvents); + const removedMemberEvents = await queryFilter(this.contract, { + fromBlock: this.deployBlock, + ...options, + membersFilter: this.membersRemovedFilter + }); + + const events = [...registeredMemberEvents, ...removedMemberEvents]; + this.processEvents(rlnInstance, events); } public processEvents(rlnInstance: RLNInstance, events: ethers.Event[]): void { @@ -147,8 +242,8 @@ export class RLNContract { return; } - if (evt.removed) { - const index: ethers.BigNumber = evt.args.index; + if (evt.event === "MembershipRemoved") { + const index = evt.args.index as ethers.BigNumber; const toRemoveVal = toRemoveTable.get(evt.blockNumber); if (toRemoveVal != undefined) { toRemoveVal.push(index.toNumber()); @@ -156,7 +251,7 @@ export class RLNContract { } else { toRemoveTable.set(evt.blockNumber, [index.toNumber()]); } - } else { + } else if (evt.event === "MembershipRegistered") { let eventsPerBlock = toInsertTable.get(evt.blockNumber); if (eventsPerBlock == undefined) { eventsPerBlock = []; @@ -177,18 +272,20 @@ export class RLNContract { ): void { toInsert.forEach((events: ethers.Event[], blockNumber: number) => { events.forEach((evt) => { - const _idCommitment = evt?.args?.idCommitment; - const index: ethers.BigNumber = evt?.args?.index; + if (!evt.args) return; + + const _idCommitment = evt.args.idCommitment as string; + const index = evt.args.index as ethers.BigNumber; if (!_idCommitment || !index) { return; } - const idCommitment = zeroPadLE(hexToBytes(_idCommitment?._hex), 32); + const idCommitment = zeroPadLE(hexToBytes(_idCommitment), 32); rlnInstance.zerokit.insertMember(idCommitment); this._members.set(index.toNumber(), { index, - idCommitment: _idCommitment?._hex + idCommitment: _idCommitment }); }); @@ -201,7 +298,7 @@ export class RLNContract { rlnInstance: RLNInstance, toRemove: Map ): void { - const removeDescending = new Map([...toRemove].sort().reverse()); + const removeDescending = new Map([...toRemove].reverse()); removeDescending.forEach((indexes: number[], blockNumber: number) => { indexes.forEach((index) => { if (this._members.has(index)) { @@ -215,63 +312,290 @@ export class RLNContract { } public subscribeToMembers(rlnInstance: RLNInstance): void { - this.contract.on(this.membersFilter, (_pubkey, _index, event) => - this.processEvents(rlnInstance, [event]) + this.contract.on( + this.membersFilter, + ( + _idCommitment: string, + _rateLimit: number, + _index: ethers.BigNumber, + event: ethers.Event + ) => { + this.processEvents(rlnInstance, [event]); + } + ); + + this.contract.on( + this.membersRemovedFilter, + ( + _idCommitment: string, + _index: ethers.BigNumber, + event: ethers.Event + ) => { + this.processEvents(rlnInstance, [event]); + } ); } public async registerWithIdentity( identity: IdentityCredential ): Promise { - if (this.storageIndex === undefined) { - throw Error( - "Cannot register credential, no storage contract index found." + try { + log.info( + `Registering identity with rate limit: ${this.rateLimit} messages/epoch` ); - } - const txRegisterResponse: ethers.ContractTransaction = - await this.registryContract["register(uint16,uint256)"]( - this.storageIndex, - identity.IDCommitmentBigInt, - { gasLimit: 100000 } + + const txRegisterResponse: ethers.ContractTransaction = + await this.contract.register( + identity.IDCommitmentBigInt, + this.rateLimit, + [], + { gasLimit: 300000 } + ); + const txRegisterReceipt = await txRegisterResponse.wait(); + + const memberRegistered = txRegisterReceipt.events?.find( + (event) => event.event === "MembershipRegistered" ); - const txRegisterReceipt = await txRegisterResponse.wait(); - // assumption: register(uint16,uint256) emits one event - const memberRegistered = txRegisterReceipt?.events?.[0]; + if (!memberRegistered || !memberRegistered.args) { + log.error( + "Failed to register membership: No MembershipRegistered event found" + ); + return undefined; + } - if (!memberRegistered) { + const decodedData: MembershipRegisteredEvent = { + idCommitment: memberRegistered.args.idCommitment, + rateLimit: memberRegistered.args.rateLimit, + index: memberRegistered.args.index + }; + + log.info( + `Successfully registered membership with index ${decodedData.index} ` + + `and rate limit ${decodedData.rateLimit}` + ); + + const network = await this.contract.provider.getNetwork(); + const address = this.contract.address; + const membershipId = decodedData.index.toNumber(); + + return { + identity, + membership: { + address, + treeIndex: membershipId, + chainId: network.chainId + } + }; + } catch (error) { + log.error(`Error in registerWithIdentity: ${(error as Error).message}`); return undefined; } + } - const decodedData = this.contract.interface.decodeEventLog( - "MemberRegistered", - memberRegistered.data - ); + /** + * 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); - const network = await this.registryContract.provider.getNetwork(); - const address = this.registryContract.address; - const membershipId = decodedData.index.toNumber(); + // 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; - return { - identity, - membership: { - address, - treeIndex: membershipId, - chainId: network.chainId + // 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, + rateLimit: memberRegistered.args.rateLimit, + index: memberRegistered.args.index + }; + + log.info( + `Successfully registered membership with permit. Index: ${decodedData.index}, ` + + `Rate limit: ${decodedData.rateLimit}, Erased ${idCommitmentsToErase.length} commitments` + ); + + const network = await this.contract.provider.getNetwork(); + const address = this.contract.address; + const membershipId = decodedData.index.toNumber(); + + return { + identity, + membership: { + address, + treeIndex: membershipId, + chainId: network.chainId + } + }; + } 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 = 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; + } + } } -type CustomQueryOptions = FetchMembersOptions & { +interface CustomQueryOptions extends FetchMembersOptions { membersFilter: ethers.EventFilter; -}; +} -// these value should be tested on other networks +// These values should be tested on other networks const FETCH_CHUNK = 5; const BLOCK_RANGE = 3000; @@ -286,18 +610,18 @@ async function queryFilter( fetchChunks = FETCH_CHUNK } = options; - if (!fromBlock) { + if (fromBlock === undefined) { return contract.queryFilter(membersFilter); } - if (!contract.signer.provider) { - throw Error("No provider found on the contract's signer."); + if (!contract.provider) { + throw Error("No provider found on the contract."); } - const toBlock = await contract.signer.provider.getBlockNumber(); + const toBlock = await contract.provider.getBlockNumber(); if (toBlock - fromBlock < fetchRange) { - return contract.queryFilter(membersFilter); + return contract.queryFilter(membersFilter, fromBlock, toBlock); } const events: ethers.Event[][] = []; @@ -319,7 +643,7 @@ function splitToChunks( to: number, step: number ): Array<[number, number]> { - const chunks = []; + const chunks: Array<[number, number]> = []; let left = from; while (left < to) { @@ -345,9 +669,18 @@ function* takeN(array: T[], size: number): Iterable { } } -function ignoreErrors(promise: Promise, defaultValue: T): Promise { - return promise.catch((err) => { - log.info(`Ignoring an error during query: ${err?.message}`); +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/index.ts b/packages/rln/src/index.ts index efc7c3ebd4..6d45b3d280 100644 --- a/packages/rln/src/index.ts +++ b/packages/rln/src/index.ts @@ -1,10 +1,6 @@ import { RLNDecoder, RLNEncoder } from "./codec.js"; -import { - RLN_REGISTRY_ABI, - RLN_STORAGE_ABI, - SEPOLIA_CONTRACT -} from "./contract/index.js"; -import { RLNContract } from "./contract/index.js"; +import { RLN_ABI } from "./contract/abi.js"; +import { RLNContract, SEPOLIA_CONTRACT } from "./contract/index.js"; import { createRLN } from "./create.js"; import { IdentityCredential } from "./identity.js"; import { Keystore } from "./keystore/index.js"; @@ -23,8 +19,7 @@ export { RLNDecoder, MerkleRootTracker, RLNContract, - RLN_STORAGE_ABI, - RLN_REGISTRY_ABI, SEPOLIA_CONTRACT, - extractMetaMaskSigner + extractMetaMaskSigner, + RLN_ABI }; diff --git a/packages/rln/src/keystore/keystore.spec.ts b/packages/rln/src/keystore/keystore.spec.ts index bda14ec9e0..322c3db688 100644 --- a/packages/rln/src/keystore/keystore.spec.ts +++ b/packages/rln/src/keystore/keystore.spec.ts @@ -1,13 +1,11 @@ -import * as chai from "chai"; +import { expect, use } from "chai"; import chaiAsPromised from "chai-as-promised"; import chaiSubset from "chai-subset"; import deepEqualInAnyOrder from "deep-equal-in-any-order"; -const { expect } = chai; - -chai.use(chaiSubset); -chai.use(deepEqualInAnyOrder); -chai.use(chaiAsPromised); +use(chaiSubset); +use(deepEqualInAnyOrder); +use(chaiAsPromised); import { IdentityCredential } from "../identity.js"; import { buildBigIntFromUint8Array } from "../utils/bytes.js"; diff --git a/packages/rln/src/message.ts b/packages/rln/src/message.ts index 20a65502f6..9533854ff2 100644 --- a/packages/rln/src/message.ts +++ b/packages/rln/src/message.ts @@ -27,7 +27,7 @@ export class RlnMessage implements IDecodedMessage { ? this.rlnInstance.zerokit.verifyWithRoots( this.rateLimitProof, toRLNSignal(this.msg.contentTopic, this.msg), - ...roots + roots ) // this.rlnInstance.verifyRLNProof once issue status-im/nwaku#1248 is fixed : undefined; } diff --git a/packages/rln/src/rln.ts b/packages/rln/src/rln.ts index dd6b0be47e..103be43b4e 100644 --- a/packages/rln/src/rln.ts +++ b/packages/rln/src/rln.ts @@ -15,6 +15,7 @@ import { type RLNDecoder, type RLNEncoder } from "./codec.js"; +import { DEFAULT_RATE_LIMIT } from "./contract/constants.js"; import { RLNContract, SEPOLIA_CONTRACT } from "./contract/index.js"; import { IdentityCredential } from "./identity.js"; import { Keystore } from "./keystore/index.js"; @@ -25,26 +26,53 @@ import type { 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 { - const res = await fetch("/base/rln.wasm"); - if (!res.ok) { - throw new Error(`Failed to fetch rln.wasm: ${res.statusText}`); +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)}` + ); } - const witnessBuffer = await res.arrayBuffer(); - return wc.builder(new Uint8Array(witnessBuffer), false); } async function loadZkey(): Promise { - const res = await fetch("/base/rln_final.zkey"); - if (!res.ok) { - throw new Error(`Failed to fetch rln_final.zkey: ${res.statusText}`); + 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)}` + ); } - return new Uint8Array(await res.arrayBuffer()); } /** @@ -65,7 +93,7 @@ export async function create(): Promise { const DEPTH = 20; const zkRLN = zerokitRLN.newRLN(DEPTH, zkey, vkey); - const zerokit = new Zerokit(zkRLN, witnessCalculator); + const zerokit = new Zerokit(zkRLN, witnessCalculator, DEFAULT_RATE_LIMIT); return new RLNInstance(zerokit); } catch (error) { @@ -82,12 +110,16 @@ type StartRLNOptions = { /** * If not set - will use default SEPOLIA_CONTRACT address. */ - registryAddress?: string; + 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 = @@ -128,7 +160,7 @@ export class RLNInstance { try { const { credentials, keystore } = await RLNInstance.decryptCredentialsIfNeeded(options.credentials); - const { signer, registryAddress } = await this.determineStartOptions( + const { signer, address } = await this.determineStartOptions( options, credentials ); @@ -140,8 +172,9 @@ export class RLNInstance { this._credentials = credentials; this._signer = signer!; this._contract = await RLNContract.init(this, { - registryAddress: registryAddress!, - signer: signer! + address: address!, + signer: signer!, + rateLimit: options.rateLimit ?? this.zerokit.getRateLimit }); this.started = true; } finally { @@ -154,12 +187,12 @@ export class RLNInstance { credentials: KeystoreEntity | undefined ): Promise { let chainId = credentials?.membership.chainId; - const registryAddress = + const address = credentials?.membership.address || - options.registryAddress || + options.address || SEPOLIA_CONTRACT.address; - if (registryAddress === SEPOLIA_CONTRACT.address) { + if (address === SEPOLIA_CONTRACT.address) { chainId = SEPOLIA_CONTRACT.chainId; } @@ -174,7 +207,7 @@ export class RLNInstance { return { signer, - registryAddress + address }; } @@ -270,7 +303,7 @@ export class RLNInstance { } const registryAddress = credentials.membership.address; - const currentRegistryAddress = this._contract.registry.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}` @@ -278,7 +311,7 @@ export class RLNInstance { } const chainId = credentials.membership.chainId; - const network = await this._contract.registry.provider.getNetwork(); + const network = await this._contract.provider.getNetwork(); const currentChainId = network.chainId; if (chainId !== currentChainId) { throw Error( diff --git a/packages/rln/src/zerokit.ts b/packages/rln/src/zerokit.ts index 098ce3ad3a..afcb6d4862 100644 --- a/packages/rln/src/zerokit.ts +++ b/packages/rln/src/zerokit.ts @@ -1,6 +1,7 @@ import type { IRateLimitProof } from "@waku/interfaces"; import * as zerokitRLN from "@waku/zerokit-rln-wasm"; +import { DEFAULT_RATE_LIMIT, RATE_LIMIT_PARAMS } from "./contract/constants.js"; import { IdentityCredential } from "./identity.js"; import { Proof, proofToBytes } from "./proof.js"; import { WitnessCalculator } from "./resources/witness_calculator"; @@ -14,9 +15,22 @@ import { export class Zerokit { public constructor( private readonly zkRLN: number, - private readonly witnessCalculator: WitnessCalculator + private readonly witnessCalculator: WitnessCalculator, + private readonly rateLimit: number = DEFAULT_RATE_LIMIT ) {} + public get getZkRLN(): number { + return this.zkRLN; + } + + public get getWitnessCalculator(): WitnessCalculator { + return this.witnessCalculator; + } + + public get getRateLimit(): number { + return this.rateLimit; + } + public generateIdentityCredentials(): IdentityCredential { const memKeys = zerokitRLN.generateExtendedMembershipKey(this.zkRLN); // TODO: rename this function in zerokit rln-wasm return IdentityCredential.fromBytes(memKeys); @@ -65,23 +79,36 @@ export class Zerokit { uint8Msg: Uint8Array, memIndex: number, epoch: Uint8Array, - idKey: Uint8Array + idKey: Uint8Array, + rateLimit?: number ): Uint8Array { // calculate message length const msgLen = writeUIntLE(new Uint8Array(8), uint8Msg.length, 0, 8); - - // Converting index to LE bytes const memIndexBytes = writeUIntLE(new Uint8Array(8), memIndex, 0, 8); + const rateLimitBytes = writeUIntLE( + new Uint8Array(8), + rateLimit ?? this.rateLimit, + 0, + 8 + ); - // [ id_key<32> | id_index<8> | epoch<32> | signal_len<8> | signal ] - return concatenate(idKey, memIndexBytes, epoch, msgLen, uint8Msg); + // [ id_key<32> | id_index<8> | epoch<32> | signal_len<8> | signal | rate_limit<8> ] + return concatenate( + idKey, + memIndexBytes, + epoch, + msgLen, + uint8Msg, + rateLimitBytes + ); } public async generateRLNProof( msg: Uint8Array, index: number, epoch: Uint8Array | Date | undefined, - idSecretHash: Uint8Array + idSecretHash: Uint8Array, + rateLimit?: number ): Promise { if (epoch === undefined) { epoch = epochIntToBytes(dateToEpoch(new Date())); @@ -89,15 +116,26 @@ export class Zerokit { epoch = epochIntToBytes(dateToEpoch(epoch)); } + const effectiveRateLimit = rateLimit ?? this.rateLimit; + if (epoch.length !== 32) throw new Error("invalid epoch"); if (idSecretHash.length !== 32) throw new Error("invalid id secret hash"); if (index < 0) throw new Error("index must be >= 0"); + if ( + effectiveRateLimit < RATE_LIMIT_PARAMS.MIN_RATE || + effectiveRateLimit > RATE_LIMIT_PARAMS.MAX_RATE + ) { + throw new Error( + `Rate limit must be between ${RATE_LIMIT_PARAMS.MIN_RATE} and ${RATE_LIMIT_PARAMS.MAX_RATE}` + ); + } const serialized_msg = this.serializeMessage( msg, index, epoch, - idSecretHash + idSecretHash, + effectiveRateLimit ); const rlnWitness = zerokitRLN.getSerializedRLNWitness( this.zkRLN, @@ -107,7 +145,7 @@ export class Zerokit { const calculatedWitness = await this.witnessCalculator.calculateWitness( inputs, false - ); // no sanity check being used in zerokit + ); const proofBytes = zerokitRLN.generate_rln_proof_with_witness( this.zkRLN, @@ -120,7 +158,8 @@ export class Zerokit { public verifyRLNProof( proof: IRateLimitProof | Uint8Array, - msg: Uint8Array + msg: Uint8Array, + rateLimit?: number ): boolean { let pBytes: Uint8Array; if (proof instanceof Uint8Array) { @@ -131,17 +170,24 @@ export class Zerokit { // calculate message length const msgLen = writeUIntLE(new Uint8Array(8), msg.length, 0, 8); + const rateLimitBytes = writeUIntLE( + new Uint8Array(8), + rateLimit ?? this.rateLimit, + 0, + 8 + ); return zerokitRLN.verifyRLNProof( this.zkRLN, - concatenate(pBytes, msgLen, msg) + concatenate(pBytes, msgLen, msg, rateLimitBytes) ); } public verifyWithRoots( proof: IRateLimitProof | Uint8Array, msg: Uint8Array, - ...roots: Array + roots: Array, + rateLimit?: number ): boolean { let pBytes: Uint8Array; if (proof instanceof Uint8Array) { @@ -151,19 +197,26 @@ export class Zerokit { } // calculate message length const msgLen = writeUIntLE(new Uint8Array(8), msg.length, 0, 8); + const rateLimitBytes = writeUIntLE( + new Uint8Array(8), + rateLimit ?? this.rateLimit, + 0, + 8 + ); const rootsBytes = concatenate(...roots); return zerokitRLN.verifyWithRoots( this.zkRLN, - concatenate(pBytes, msgLen, msg), + concatenate(pBytes, msgLen, msg, rateLimitBytes), rootsBytes ); } public verifyWithNoRoot( proof: IRateLimitProof | Uint8Array, - msg: Uint8Array + msg: Uint8Array, + rateLimit?: number ): boolean { let pBytes: Uint8Array; if (proof instanceof Uint8Array) { @@ -174,10 +227,16 @@ export class Zerokit { // calculate message length const msgLen = writeUIntLE(new Uint8Array(8), msg.length, 0, 8); + const rateLimitBytes = writeUIntLE( + new Uint8Array(8), + rateLimit ?? this.rateLimit, + 0, + 8 + ); return zerokitRLN.verifyWithRoots( this.zkRLN, - concatenate(pBytes, msgLen, msg), + concatenate(pBytes, msgLen, msg, rateLimitBytes), new Uint8Array() ); }