feat: refine work with membership info and other meta information (#2341)

* chore: update definitions to match the ABI

* chore: improve exports and types

* chore: use big endian instead of little endian

* chore: improve logging

* chore: update import

* fix: use overloaded function call

* chore: estimate gas

* test

* npm i

* chore: regen locl

* chore: update

* chore: update playwright docker images

* chore: cannot erase membership if it isn't expired/in grace period

* chore: fix condition

* chore: add `getMembershipStatus` method

* refactor(rln): cache min/max rate limits at contract instantiation

* chore: fix accessor

* chore: rename arg

* chore: allow chromebin path through env
This commit is contained in:
Danish Arora 2025-05-29 16:09:08 +05:30 committed by GitHub
parent a4dfd3455c
commit 3b23bceb9d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 2763 additions and 2745 deletions

View File

@ -57,7 +57,7 @@ jobs:
browser: browser:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/playwright:v1.51.1-jammy image: mcr.microsoft.com/playwright:v1.52.0-jammy
env: env:
HOME: "/root" HOME: "/root"
steps: steps:

View File

@ -20,7 +20,7 @@ jobs:
timeout-minutes: 60 timeout-minutes: 60
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/playwright:v1.51.1-jammy image: mcr.microsoft.com/playwright:v1.52.0-jammy
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3

View File

@ -1,7 +1,11 @@
/* eslint-env node */
const playwright = require("playwright"); const playwright = require("playwright");
const webpack = require("webpack"); const webpack = require("webpack");
process.env.CHROME_BIN = playwright.chromium.executablePath(); if (!process.env.CHROME_BIN) {
process.env.CHROME_BIN = playwright.chromium.executablePath();
}
console.log("Using CHROME_BIN:", process.env.CHROME_BIN);
process.env.FIREFOX_BIN = playwright.firefox.executablePath(); process.env.FIREFOX_BIN = playwright.firefox.executablePath();
module.exports = function (config) { module.exports = function (config) {

5200
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,2 +1,3 @@
export { RLNContract } from "./rln_contract.js"; export { RLNContract } from "./rln_contract.js";
export * from "./constants.js"; export * from "./constants.js";
export * from "./types.js";

View File

@ -22,6 +22,8 @@ export class RLNBaseContract {
public contract: ethers.Contract; public contract: ethers.Contract;
private deployBlock: undefined | number; private deployBlock: undefined | number;
private rateLimit: number; private rateLimit: number;
private minRateLimit?: number;
private maxRateLimit?: number;
protected _members: Map<number, Member> = new Map(); protected _members: Map<number, Member> = new Map();
private _membersFilter: ethers.EventFilter; private _membersFilter: ethers.EventFilter;
@ -29,10 +31,35 @@ export class RLNBaseContract {
private _membersExpiredFilter: ethers.EventFilter; private _membersExpiredFilter: ethers.EventFilter;
/** /**
* Constructor for RLNBaseContract. * Private constructor for RLNBaseContract. Use static create() instead.
* Allows injecting a mocked contract for testing purposes.
*/ */
public constructor(options: RLNContractInitOptions) { protected constructor(options: RLNContractInitOptions) {
const {
address,
signer,
rateLimit = DEFAULT_RATE_LIMIT,
contract
} = options;
log.info("Initializing RLNBaseContract", { address, rateLimit });
this.contract = contract || new ethers.Contract(address, RLN_ABI, signer);
this.rateLimit = rateLimit;
try {
log.info("Setting up event filters");
// Initialize event filters
this._membersFilter = this.contract.filters.MembershipRegistered();
this._membershipErasedFilter = this.contract.filters.MembershipErased();
this._membersExpiredFilter = this.contract.filters.MembershipExpired();
log.info("Event filters initialized successfully");
} catch (error) {
log.error("Failed to initialize event filters", { error });
throw new Error(
"Failed to initialize event filters: " + (error as Error).message
);
}
// Initialize members and subscriptions // Initialize members and subscriptions
this.fetchMembers() this.fetchMembers()
.then(() => { .then(() => {
@ -41,23 +68,24 @@ export class RLNBaseContract {
.catch((error) => { .catch((error) => {
log.error("Failed to initialize members", { error }); log.error("Failed to initialize members", { error });
}); });
}
const { /**
address, * Static async factory to create and initialize RLNBaseContract
signer, */
rateLimit = DEFAULT_RATE_LIMIT, public static async create(
contract options: RLNContractInitOptions
} = options; ): Promise<RLNBaseContract> {
const instance = new RLNBaseContract(options);
const [min, max] = await Promise.all([
instance.contract.minMembershipRateLimit(),
instance.contract.maxMembershipRateLimit()
]);
instance.minRateLimit = ethers.BigNumber.from(min).toNumber();
instance.maxRateLimit = ethers.BigNumber.from(max).toNumber();
this.validateRateLimit(rateLimit); instance.validateRateLimit(instance.rateLimit);
return instance;
this.contract = contract || new ethers.Contract(address, RLN_ABI, signer);
this.rateLimit = rateLimit;
// Initialize event filters
this._membersFilter = this.contract.filters.MembershipRegistered();
this._membershipErasedFilter = this.contract.filters.MembershipErased();
this._membersExpiredFilter = this.contract.filters.MembershipExpired();
} }
/** /**
@ -82,21 +110,21 @@ export class RLNBaseContract {
} }
/** /**
* Gets the minimum allowed rate limit from the contract * Gets the minimum allowed rate limit (cached)
* @returns Promise<number> The minimum rate limit in messages per epoch
*/ */
public async getMinRateLimit(): Promise<number> { public getMinRateLimit(): number {
const minRate = await this.contract.minMembershipRateLimit(); if (this.minRateLimit === undefined)
return ethers.BigNumber.from(minRate).toNumber(); throw new Error("minRateLimit not initialized");
return this.minRateLimit;
} }
/** /**
* Gets the maximum allowed rate limit from the contract * Gets the maximum allowed rate limit (cached)
* @returns Promise<number> The maximum rate limit in messages per epoch
*/ */
public async getMaxRateLimit(): Promise<number> { public getMaxRateLimit(): number {
const maxRate = await this.contract.maxMembershipRateLimit(); if (this.maxRateLimit === undefined)
return ethers.BigNumber.from(maxRate).toNumber(); throw new Error("maxRateLimit not initialized");
return this.maxRateLimit;
} }
/** /**
@ -133,7 +161,7 @@ export class RLNBaseContract {
* Updates the rate limit for future registrations * Updates the rate limit for future registrations
* @param newRateLimit The new rate limit to use * @param newRateLimit The new rate limit to use
*/ */
public async setRateLimit(newRateLimit: number): Promise<void> { public setRateLimit(newRateLimit: number): void {
this.validateRateLimit(newRateLimit); this.validateRateLimit(newRateLimit);
this.rateLimit = newRateLimit; this.rateLimit = newRateLimit;
} }
@ -324,7 +352,7 @@ export class RLNBaseContract {
this.contract.on( this.contract.on(
this.membersFilter, this.membersFilter,
( (
_idCommitment: string, _idCommitment: bigint,
_membershipRateLimit: ethers.BigNumber, _membershipRateLimit: ethers.BigNumber,
_index: ethers.BigNumber, _index: ethers.BigNumber,
event: ethers.Event event: ethers.Event
@ -336,7 +364,7 @@ export class RLNBaseContract {
this.contract.on( this.contract.on(
this.membershipErasedFilter, this.membershipErasedFilter,
( (
_idCommitment: string, _idCommitment: bigint,
_membershipRateLimit: ethers.BigNumber, _membershipRateLimit: ethers.BigNumber,
_index: ethers.BigNumber, _index: ethers.BigNumber,
event: ethers.Event event: ethers.Event
@ -348,7 +376,7 @@ export class RLNBaseContract {
this.contract.on( this.contract.on(
this.membersExpiredFilter, this.membersExpiredFilter,
( (
_idCommitment: string, _idCommitment: bigint,
_membershipRateLimit: ethers.BigNumber, _membershipRateLimit: ethers.BigNumber,
_index: ethers.BigNumber, _index: ethers.BigNumber,
event: ethers.Event event: ethers.Event
@ -358,94 +386,89 @@ export class RLNBaseContract {
); );
} }
/**
* 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);
// Calculate current epoch
const currentTime = Math.floor(Date.now() / 1000);
const epochsPassed = Math.floor(
(currentTime - startTime) / RATE_LIMIT_PARAMS.EPOCH_LENGTH
);
const currentEpochStart =
startTime + epochsPassed * RATE_LIMIT_PARAMS.EPOCH_LENGTH;
// Get message count in current epoch using contract's function
const messageCount = await this.contract.getMessageCount(
membershipId,
currentEpochStart
);
return Math.max(
0,
ethers.BigNumber.from(rateLimit)
.sub(ethers.BigNumber.from(messageCount))
.toNumber()
);
} catch (error) {
log.error(
`Error getting remaining messages: ${(error as Error).message}`
);
return 0; // Fail safe: assume no messages remaining on error
}
}
public async getMembershipInfo( public async getMembershipInfo(
idCommitment: string idCommitmentBigInt: bigint
): Promise<MembershipInfo | undefined> { ): Promise<MembershipInfo | undefined> {
try { try {
const [startBlock, endBlock, rateLimit] = const membershipData =
await this.contract.getMembershipInfo(idCommitment); await this.contract.memberships(idCommitmentBigInt);
const currentBlock = await this.contract.provider.getBlockNumber(); const currentBlock = await this.contract.provider.getBlockNumber();
const [
depositAmount,
activeDuration,
gracePeriodStartTimestamp,
gracePeriodDuration,
rateLimit,
index,
holder,
token
] = membershipData;
const gracePeriodEnd = gracePeriodStartTimestamp.add(gracePeriodDuration);
let state: MembershipState; let state: MembershipState;
if (currentBlock < startBlock) { if (currentBlock < gracePeriodStartTimestamp.toNumber()) {
state = MembershipState.Active; state = MembershipState.Active;
} else if (currentBlock < endBlock) { } else if (currentBlock < gracePeriodEnd.toNumber()) {
state = MembershipState.GracePeriod; state = MembershipState.GracePeriod;
} else { } else {
state = MembershipState.Expired; state = MembershipState.Expired;
} }
const index = await this.getMemberIndex(idCommitment);
if (!index) return undefined;
return { return {
index, index,
idCommitment, idCommitment: idCommitmentBigInt.toString(),
rateLimit: rateLimit.toNumber(), rateLimit: Number(rateLimit),
startBlock: startBlock.toNumber(), startBlock: gracePeriodStartTimestamp.toNumber(),
endBlock: endBlock.toNumber(), endBlock: gracePeriodEnd.toNumber(),
state state,
depositAmount,
activeDuration,
gracePeriodDuration,
holder,
token
}; };
} catch (error) { } catch (error) {
log.error("Error in getMembershipInfo:", error);
return undefined; return undefined;
} }
} }
public async extendMembership( public async extendMembership(
idCommitment: string idCommitmentBigInt: bigint
): Promise<ethers.ContractTransaction> { ): Promise<ethers.ContractTransaction> {
return this.contract.extendMemberships([idCommitment]); const tx = await this.contract.extendMemberships([idCommitmentBigInt]);
await tx.wait();
return tx;
} }
public async eraseMembership( public async eraseMembership(
idCommitment: string, idCommitmentBigInt: bigint,
eraseFromMembershipSet: boolean = true eraseFromMembershipSet: boolean = true
): Promise<ethers.ContractTransaction> { ): Promise<ethers.ContractTransaction> {
return this.contract.eraseMemberships( if (
[idCommitment], !(await this.isExpired(idCommitmentBigInt)) ||
eraseFromMembershipSet !(await this.isInGracePeriod(idCommitmentBigInt))
) {
throw new Error("Membership is not expired or in grace period");
}
const estimatedGas = await this.contract.estimateGas[
"eraseMemberships(uint256[],bool)"
]([idCommitmentBigInt], eraseFromMembershipSet);
const gasLimit = estimatedGas.add(10000);
const tx = await this.contract["eraseMemberships(uint256[],bool)"](
[idCommitmentBigInt],
eraseFromMembershipSet,
{ gasLimit }
); );
await tx.wait();
return tx;
} }
public async registerMembership( public async registerMembership(
idCommitment: string, idCommitmentBigInt: bigint,
rateLimit: number = DEFAULT_RATE_LIMIT rateLimit: number = DEFAULT_RATE_LIMIT
): Promise<ethers.ContractTransaction> { ): Promise<ethers.ContractTransaction> {
if ( if (
@ -456,12 +479,12 @@ export class RLNBaseContract {
`Rate limit must be between ${RATE_LIMIT_PARAMS.MIN_RATE} and ${RATE_LIMIT_PARAMS.MAX_RATE}` `Rate limit must be between ${RATE_LIMIT_PARAMS.MIN_RATE} and ${RATE_LIMIT_PARAMS.MAX_RATE}`
); );
} }
return this.contract.register(idCommitment, rateLimit, []); return this.contract.register(idCommitmentBigInt, rateLimit, []);
} }
public async withdraw(token: string, holder: string): Promise<void> { public async withdraw(token: string, walletAddress: string): Promise<void> {
try { try {
const tx = await this.contract.withdraw(token, { from: holder }); const tx = await this.contract.withdraw(token, walletAddress);
await tx.wait(); await tx.wait();
} catch (error) { } catch (error) {
log.error(`Error in withdraw: ${(error as Error).message}`); log.error(`Error in withdraw: ${(error as Error).message}`);
@ -478,7 +501,7 @@ export class RLNBaseContract {
// Check if the ID commitment is already registered // Check if the ID commitment is already registered
const existingIndex = await this.getMemberIndex( const existingIndex = await this.getMemberIndex(
identity.IDCommitmentBigInt.toString() identity.IDCommitmentBigInt
); );
if (existingIndex) { if (existingIndex) {
throw new Error( throw new Error(
@ -516,7 +539,7 @@ export class RLNBaseContract {
} }
const memberRegistered = txRegisterReceipt.events?.find( const memberRegistered = txRegisterReceipt.events?.find(
(event) => event.event === "MembershipRegistered" (event: ethers.Event) => event.event === "MembershipRegistered"
); );
if (!memberRegistered || !memberRegistered.args) { if (!memberRegistered || !memberRegistered.args) {
@ -610,7 +633,7 @@ export class RLNBaseContract {
const txRegisterReceipt = await txRegisterResponse.wait(); const txRegisterReceipt = await txRegisterResponse.wait();
const memberRegistered = txRegisterReceipt.events?.find( const memberRegistered = txRegisterReceipt.events?.find(
(event) => event.event === "MembershipRegistered" (event: ethers.Event) => event.event === "MembershipRegistered"
); );
if (!memberRegistered || !memberRegistered.args) { if (!memberRegistered || !memberRegistered.args) {
@ -653,16 +676,16 @@ export class RLNBaseContract {
} }
/** /**
* Validates that the rate limit is within the allowed range * Validates that the rate limit is within the allowed range (sync)
* @throws Error if the rate limit is outside the allowed range * @throws Error if the rate limit is outside the allowed range
*/ */
private validateRateLimit(rateLimit: number): void { private validateRateLimit(rateLimit: number): void {
if ( if (this.minRateLimit === undefined || this.maxRateLimit === undefined) {
rateLimit < RATE_LIMIT_PARAMS.MIN_RATE || throw new Error("Rate limits not initialized");
rateLimit > RATE_LIMIT_PARAMS.MAX_RATE }
) { if (rateLimit < this.minRateLimit || rateLimit > this.maxRateLimit) {
throw new Error( throw new Error(
`Rate limit must be between ${RATE_LIMIT_PARAMS.MIN_RATE} and ${RATE_LIMIT_PARAMS.MAX_RATE} messages per epoch` `Rate limit must be between ${this.minRateLimit} and ${this.maxRateLimit} messages per epoch`
); );
} }
} }
@ -689,11 +712,11 @@ export class RLNBaseContract {
} }
private async getMemberIndex( private async getMemberIndex(
idCommitment: string idCommitmentBigInt: bigint
): Promise<ethers.BigNumber | undefined> { ): Promise<ethers.BigNumber | undefined> {
try { try {
const events = await this.contract.queryFilter( const events = await this.contract.queryFilter(
this.contract.filters.MembershipRegistered(idCommitment) this.contract.filters.MembershipRegistered(idCommitmentBigInt)
); );
if (events.length === 0) return undefined; if (events.length === 0) return undefined;
@ -704,4 +727,45 @@ export class RLNBaseContract {
return undefined; return undefined;
} }
} }
public async getMembershipStatus(
idCommitment: bigint
): Promise<"expired" | "grace" | "active"> {
const [isExpired, isInGrace] = await Promise.all([
this.contract.isExpired(idCommitment),
this.contract.isInGracePeriod(idCommitment)
]);
if (isExpired) return "expired";
if (isInGrace) return "grace";
return "active";
}
/**
* Checks if a membership is expired for the given idCommitment
* @param idCommitmentBigInt The idCommitment as bigint
* @returns Promise<boolean> True if expired, false otherwise
*/
public async isExpired(idCommitmentBigInt: bigint): Promise<boolean> {
try {
return await this.contract.isExpired(idCommitmentBigInt);
} catch (error) {
log.error("Error in isExpired:", error);
return false;
}
}
/**
* Checks if a membership is in grace period for the given idCommitment
* @param idCommitmentBigInt The idCommitment as bigint
* @returns Promise<boolean> True if in grace period, false otherwise
*/
public async isInGracePeriod(idCommitmentBigInt: bigint): Promise<boolean> {
try {
return await this.contract.isInGracePeriod(idCommitmentBigInt);
} catch (error) {
log.error("Error in isInGracePeriod:", error);
return false;
}
}
} }

View File

@ -38,6 +38,11 @@ export interface MembershipInfo {
startBlock: number; startBlock: number;
endBlock: number; endBlock: number;
state: MembershipState; state: MembershipState;
depositAmount: ethers.BigNumber;
activeDuration: number;
gracePeriodDuration: number;
holder: string;
token: string;
} }
export enum MembershipState { export enum MembershipState {

View File

@ -80,7 +80,7 @@ export class RLNCredentialsManager {
this.credentials = credentials; this.credentials = credentials;
this.signer = signer!; this.signer = signer!;
this.contract = new RLNBaseContract({ this.contract = await RLNBaseContract.create({
address: address!, address: address!,
signer: signer!, signer: signer!,
rateLimit: rateLimit ?? this.zerokit?.rateLimit rateLimit: rateLimit ?? this.zerokit?.rateLimit

View File

@ -34,7 +34,9 @@ export type {
Keccak256Hash, Keccak256Hash,
KeystoreEntity, KeystoreEntity,
MembershipHash, MembershipHash,
MembershipInfo, KeystoreMembershipInfo,
Password, Password,
Sha256Hash Sha256Hash
} from "./keystore/types.js"; } from "./keystore/types.js";
export * from "./contract/index.js";

View File

@ -11,7 +11,7 @@ import { IdentityCredential } from "../identity.js";
import { buildBigIntFromUint8Array } from "../utils/bytes.js"; import { buildBigIntFromUint8Array } from "../utils/bytes.js";
import { Keystore } from "./keystore.js"; import { Keystore } from "./keystore.js";
import type { MembershipInfo } from "./types.js"; import type { KeystoreMembershipInfo } from "./types.js";
const DEFAULT_PASSWORD = "sup3rsecure"; const DEFAULT_PASSWORD = "sup3rsecure";
const NWAKU_KEYSTORE = { const NWAKU_KEYSTORE = {
@ -233,7 +233,7 @@ describe("Keystore", () => {
treeIndex: 8, treeIndex: 8,
address: "0x8e1F3742B987d8BA376c0CBbD7357fE1F003ED71", address: "0x8e1F3742B987d8BA376c0CBbD7357fE1F003ED71",
rateLimit: undefined rateLimit: undefined
} as unknown as MembershipInfo; } as unknown as KeystoreMembershipInfo;
const store = Keystore.create(); const store = Keystore.create();
const hash = await store.addCredential( const hash = await store.addCredential(
@ -284,7 +284,7 @@ describe("Keystore", () => {
treeIndex: 8, treeIndex: 8,
address: "0x8e1F3742B987d8BA376c0CBbD7357fE1F003ED71", address: "0x8e1F3742B987d8BA376c0CBbD7357fE1F003ED71",
rateLimit: undefined rateLimit: undefined
} as unknown as MembershipInfo; } as unknown as KeystoreMembershipInfo;
const store = Keystore.fromObject(NWAKU_KEYSTORE as any); const store = Keystore.fromObject(NWAKU_KEYSTORE as any);

View File

@ -21,8 +21,8 @@ import { isCredentialValid, isKeystoreValid } from "./schema_validator.js";
import type { import type {
Keccak256Hash, Keccak256Hash,
KeystoreEntity, KeystoreEntity,
KeystoreMembershipInfo,
MembershipHash, MembershipHash,
MembershipInfo,
Password, Password,
Sha256Hash Sha256Hash
} from "./types.js"; } from "./types.js";
@ -310,7 +310,9 @@ export class Keystore {
// follows nwaku implementation // follows nwaku implementation
// https://github.com/waku-org/nwaku/blob/f05528d4be3d3c876a8b07f9bb7dfaae8aa8ec6e/waku/waku_keystore/protocol_types.nim#L111 // https://github.com/waku-org/nwaku/blob/f05528d4be3d3c876a8b07f9bb7dfaae8aa8ec6e/waku/waku_keystore/protocol_types.nim#L111
private static computeMembershipHash(info: MembershipInfo): MembershipHash { private static computeMembershipHash(
info: KeystoreMembershipInfo
): MembershipHash {
return bytesToHex( return bytesToHex(
sha256(utf8ToBytes(`${info.chainId}${info.address}${info.treeIndex}`)) sha256(utf8ToBytes(`${info.chainId}${info.address}${info.treeIndex}`))
).toUpperCase(); ).toUpperCase();

View File

@ -7,7 +7,7 @@ export type Password = string | Uint8Array;
// see reference // see reference
// https://github.com/waku-org/nwaku/blob/f05528d4be3d3c876a8b07f9bb7dfaae8aa8ec6e/waku/waku_keystore/protocol_types.nim#L111 // https://github.com/waku-org/nwaku/blob/f05528d4be3d3c876a8b07f9bb7dfaae8aa8ec6e/waku/waku_keystore/protocol_types.nim#L111
export type MembershipInfo = { export type KeystoreMembershipInfo = {
chainId: string; chainId: string;
address: string; address: string;
treeIndex: number; treeIndex: number;
@ -16,7 +16,7 @@ export type MembershipInfo = {
export type KeystoreEntity = { export type KeystoreEntity = {
identity: IdentityCredential; identity: IdentityCredential;
membership: MembershipInfo; membership: KeystoreMembershipInfo;
}; };
export type DecryptedCredentials = KeystoreEntity; export type DecryptedCredentials = KeystoreEntity;