feat: upgrading adapter to basic rlnv2

This commit is contained in:
Danish Arora 2025-02-13 13:50:54 +05:30
parent fe5bbd9854
commit 8f5df7817f
No known key found for this signature in database
GPG Key ID: 1C6EF37CDAE1426E
5 changed files with 395 additions and 155 deletions

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

@ -1,43 +1,100 @@
import { expect } from "chai";
import { hexToBytes } from "@waku/utils/bytes";
import chai from "chai";
import spies from "chai-spies";
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 { 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;
chai.use(spies);
// 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);
const DEFAULT_RATE_LIMIT = 10;
function mockRLNv2RegisteredEvent(idCommitment?: string): ethers.Event {
return {
args: {
idCommitment:
idCommitment ||
"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
rateLimit: DEFAULT_RATE_LIMIT,
index: ethers.BigNumber.from(1)
},
event: "MembershipRegistered"
} as unknown as ethers.Event;
}
describe("RLN Contract abstraction - RLN v2", () => {
let sandbox: SinonSandbox;
beforeEach(() => {
sandbox = sinon.createSandbox();
});
afterEach(() => {
sandbox.restore();
});
it("should fetch members from events and store them in the RLN instance", async () => {
const rlnInstance = await createRLN();
rlnInstance.zerokit.insertMember = () => undefined;
const insertMemberSpy = chai.spy.on(rlnInstance.zerokit, "insertMember");
const membershipRegisteredEvent = mockRLNv2RegisteredEvent();
const mockedRegistryContract = {
queryFilter: () => [membershipRegisteredEvent],
provider: {
getLogs: () => [],
getBlockNumber: () => Promise.resolve(1000)
},
interface: {
getEvent: (eventName: string) => ({
name: eventName,
format: () => {}
})
},
filters: {
MembershipRegistered: () => ({}),
MembershipRemoved: () => ({})
},
on: () => ({}),
removeAllListeners: () => ({})
};
const voidSigner = new ethers.VoidSigner(SEPOLIA_CONTRACT.address);
const rlnContract = new RLNContract(rlnInstance, {
registryAddress: SEPOLIA_CONTRACT.address,
signer: voidSigner
const queryFilterSpy = chai.spy.on(mockedRegistryContract, "queryFilter");
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: 0,
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);
chai
.expect(insertMemberSpy)
.to.have.been.called.with(
ethers.utils.zeroPad(
hexToBytes(membershipRegisteredEvent.args!.idCommitment),
32
)
);
expect(insertMemberCalled).to.be.true;
chai.expect(queryFilterSpy).to.have.been.called;
});
it("should register a member", async () => {
@ -45,38 +102,96 @@ 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);
rlnInstance.zerokit.insertMember = () => undefined;
const insertMemberSpy = chai.spy.on(rlnInstance.zerokit, "insertMember");
const formatIdCommitment = (idCommitmentBigInt: bigint): string =>
"0x" + idCommitmentBigInt.toString(16).padStart(64, "0");
const membershipRegisteredEvent = mockRLNv2RegisteredEvent(
formatIdCommitment(identity.IDCommitmentBigInt)
);
const mockedRegistryContract = {
register: () => ({
wait: () =>
Promise.resolve({
events: [
{
event: "MembershipRegistered",
args: {
idCommitment: formatIdCommitment(identity.IDCommitmentBigInt),
rateLimit: 0,
index: ethers.BigNumber.from(1)
}
}
]
})
}),
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 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: 0,
contract: mockedRegistryContract as unknown as ethers.Contract
});
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 registerSpy = chai.spy.on(mockedRegistryContract, "register");
const identity =
rlnInstance.zerokit.generateSeededIdentityCredential(mockSignature);
await rlnContract.registerWithIdentity(identity);
const decryptedCredentials =
await rlnContract.registerWithIdentity(identity);
expect(registerCalled).to.be.true;
chai.expect(decryptedCredentials).to.not.be.undefined;
if (!decryptedCredentials)
throw new Error("Decrypted credentials should not be undefined");
chai
.expect(registerSpy)
.to.have.been.called.with(identity.IDCommitmentBigInt, 0, [], {
gasLimit: 300000
});
chai.expect(decryptedCredentials).to.have.property("identity");
chai.expect(decryptedCredentials).to.have.property("membership");
chai.expect(decryptedCredentials.membership).to.include({
address: SEPOLIA_CONTRACT.address,
treeIndex: 1
});
const expectedIdCommitment = ethers.utils.zeroPad(
hexToBytes(formatIdCommitment(identity.IDCommitmentBigInt)),
32
);
const insertMemberCalls = (insertMemberSpy as any).__spy.calls;
chai.expect(insertMemberCalls).to.have.length(2);
chai.expect(insertMemberCalls[1][0]).to.deep.equal(expectedIdCommitment);
});
});
function mockEvent(): ethers.Event {
return {
args: {
idCommitment: { _hex: "0xb3df1c4e5600ef2b" },
index: ethers.BigNumber.from(1)
}
} as unknown as ethers.Event;
}

View File

@ -9,44 +9,30 @@ import { MerkleRootTracker } from "../root_tracker.js";
import { zeroPadLE } from "../utils/index.js";
import { RLN_V2_ABI } from "./abi/rlnv2.js";
import {
FetchMembersOptions,
MembershipRegisteredEvent,
RLNContractInitOptions
} from "./types.js";
import { Member } from "./types.js";
const log = new Logger("waku:rln:contract");
type Member = {
idCommitment: string;
index: ethers.BigNumber;
};
type Signer = ethers.Signer;
type RLNContractOptions = {
signer: Signer;
registryAddress: string;
};
type RLNStorageOptions = {
storageIndex?: number;
};
type RLNContractInitOptions = RLNContractOptions & RLNStorageOptions;
type FetchMembersOptions = {
fromBlock?: number;
fetchRange?: number;
fetchChunks?: number;
};
export class RLNContract {
private registryContract: ethers.Contract;
private 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 _membersFilter: ethers.EventFilter;
private _membersRemovedFilter: ethers.EventFilter;
private _members: Map<number, Member> = new Map();
/**
* Asynchronous initializer for RLNContract.
* Allows injecting a mocked registryContract for testing purposes.
*/
public static async init(
rlnInstance: RLNInstance,
options: RLNContractInitOptions
@ -58,33 +44,35 @@ export class RLNContract {
return rlnContract;
}
public constructor(
/**
* Private constructor to enforce the use of the async init method.
*/
private constructor(
rlnInstance: RLNInstance,
{ registryAddress, signer }: RLNContractOptions
options: RLNContractInitOptions
) {
const { address, signer, rateLimit, contract } = options;
if (rateLimit === undefined) {
throw new Error("rateLimit must be provided in RLNContractOptions.");
}
this.rateLimit = rateLimit;
const initialRoot = rlnInstance.zerokit.getMerkleRoot();
this.registryContract = new ethers.Contract(
registryAddress,
RLN_V2_ABI,
signer
);
// Use the injected registryContract if provided; otherwise, instantiate a new one.
this.contract =
contract || new ethers.Contract(address, RLN_V2_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();
}
public get registry(): ethers.Contract {
if (!this.registryContract) {
throw Error("Registry contract was not initialized");
}
return this.registryContract as ethers.Contract;
}
public get contract(): ethers.Contract {
if (!this.storageContract) {
throw Error("Storage contract was not initialized");
}
return this.storageContract as ethers.Contract;
return this.contract;
}
public get members(): Member[] {
@ -98,7 +86,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(
@ -110,7 +105,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 {
@ -122,8 +124,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());
@ -131,7 +133,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 = [];
@ -152,18 +154,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
});
});
@ -176,7 +180,7 @@ export class RLNContract {
rlnInstance: RLNInstance,
toRemove: Map<number, number[]>
): void {
const removeDescending = new Map([...toRemove].sort().reverse());
const removeDescending = new Map([...toRemove].sort((a, b) => b[0] - a[0]));
removeDescending.forEach((indexes: number[], blockNumber: number) => {
indexes.forEach((index) => {
if (this._members.has(index)) {
@ -190,63 +194,153 @@ 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."
);
}
const txRegisterResponse: ethers.ContractTransaction =
await this.registryContract["register(uint16,uint256)"](
this.storageIndex,
identity.IDCommitmentBigInt,
{ gasLimit: 100000 }
);
const txRegisterReceipt = await txRegisterResponse.wait();
try {
const txRegisterResponse: ethers.ContractTransaction =
await this.contract.register(
identity.IDCommitmentBigInt,
this.rateLimit,
[],
{ gasLimit: 300000 }
);
const txRegisterReceipt = await txRegisterResponse.wait();
// assumption: register(uint16,uint256) emits one event
const memberRegistered = txRegisterReceipt?.events?.[0];
const memberRegistered = txRegisterReceipt.events?.find(
(event) => event.event === "MembershipRegistered"
);
if (!memberRegistered) {
if (!memberRegistered || !memberRegistered.args) {
return undefined;
}
const decodedData: MembershipRegisteredEvent = {
idCommitment: memberRegistered.args.idCommitment,
rateLimit: memberRegistered.args.rateLimit,
index: memberRegistered.args.index
};
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
);
public async registerWithPermitAndErase(
identity: IdentityCredential,
permit: {
owner: string;
deadline: number;
v: number;
r: string;
s: string;
},
idCommitmentsToErase: string[]
): Promise<DecryptedCredentials | undefined> {
try {
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 network = await this.registryContract.provider.getNetwork();
const address = this.registryContract.address;
const membershipId = decodedData.index.toNumber();
const memberRegistered = txRegisterReceipt.events?.find(
(event) => event.event === "MembershipRegistered"
);
return {
identity,
membership: {
address,
treeIndex: membershipId,
chainId: network.chainId
if (!memberRegistered || !memberRegistered.args) {
return undefined;
}
};
const decodedData: MembershipRegisteredEvent = {
idCommitment: memberRegistered.args.idCommitment,
rateLimit: memberRegistered.args.rateLimit,
index: memberRegistered.args.index
};
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}`);
}
}
}
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;
@ -261,18 +355,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[][] = [];
@ -294,7 +388,7 @@ function splitToChunks(
to: number,
step: number
): Array<[number, number]> {
const chunks = [];
const chunks: Array<[number, number]> = [];
let left = from;
while (left < to) {

View File

@ -0,0 +1,28 @@
import { ethers } from "ethers";
export interface MembershipRegisteredEvent {
idCommitment: string;
rateLimit: number;
index: ethers.BigNumber;
}
export interface Member {
idCommitment: string;
index: ethers.BigNumber;
}
interface RLNContractOptions {
signer: ethers.Signer;
address: string;
rateLimit?: number;
}
export interface FetchMembersOptions {
fromBlock?: number;
fetchRange?: number;
fetchChunks?: number;
}
export interface RLNContractInitOptions extends RLNContractOptions {
contract?: ethers.Contract;
}

View File

@ -140,8 +140,9 @@ export class RLNInstance {
this._credentials = credentials;
this._signer = signer!;
this._contract = await RLNContract.init(this, {
registryAddress: registryAddress!,
signer: signer!
address: registryAddress!,
signer: signer!,
rateLimit: 0
});
this.started = true;
} finally {