feat(rln): migrate from v1 to v2, rate limiting, memberships, test coverage (#2262)

* chore: update ABIs and deployed address

* chore: remove storage contract references

* feat: upgrading adapter to basic rlnv2

* feat: rate limit

* chore: upgrade packages revert

* fix: tests

* chore: remove uneeded file

* feat(rln): implement RLNv2 rate limiting and membership states

- Add rate limit validation and handling in proof generation/verification
- Implement membership lifecycle state management (Active/GracePeriod/Expired)
- Add new membership management methods:
  - getMembershipInfo
  - extendMembership
  - eraseMembership
  - registerMembership
- Update proof verification to include rate limit checks
- Refactor message serialization to include rate limit data

Breaking changes:
- verifyWithRoots now takes roots as array instead of spread parameters
- Proof verification methods now accept optional rateLimit parameter

* fix: typo

* chore: add to cspell

* chore: reduce diff

* chore: simplify subdir for abi

* chore: address comments

* chore: simplify access to variables

* chore: address comments

* chore: simplify constants

* chore: add error handling

* chore: change rln v2 references to rln

* fix: check
This commit is contained in:
Danish Arora 2025-03-03 18:14:06 +05:30 committed by GitHub
parent 09108d9284
commit 6fc6bf3916
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1260 additions and 280 deletions

View File

@ -9,6 +9,7 @@
"Alives",
"alphabeta",
"Arraylike",
"arrayify",
"asym",
"autoshard",
"autosharding",

43
package-lock.json generated
View File

@ -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",

View File

@ -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";

View File

@ -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"],

View File

@ -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",

View File

@ -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
}
];

View File

@ -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;

View File

@ -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<typeof originalInsertMember>
) {
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;
}

View File

@ -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<number, Member> = 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<RLNContract> {
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<void> {
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<number> The minimum rate limit in messages per epoch
*/
public async getMinRateLimit(): Promise<number> {
const minRate = await this.contract.minMembershipRateLimit();
return minRate.toNumber();
}
/**
* Gets the maximum allowed rate limit from the contract
* @returns Promise<number> The maximum rate limit in messages per epoch
*/
public async getMaxRateLimit(): Promise<number> {
const maxRate = await this.contract.maxMembershipRateLimit();
return maxRate.toNumber();
}
/**
* Gets the maximum total rate limit across all memberships
* @returns Promise<number> The maximum total rate limit in messages per epoch
*/
public async getMaxTotalRateLimit(): Promise<number> {
const maxTotalRate = await this.contract.maxTotalRateLimit();
return maxTotalRate.toNumber();
}
/**
* Gets the current total rate limit usage across all memberships
* @returns Promise<number> The current total rate limit usage in messages per epoch
*/
public async getCurrentTotalRateLimit(): Promise<number> {
const currentTotal = await this.contract.currentTotalRateLimit();
return currentTotal.toNumber();
}
/**
* Gets the remaining available total rate limit that can be allocated
* @returns Promise<number> The remaining rate limit that can be allocated
*/
public async getRemainingTotalRateLimit(): Promise<number> {
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<void> {
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<number, number[]>
): 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<DecryptedCredentials | undefined> {
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<number> {
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<DecryptedCredentials | undefined> {
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<void> {
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<MembershipInfo | undefined> {
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<ethers.ContractTransaction> {
return this.contract.extendMemberships([idCommitment]);
}
public async eraseMembership(
idCommitment: string,
eraseFromMembershipSet: boolean = true
): Promise<ethers.ContractTransaction> {
return this.contract.eraseMemberships(
[idCommitment],
eraseFromMembershipSet
);
}
public async registerMembership(
idCommitment: string,
rateLimit: number = DEFAULT_RATE_LIMIT
): Promise<ethers.ContractTransaction> {
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<ethers.BigNumber | undefined> {
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<T>(array: T[], size: number): Iterable<T[]> {
}
}
function ignoreErrors<T>(promise: Promise<T>, defaultValue: T): Promise<T> {
return promise.catch((err) => {
log.info(`Ignoring an error during query: ${err?.message}`);
async function ignoreErrors<T>(
promise: Promise<T>,
defaultValue: T
): Promise<T> {
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;
});
}
}

View File

@ -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
};

View File

@ -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";

View File

@ -27,7 +27,7 @@ export class RlnMessage<T extends IDecodedMessage> 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;
}

View File

@ -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<wc.WitnessCalculator> {
const res = await fetch("/base/rln.wasm");
if (!res.ok) {
throw new Error(`Failed to fetch rln.wasm: ${res.statusText}`);
async function loadWitnessCalculator(): Promise<WitnessCalculator> {
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<Uint8Array> {
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<RLNInstance> {
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<StartRLNOptions> {
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(

View File

@ -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<var> ]
return concatenate(idKey, memIndexBytes, epoch, msgLen, uint8Msg);
// [ id_key<32> | id_index<8> | epoch<32> | signal_len<8> | signal<var> | 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<IRateLimitProof> {
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<Uint8Array>
roots: Array<Uint8Array>,
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()
);
}