Merge branch 'feat/rln-light-without-zerokit' of github.com:waku-org/js-waku into chore/match-spec-format

This commit is contained in:
Danish Arora 2025-04-04 15:55:06 +05:30
commit 8f85be3b34
No known key found for this signature in database
GPG Key ID: 1C6EF37CDAE1426E
10 changed files with 762 additions and 1476 deletions

View File

@ -1,84 +1,47 @@
import { Logger } from "@waku/utils";
import { ethers } from "ethers";
import type { IdentityCredential } from "../identity.js";
import type { DecryptedCredentials } from "../keystore/index.js";
import { IdentityCredential } from "../identity.js";
import { DecryptedCredentials } from "../keystore/types.js";
import { RLN_ABI } from "./abi.js";
import { DEFAULT_RATE_LIMIT, RATE_LIMIT_PARAMS } from "./constants.js";
import {
CustomQueryOptions,
FetchMembersOptions,
Member,
MembershipInfo,
MembershipRegisteredEvent,
MembershipState,
RLNContractInitOptions
} from "./types.js";
const log = new Logger("waku:rln:contract");
const log = new Logger("waku:rln:contract:base");
type Member = {
idCommitment: string;
index: ethers.BigNumber;
};
interface RLNContractOptions {
signer: ethers.Signer;
address: string;
rateLimit?: number;
}
interface RLNContractInitOptions extends RLNContractOptions {
contract?: ethers.Contract;
}
export interface MembershipRegisteredEvent {
idCommitment: string;
membershipRateLimit: ethers.BigNumber;
index: ethers.BigNumber;
}
type FetchMembersOptions = {
fromBlock?: number;
fetchRange?: number;
fetchChunks?: number;
};
export interface MembershipInfo {
index: ethers.BigNumber;
idCommitment: string;
rateLimit: number;
startBlock: number;
endBlock: number;
state: MembershipState;
}
export enum MembershipState {
Active = "Active",
GracePeriod = "GracePeriod",
Expired = "Expired",
ErasedAwaitsWithdrawal = "ErasedAwaitsWithdrawal"
}
export class RLNLightContract {
export class RLNBaseContract {
public contract: ethers.Contract;
private deployBlock: undefined | number;
private rateLimit: number;
private _members: Map<number, Member> = new Map();
protected _members: Map<number, Member> = new Map();
private _membersFilter: ethers.EventFilter;
private _membershipErasedFilter: ethers.EventFilter;
private _membersExpiredFilter: ethers.EventFilter;
/**
* Asynchronous initializer for RLNContract.
* Constructor for RLNBaseContract.
* Allows injecting a mocked contract for testing purposes.
*/
public static async init(
options: RLNContractInitOptions
): Promise<RLNLightContract> {
const rlnContract = new RLNLightContract(options);
public constructor(options: RLNContractInitOptions) {
// Initialize members and subscriptions
this.fetchMembers()
.then(() => {
this.subscribeToMembers();
})
.catch((error) => {
log.error("Failed to initialize members", { error });
});
await rlnContract.fetchMembers();
rlnContract.subscribeToMembers();
return rlnContract;
}
private constructor(options: RLNContractInitOptions) {
const {
address,
signer,
@ -86,19 +49,10 @@ export class RLNLightContract {
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.validateRateLimit(rateLimit);
this.rateLimit = rateLimit;
// Use the injected contract if provided; otherwise, instantiate a new one.
this.contract = contract || new ethers.Contract(address, RLN_ABI, signer);
this.rateLimit = rateLimit;
// Initialize event filters
this._membersFilter = this.contract.filters.MembershipRegistered();
@ -151,7 +105,7 @@ export class RLNLightContract {
*/
public async getMaxTotalRateLimit(): Promise<number> {
const maxTotalRate = await this.contract.maxTotalRateLimit();
return ethers.BigNumber.from(maxTotalRate).toNumber();
return maxTotalRate.toNumber();
}
/**
@ -160,7 +114,7 @@ export class RLNLightContract {
*/
public async getCurrentTotalRateLimit(): Promise<number> {
const currentTotal = await this.contract.currentTotalRateLimit();
return ethers.BigNumber.from(currentTotal).toNumber();
return currentTotal.toNumber();
}
/**
@ -172,9 +126,7 @@ export class RLNLightContract {
this.contract.maxTotalRateLimit(),
this.contract.currentTotalRateLimit()
]);
return ethers.BigNumber.from(maxTotal)
.sub(ethers.BigNumber.from(currentTotal))
.toNumber();
return Number(maxTotal) - Number(currentTotal);
}
/**
@ -182,6 +134,7 @@ export class RLNLightContract {
* @param newRateLimit The new rate limit to use
*/
public async setRateLimit(newRateLimit: number): Promise<void> {
this.validateRateLimit(newRateLimit);
this.rateLimit = newRateLimit;
}
@ -192,43 +145,31 @@ export class RLNLightContract {
return sortedMembers;
}
private get membersFilter(): ethers.EventFilter {
if (!this._membersFilter) {
throw Error("Members filter was not initialized.");
}
return this._membersFilter;
}
private get membershipErasedFilter(): ethers.EventFilter {
if (!this._membershipErasedFilter) {
throw Error("MembershipErased filter was not initialized.");
}
return this._membershipErasedFilter;
}
private get membersExpiredFilter(): ethers.EventFilter {
if (!this._membersExpiredFilter) {
throw Error("MembersExpired filter was not initialized.");
}
return this._membersExpiredFilter;
}
public async fetchMembers(options: FetchMembersOptions = {}): Promise<void> {
const registeredMemberEvents = await queryFilter(this.contract, {
fromBlock: this.deployBlock,
...options,
membersFilter: this.membersFilter
});
const removedMemberEvents = await queryFilter(this.contract, {
fromBlock: this.deployBlock,
...options,
membersFilter: this.membershipErasedFilter
});
const expiredMemberEvents = await queryFilter(this.contract, {
fromBlock: this.deployBlock,
...options,
membersFilter: this.membersExpiredFilter
});
const registeredMemberEvents = await RLNBaseContract.queryFilter(
this.contract,
{
fromBlock: this.deployBlock,
...options,
membersFilter: this.membersFilter
}
);
const removedMemberEvents = await RLNBaseContract.queryFilter(
this.contract,
{
fromBlock: this.deployBlock,
...options,
membersFilter: this.membershipErasedFilter
}
);
const expiredMemberEvents = await RLNBaseContract.queryFilter(
this.contract,
{
fromBlock: this.deployBlock,
...options,
membersFilter: this.membersExpiredFilter
}
);
const events = [
...registeredMemberEvents,
@ -238,6 +179,58 @@ export class RLNLightContract {
this.processEvents(events);
}
public static async queryFilter(
contract: ethers.Contract,
options: CustomQueryOptions
): Promise<ethers.Event[]> {
const FETCH_CHUNK = 5;
const BLOCK_RANGE = 3000;
const {
fromBlock,
membersFilter,
fetchRange = BLOCK_RANGE,
fetchChunks = FETCH_CHUNK
} = options;
if (fromBlock === undefined) {
return contract.queryFilter(membersFilter);
}
if (!contract.provider) {
throw Error("No provider found on the contract.");
}
const toBlock = await contract.provider.getBlockNumber();
if (toBlock - fromBlock < fetchRange) {
return contract.queryFilter(membersFilter, fromBlock, toBlock);
}
const events: ethers.Event[][] = [];
const chunks = RLNBaseContract.splitToChunks(
fromBlock,
toBlock,
fetchRange
);
for (const portion of RLNBaseContract.takeN<[number, number]>(
chunks,
fetchChunks
)) {
const promises = portion.map(([left, right]) =>
RLNBaseContract.ignoreErrors(
contract.queryFilter(membersFilter, left, right),
[]
)
);
const fetchedEvents = await Promise.all(promises);
events.push(fetchedEvents.flatMap((v) => v));
}
return events.flatMap((v) => v);
}
public processEvents(events: ethers.Event[]): void {
const toRemoveTable = new Map<number, number[]>();
const toInsertTable = new Map<number, ethers.Event[]>();
@ -280,6 +273,53 @@ export class RLNLightContract {
});
}
public static splitToChunks(
from: number,
to: number,
step: number
): Array<[number, number]> {
const chunks: Array<[number, number]> = [];
let left = from;
while (left < to) {
const right = left + step < to ? left + step : to;
chunks.push([left, right] as [number, number]);
left = right;
}
return chunks;
}
public static *takeN<T>(array: T[], size: number): Iterable<T[]> {
let start = 0;
while (start < array.length) {
const portion = array.slice(start, start + size);
yield portion;
start += size;
}
}
public static async 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;
}
}
public subscribeToMembers(): void {
this.contract.on(
this.membersFilter,
@ -318,6 +358,116 @@ export class RLNLightContract {
);
}
/**
* 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(
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, []);
}
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 registerWithIdentity(
identity: IdentityCredential
): Promise<DecryptedCredentials | undefined> {
@ -430,43 +580,6 @@ export class RLNLightContract {
}
}
/**
* 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 registerWithPermitAndErase(
identity: IdentityCredential,
permit: {
@ -539,77 +652,40 @@ export class RLNLightContract {
}
}
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> {
/**
* Validates that the rate limit is within the allowed range
* @throws Error if the rate limit is outside the allowed range
*/
private validateRateLimit(rateLimit: number): void {
if (
rateLimit < RATE_LIMIT_PARAMS.MIN_RATE ||
rateLimit > RATE_LIMIT_PARAMS.MAX_RATE
) {
throw new Error(
`Rate limit must be between ${RATE_LIMIT_PARAMS.MIN_RATE} and ${RATE_LIMIT_PARAMS.MAX_RATE}`
`Rate limit must be between ${RATE_LIMIT_PARAMS.MIN_RATE} and ${RATE_LIMIT_PARAMS.MAX_RATE} messages per epoch`
);
}
return this.contract.register(idCommitment, rateLimit, []);
}
private get membersFilter(): ethers.EventFilter {
if (!this._membersFilter) {
throw Error("Members filter was not initialized.");
}
return this._membersFilter;
}
private get membershipErasedFilter(): ethers.EventFilter {
if (!this._membershipErasedFilter) {
throw Error("MembershipErased filter was not initialized.");
}
return this._membershipErasedFilter;
}
private get membersExpiredFilter(): ethers.EventFilter {
if (!this._membersExpiredFilter) {
throw Error("MembersExpired filter was not initialized.");
}
return this._membersExpiredFilter;
}
private async getMemberIndex(
@ -629,97 +705,3 @@ export class RLNLightContract {
}
}
}
interface CustomQueryOptions extends FetchMembersOptions {
membersFilter: ethers.EventFilter;
}
// These values should be tested on other networks
const FETCH_CHUNK = 5;
const BLOCK_RANGE = 3000;
async function queryFilter(
contract: ethers.Contract,
options: CustomQueryOptions
): Promise<ethers.Event[]> {
const {
fromBlock,
membersFilter,
fetchRange = BLOCK_RANGE,
fetchChunks = FETCH_CHUNK
} = options;
if (fromBlock === undefined) {
return contract.queryFilter(membersFilter);
}
if (!contract.provider) {
throw Error("No provider found on the contract.");
}
const toBlock = await contract.provider.getBlockNumber();
if (toBlock - fromBlock < fetchRange) {
return contract.queryFilter(membersFilter, fromBlock, toBlock);
}
const events: ethers.Event[][] = [];
const chunks = splitToChunks(fromBlock, toBlock, fetchRange);
for (const portion of takeN<[number, number]>(chunks, fetchChunks)) {
const promises = portion.map(([left, right]) =>
ignoreErrors(contract.queryFilter(membersFilter, left, right), [])
);
const fetchedEvents = await Promise.all(promises);
events.push(fetchedEvents.flatMap((v) => v));
}
return events.flatMap((v) => v);
}
function splitToChunks(
from: number,
to: number,
step: number
): Array<[number, number]> {
const chunks: Array<[number, number]> = [];
let left = from;
while (left < to) {
const right = left + step < to ? left + step : to;
chunks.push([left, right] as [number, number]);
left = right;
}
return chunks;
}
function* takeN<T>(array: T[], size: number): Iterable<T[]> {
let start = 0;
while (start < array.length) {
const portion = array.slice(start, start + size);
yield portion;
start += size;
}
}
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

@ -40,7 +40,7 @@ describe("RLN Contract abstraction - RLN", () => {
mockedRegistryContract
);
await rlnContract.fetchMembers(rlnInstance, {
await rlnContract.fetchMembers({
fromBlock: 0,
fetchRange: 1000,
fetchChunks: 2

View File

@ -2,72 +2,19 @@ import { Logger } from "@waku/utils";
import { hexToBytes } from "@waku/utils/bytes";
import { ethers } from "ethers";
import type { IdentityCredential } from "../identity.js";
import type { DecryptedCredentials } from "../keystore/index.js";
import type { RLNInstance } from "../rln.js";
import { MerkleRootTracker } from "../root_tracker.js";
import { zeroPadLE } from "../utils/bytes.js";
import { RLN_ABI } from "./abi.js";
import { DEFAULT_RATE_LIMIT, RATE_LIMIT_PARAMS } from "./constants.js";
import { RLNBaseContract } from "./rln_base_contract.js";
import { RLNContractInitOptions } from "./types.js";
const log = new Logger("waku:rln:contract");
type Member = {
idCommitment: string;
index: ethers.BigNumber;
};
interface RLNContractOptions {
signer: ethers.Signer;
address: string;
rateLimit?: number;
}
interface RLNContractInitOptions extends RLNContractOptions {
contract?: ethers.Contract;
}
export interface MembershipRegisteredEvent {
idCommitment: string;
membershipRateLimit: ethers.BigNumber;
index: ethers.BigNumber;
}
type FetchMembersOptions = {
fromBlock?: number;
fetchRange?: number;
fetchChunks?: number;
};
export interface MembershipInfo {
index: ethers.BigNumber;
idCommitment: string;
rateLimit: number;
startBlock: number;
endBlock: number;
state: MembershipState;
}
export enum MembershipState {
Active = "Active",
GracePeriod = "GracePeriod",
Expired = "Expired",
ErasedAwaitsWithdrawal = "ErasedAwaitsWithdrawal"
}
export class RLNContract {
public contract: ethers.Contract;
export class RLNContract extends RLNBaseContract {
private instance: RLNInstance;
private merkleRootTracker: MerkleRootTracker;
private deployBlock: undefined | number;
private rateLimit: number;
private _members: Map<number, Member> = new Map();
private _membersFilter: ethers.EventFilter;
private _membershipErasedFilter: ethers.EventFilter;
private _membersExpiredFilter: ethers.EventFilter;
/**
* Asynchronous initializer for RLNContract.
* Allows injecting a mocked contract for testing purposes.
@ -78,9 +25,6 @@ export class RLNContract {
): Promise<RLNContract> {
const rlnContract = new RLNContract(rlnInstance, options);
await rlnContract.fetchMembers(rlnInstance);
rlnContract.subscribeToMembers(rlnInstance);
return rlnContract;
}
@ -88,178 +32,15 @@ export class RLNContract {
rlnInstance: RLNInstance,
options: RLNContractInitOptions
) {
const {
address,
signer,
rateLimit = DEFAULT_RATE_LIMIT,
contract
} = options;
super(options);
this.validateRateLimit(rateLimit);
this.rateLimit = rateLimit;
this.instance = rlnInstance;
const initialRoot = rlnInstance.zerokit.getMerkleRoot();
// Use the injected contract if provided; otherwise, instantiate a new one.
this.contract = contract || new ethers.Contract(address, RLN_ABI, signer);
this.merkleRootTracker = new MerkleRootTracker(5, initialRoot);
// Initialize event filters
this._membersFilter = this.contract.filters.MembershipRegistered();
this._membershipErasedFilter = this.contract.filters.MembershipErased();
this._membersExpiredFilter = this.contract.filters.MembershipExpired();
}
/**
* Validates that the rate limit is within the allowed range
* @throws Error if the rate limit is outside the allowed range
*/
private validateRateLimit(rateLimit: number): void {
if (
rateLimit < RATE_LIMIT_PARAMS.MIN_RATE ||
rateLimit > RATE_LIMIT_PARAMS.MAX_RATE
) {
throw new Error(
`Rate limit must be between ${RATE_LIMIT_PARAMS.MIN_RATE} and ${RATE_LIMIT_PARAMS.MAX_RATE} messages per epoch`
);
}
}
/**
* Gets the current rate limit for this contract instance
*/
public getRateLimit(): number {
return this.rateLimit;
}
/**
* Gets the contract address
*/
public get address(): string {
return this.contract.address;
}
/**
* Gets the contract provider
*/
public get provider(): ethers.providers.Provider {
return this.contract.provider;
}
/**
* Gets the minimum allowed rate limit from the contract
* @returns Promise<number> The minimum rate limit in messages per epoch
*/
public async getMinRateLimit(): Promise<number> {
const minRate = await this.contract.minMembershipRateLimit();
return ethers.BigNumber.from(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 ethers.BigNumber.from(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 Number(maxTotal) - Number(currentTotal);
}
/**
* Updates the rate limit for future registrations
* @param newRateLimit The new rate limit to use
*/
public async setRateLimit(newRateLimit: number): Promise<void> {
this.validateRateLimit(newRateLimit);
this.rateLimit = newRateLimit;
}
public get members(): Member[] {
const sortedMembers = Array.from(this._members.values()).sort(
(left, right) => left.index.toNumber() - right.index.toNumber()
);
return sortedMembers;
}
private get membersFilter(): ethers.EventFilter {
if (!this._membersFilter) {
throw Error("Members filter was not initialized.");
}
return this._membersFilter;
}
private get membershipErasedFilter(): ethers.EventFilter {
if (!this._membershipErasedFilter) {
throw Error("MembershipErased filter was not initialized.");
}
return this._membershipErasedFilter;
}
private get membersExpiredFilter(): ethers.EventFilter {
if (!this._membersExpiredFilter) {
throw Error("MembersExpired filter was not initialized.");
}
return this._membersExpiredFilter;
}
public async fetchMembers(
rlnInstance: RLNInstance,
options: FetchMembersOptions = {}
): Promise<void> {
const registeredMemberEvents = await queryFilter(this.contract, {
fromBlock: this.deployBlock,
...options,
membersFilter: this.membersFilter
});
const removedMemberEvents = await queryFilter(this.contract, {
fromBlock: this.deployBlock,
...options,
membersFilter: this.membershipErasedFilter
});
const expiredMemberEvents = await queryFilter(this.contract, {
fromBlock: this.deployBlock,
...options,
membersFilter: this.membersExpiredFilter
});
const events = [
...registeredMemberEvents,
...removedMemberEvents,
...expiredMemberEvents
];
this.processEvents(rlnInstance, events);
}
public processEvents(rlnInstance: RLNInstance, events: ethers.Event[]): void {
public override processEvents(events: ethers.Event[]): void {
const toRemoveTable = new Map<number, number[]>();
const toInsertTable = new Map<number, ethers.Event[]>();
@ -306,8 +87,8 @@ export class RLNContract {
}
});
this.removeMembers(rlnInstance, toRemoveTable);
this.insertMembers(rlnInstance, toInsertTable);
this.removeMembers(this.instance, toRemoveTable);
this.insertMembers(this.instance, toInsertTable);
}
private insertMembers(
@ -360,439 +141,4 @@ export class RLNContract {
this.merkleRootTracker.backFill(blockNumber);
});
}
public subscribeToMembers(rlnInstance: RLNInstance): void {
this.contract.on(
this.membersFilter,
(
_idCommitment: string,
_membershipRateLimit: ethers.BigNumber,
_index: ethers.BigNumber,
event: ethers.Event
) => {
this.processEvents(rlnInstance, [event]);
}
);
this.contract.on(
this.membershipErasedFilter,
(
_idCommitment: string,
_membershipRateLimit: ethers.BigNumber,
_index: ethers.BigNumber,
event: ethers.Event
) => {
this.processEvents(rlnInstance, [event]);
}
);
this.contract.on(
this.membersExpiredFilter,
(
_idCommitment: string,
_membershipRateLimit: ethers.BigNumber,
_index: ethers.BigNumber,
event: ethers.Event
) => {
this.processEvents(rlnInstance, [event]);
}
);
}
public async registerWithIdentity(
identity: IdentityCredential
): Promise<DecryptedCredentials | undefined> {
try {
log.info(
`Registering identity with rate limit: ${this.rateLimit} messages/epoch`
);
// Check if the ID commitment is already registered
const existingIndex = await this.getMemberIndex(
identity.IDCommitmentBigInt.toString()
);
if (existingIndex) {
throw new Error(
`ID commitment is already registered with index ${existingIndex}`
);
}
// Check if there's enough remaining rate limit
const remainingRateLimit = await this.getRemainingTotalRateLimit();
if (remainingRateLimit < this.rateLimit) {
throw new Error(
`Not enough remaining rate limit. Requested: ${this.rateLimit}, Available: ${remainingRateLimit}`
);
}
const estimatedGas = await this.contract.estimateGas.register(
identity.IDCommitmentBigInt,
this.rateLimit,
[]
);
const gasLimit = estimatedGas.add(10000);
const txRegisterResponse: ethers.ContractTransaction =
await this.contract.register(
identity.IDCommitmentBigInt,
this.rateLimit,
[],
{ gasLimit }
);
const txRegisterReceipt = await txRegisterResponse.wait();
if (txRegisterReceipt.status === 0) {
throw new Error("Transaction failed on-chain");
}
const memberRegistered = txRegisterReceipt.events?.find(
(event) => event.event === "MembershipRegistered"
);
if (!memberRegistered || !memberRegistered.args) {
log.error(
"Failed to register membership: No MembershipRegistered event found"
);
return undefined;
}
const decodedData: MembershipRegisteredEvent = {
idCommitment: memberRegistered.args.idCommitment,
membershipRateLimit: memberRegistered.args.membershipRateLimit,
index: memberRegistered.args.index
};
log.info(
`Successfully registered membership with index ${decodedData.index} ` +
`and rate limit ${decodedData.membershipRateLimit}`
);
const network = await this.contract.provider.getNetwork();
const address = this.contract.address;
const membershipId = decodedData.index.toString();
return {
identity,
membership: {
address,
treeIndex: parseInt(membershipId),
chainId: network.chainId.toString(),
rateLimit: decodedData.membershipRateLimit.toNumber()
}
};
} catch (error) {
if (error instanceof Error) {
const errorMessage = error.message;
log.error("registerWithIdentity - error message:", errorMessage);
log.error("registerWithIdentity - error stack:", error.stack);
// Try to extract more specific error information
if (errorMessage.includes("CannotExceedMaxTotalRateLimit")) {
throw new Error(
"Registration failed: Cannot exceed maximum total rate limit"
);
} else if (errorMessage.includes("InvalidIdCommitment")) {
throw new Error("Registration failed: Invalid ID commitment");
} else if (errorMessage.includes("InvalidMembershipRateLimit")) {
throw new Error("Registration failed: Invalid membership rate limit");
} else if (errorMessage.includes("execution reverted")) {
throw new Error(
"Contract execution reverted. Check contract requirements."
);
} else {
throw new Error(`Error in registerWithIdentity: ${errorMessage}`);
}
} else {
throw new Error("Unknown error in registerWithIdentity", {
cause: error
});
}
}
}
/**
* Helper method to get remaining messages in current epoch
* @param membershipId The ID of the membership to check
* @returns number of remaining messages allowed in current epoch
*/
public async getRemainingMessages(membershipId: number): Promise<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, 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,
membershipRateLimit: memberRegistered.args.membershipRateLimit,
index: memberRegistered.args.index
};
log.info(
`Successfully registered membership with permit. Index: ${decodedData.index}, ` +
`Rate limit: ${decodedData.membershipRateLimit}, Erased ${idCommitmentsToErase.length} commitments`
);
const network = await this.contract.provider.getNetwork();
const address = this.contract.address;
const membershipId = decodedData.index.toString();
return {
identity,
membership: {
address,
treeIndex: parseInt(membershipId),
chainId: network.chainId.toString(),
rateLimit: decodedData.membershipRateLimit.toNumber()
}
};
} catch (error) {
log.error(
`Error in registerWithPermitAndErase: ${(error as Error).message}`
);
return undefined;
}
}
public roots(): Uint8Array[] {
return this.merkleRootTracker.roots();
}
public async withdraw(token: string, holder: string): Promise<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 = this.rateLimit
): Promise<ethers.ContractTransaction> {
this.validateRateLimit(rateLimit);
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;
}
}
}
interface CustomQueryOptions extends FetchMembersOptions {
membersFilter: ethers.EventFilter;
}
// These values should be tested on other networks
const FETCH_CHUNK = 5;
const BLOCK_RANGE = 3000;
async function queryFilter(
contract: ethers.Contract,
options: CustomQueryOptions
): Promise<ethers.Event[]> {
const {
fromBlock,
membersFilter,
fetchRange = BLOCK_RANGE,
fetchChunks = FETCH_CHUNK
} = options;
if (fromBlock === undefined) {
return contract.queryFilter(membersFilter);
}
if (!contract.provider) {
throw Error("No provider found on the contract.");
}
const toBlock = await contract.provider.getBlockNumber();
if (toBlock - fromBlock < fetchRange) {
return contract.queryFilter(membersFilter, fromBlock, toBlock);
}
const events: ethers.Event[][] = [];
const chunks = splitToChunks(fromBlock, toBlock, fetchRange);
for (const portion of takeN<[number, number]>(chunks, fetchChunks)) {
const promises = portion.map(([left, right]) =>
ignoreErrors(contract.queryFilter(membersFilter, left, right), [])
);
const fetchedEvents = await Promise.all(promises);
events.push(fetchedEvents.flatMap((v) => v));
}
return events.flatMap((v) => v);
}
function splitToChunks(
from: number,
to: number,
step: number
): Array<[number, number]> {
const chunks: Array<[number, number]> = [];
let left = from;
while (left < to) {
const right = left + step < to ? left + step : to;
chunks.push([left, right] as [number, number]);
left = right;
}
return chunks;
}
function* takeN<T>(array: T[], size: number): Iterable<T[]> {
let start = 0;
while (start < array.length) {
const portion = array.slice(start, start + size);
yield portion;
start += size;
}
}
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

@ -0,0 +1,48 @@
import { ethers } from "ethers";
export interface CustomQueryOptions extends FetchMembersOptions {
membersFilter: ethers.EventFilter;
}
export type Member = {
idCommitment: string;
index: ethers.BigNumber;
};
export interface RLNContractOptions {
signer: ethers.Signer;
address: string;
rateLimit?: number;
}
export interface RLNContractInitOptions extends RLNContractOptions {
contract?: ethers.Contract;
}
export interface MembershipRegisteredEvent {
idCommitment: string;
membershipRateLimit: ethers.BigNumber;
index: ethers.BigNumber;
}
export type FetchMembersOptions = {
fromBlock?: number;
fetchRange?: number;
fetchChunks?: number;
};
export interface MembershipInfo {
index: ethers.BigNumber;
idCommitment: string;
rateLimit: number;
startBlock: number;
endBlock: number;
state: MembershipState;
}
export enum MembershipState {
Active = "Active",
GracePeriod = "GracePeriod",
Expired = "Expired",
ErasedAwaitsWithdrawal = "ErasedAwaitsWithdrawal"
}

View File

@ -5,5 +5,5 @@ export async function createRLN(): Promise<RLNInstance> {
// asynchronously. This file does the single async import, so
// that no one else needs to worry about it again.
const rlnModule = await import("./rln.js");
return rlnModule.create();
return rlnModule.RLNInstance.create();
}

View File

@ -0,0 +1,306 @@
import { hmac } from "@noble/hashes/hmac";
import { sha256 } from "@noble/hashes/sha256";
import { Logger } from "@waku/utils";
import { ethers } from "ethers";
import { LINEA_CONTRACT } from "./contract/constants.js";
import { RLNBaseContract } from "./contract/rln_base_contract.js";
import { IdentityCredential } from "./identity.js";
import { Keystore } from "./keystore/index.js";
import type {
DecryptedCredentials,
EncryptedCredentials
} from "./keystore/index.js";
import { KeystoreEntity, Password } from "./keystore/types.js";
import { RegisterMembershipOptions, StartRLNOptions } from "./types.js";
import {
buildBigIntFromUint8Array,
extractMetaMaskSigner
} from "./utils/index.js";
import { Zerokit } from "./zerokit.js";
const log = new Logger("waku:credentials");
/**
* Manages credentials for RLN
* This is a lightweight implementation of the RLN contract that doesn't require Zerokit
* It is used to register membership and generate identity credentials
*/
export class RLNCredentialsManager {
protected started = false;
protected starting = false;
private _contract: undefined | RLNBaseContract;
private _signer: undefined | ethers.Signer;
protected keystore = Keystore.create();
private _credentials: undefined | DecryptedCredentials;
public zerokit: undefined | Zerokit;
public constructor(zerokit?: Zerokit) {
log.info("RLNCredentialsManager initialized");
this.zerokit = zerokit;
}
public get contract(): undefined | RLNBaseContract {
return this._contract;
}
public set contract(contract: RLNBaseContract | undefined) {
this._contract = contract;
}
public get signer(): undefined | ethers.Signer {
return this._signer;
}
public set signer(signer: ethers.Signer | undefined) {
this._signer = signer;
}
public get credentials(): undefined | DecryptedCredentials {
return this._credentials;
}
public set credentials(credentials: DecryptedCredentials | undefined) {
this._credentials = credentials;
}
public get provider(): undefined | ethers.providers.Provider {
return this.contract?.provider;
}
public async start(options: StartRLNOptions = {}): Promise<void> {
if (this.started || this.starting) {
log.info("RLNCredentialsManager already started or starting");
return;
}
log.info("Starting RLNCredentialsManager");
this.starting = true;
try {
const { credentials, keystore } =
await RLNCredentialsManager.decryptCredentialsIfNeeded(
options.credentials
);
if (credentials) {
log.info("Credentials successfully decrypted");
}
const { signer, address, rateLimit } = await this.determineStartOptions(
options,
credentials
);
log.info(`Using contract address: ${address}`);
if (keystore) {
this.keystore = keystore;
log.info("Using provided keystore");
}
this._credentials = credentials;
this._signer = signer!;
this._contract = new RLNBaseContract({
address: address!,
signer: signer!,
rateLimit: rateLimit ?? this.zerokit?.getRateLimit
});
log.info("RLNCredentialsManager successfully started");
this.started = true;
} catch (error) {
log.error("Failed to start RLNCredentialsManager", error);
throw error;
} finally {
this.starting = false;
}
}
public async registerMembership(
options: RegisterMembershipOptions
): Promise<undefined | DecryptedCredentials> {
if (!this.contract) {
log.error("RLN Contract is not initialized");
throw Error("RLN Contract is not initialized.");
}
log.info("Registering membership");
let identity = "identity" in options && options.identity;
if ("signature" in options) {
log.info("Generating identity from signature");
if (this.zerokit) {
log.info("Using Zerokit to generate identity");
identity = this.zerokit.generateSeededIdentityCredential(
options.signature
);
} else {
log.info("Using local implementation to generate identity");
identity = this.generateSeededIdentityCredential(options.signature);
}
}
if (!identity) {
log.error("Missing signature or identity to register membership");
throw Error("Missing signature or identity to register membership.");
}
log.info("Registering identity with contract");
return this.contract.registerWithIdentity(identity);
}
/**
* Changes credentials in use by relying on provided Keystore earlier in rln.start
* @param id: string, hash of credentials to select from Keystore
* @param password: string or bytes to use to decrypt credentials from Keystore
*/
public async useCredentials(id: string, password: Password): Promise<void> {
log.info(`Attempting to use credentials with ID: ${id}`);
this._credentials = await this.keystore?.readCredential(id, password);
if (this._credentials) {
log.info("Successfully loaded credentials");
} else {
log.warn("Failed to load credentials");
}
}
protected async determineStartOptions(
options: StartRLNOptions,
credentials: KeystoreEntity | undefined
): Promise<StartRLNOptions> {
let chainId = credentials?.membership.chainId;
const address =
credentials?.membership.address ||
options.address ||
LINEA_CONTRACT.address;
if (address === LINEA_CONTRACT.address) {
chainId = LINEA_CONTRACT.chainId;
log.info(`Using Linea contract with chainId: ${chainId}`);
}
const signer = options.signer || (await extractMetaMaskSigner());
const currentChainId = await signer.getChainId();
log.info(`Current chain ID: ${currentChainId}`);
if (chainId && chainId !== currentChainId) {
log.error(
`Chain ID mismatch: contract=${chainId}, current=${currentChainId}`
);
throw Error(
`Failed to start RLN contract, chain ID of contract is different from current one: contract-${chainId}, current network-${currentChainId}`
);
}
return {
signer,
address
};
}
protected static async decryptCredentialsIfNeeded(
credentials?: EncryptedCredentials | DecryptedCredentials
): Promise<{ credentials?: DecryptedCredentials; keystore?: Keystore }> {
if (!credentials) {
log.info("No credentials provided");
return {};
}
if ("identity" in credentials) {
log.info("Using already decrypted credentials");
return { credentials };
}
log.info("Attempting to decrypt credentials");
const keystore = Keystore.fromString(credentials.keystore);
if (!keystore) {
log.warn("Failed to create keystore from string");
return {};
}
try {
const decryptedCredentials = await keystore.readCredential(
credentials.id,
credentials.password
);
log.info(`Successfully decrypted credentials with ID: ${credentials.id}`);
return {
keystore,
credentials: decryptedCredentials
};
} catch (error) {
log.error("Failed to decrypt credentials", error);
throw error;
}
}
protected async verifyCredentialsAgainstContract(
credentials: KeystoreEntity
): Promise<void> {
if (!this.contract) {
throw Error(
"Failed to verify chain coordinates: no contract initialized."
);
}
const registryAddress = credentials.membership.address;
const currentRegistryAddress = this.contract.address;
if (registryAddress !== currentRegistryAddress) {
throw Error(
`Failed to verify chain coordinates: credentials contract address=${registryAddress} is not equal to registryContract address=${currentRegistryAddress}`
);
}
const chainId = credentials.membership.chainId;
const network = await this.contract.provider.getNetwork();
const currentChainId = network.chainId;
if (chainId !== currentChainId) {
throw Error(
`Failed to verify chain coordinates: credentials chainID=${chainId} is not equal to registryContract chainID=${currentChainId}`
);
}
}
/**
* Generates an identity credential from a seed string
* This is a pure implementation that doesn't rely on Zerokit
* @param seed A string seed to generate the identity from
* @returns IdentityCredential
*/
private generateSeededIdentityCredential(seed: string): IdentityCredential {
log.info("Generating seeded identity credential");
// Convert the seed to bytes
const encoder = new TextEncoder();
const seedBytes = encoder.encode(seed);
// Generate deterministic values using HMAC-SHA256
// We use different context strings for each component to ensure they're different
const idTrapdoor = hmac(sha256, seedBytes, encoder.encode("IDTrapdoor"));
const idNullifier = hmac(sha256, seedBytes, encoder.encode("IDNullifier"));
// Generate IDSecretHash as a hash of IDTrapdoor and IDNullifier
const combinedBytes = new Uint8Array([...idTrapdoor, ...idNullifier]);
const idSecretHash = sha256(combinedBytes);
// Generate IDCommitment as a hash of IDSecretHash
const idCommitment = sha256(idSecretHash);
// Convert IDCommitment to BigInt
const idCommitmentBigInt = buildBigIntFromUint8Array(idCommitment);
log.info("Successfully generated identity credential");
return new IdentityCredential(
idTrapdoor,
idNullifier,
idSecretHash,
idCommitment,
idCommitmentBigInt
);
}
}

View File

@ -1,19 +1,19 @@
import { RLNDecoder, RLNEncoder } from "./codec.js";
import { RLN_ABI } from "./contract/abi.js";
import { LINEA_CONTRACT, RLNContract } from "./contract/index.js";
import { RLNLightContract } from "./contract/rln_light_contract.js";
import { RLNBaseContract } from "./contract/rln_base_contract.js";
import { createRLN } from "./create.js";
import { RLNCredentialsManager } from "./credentials_manager.js";
import { IdentityCredential } from "./identity.js";
import { Keystore } from "./keystore/index.js";
import { Proof } from "./proof.js";
import { RLNInstance } from "./rln.js";
import { RLNLightInstance } from "./rln_light.js";
import { MerkleRootTracker } from "./root_tracker.js";
import { extractMetaMaskSigner } from "./utils/index.js";
export {
RLNLightInstance,
RLNLightContract,
RLNCredentialsManager,
RLNBaseContract,
createRLN,
Keystore,
RLNInstance,

View File

@ -7,7 +7,6 @@ import type {
import { Logger } from "@waku/utils";
import init from "@waku/zerokit-rln-wasm";
import * as zerokitRLN from "@waku/zerokit-rln-wasm";
import { ethers } from "ethers";
import {
createRLNDecoder,
@ -16,258 +15,51 @@ import {
type RLNEncoder
} from "./codec.js";
import { DEFAULT_RATE_LIMIT } from "./contract/constants.js";
import { LINEA_CONTRACT, RLNContract } from "./contract/index.js";
import { IdentityCredential } from "./identity.js";
import { Keystore } from "./keystore/index.js";
import { RLNCredentialsManager } from "./credentials_manager.js";
import type {
DecryptedCredentials,
EncryptedCredentials
} from "./keystore/index.js";
import { KeystoreEntity, Password } from "./keystore/types.js";
import verificationKey from "./resources/verification_key";
import * as wc from "./resources/witness_calculator";
import { WitnessCalculator } from "./resources/witness_calculator";
import { extractMetaMaskSigner } from "./utils/index.js";
import { Zerokit } from "./zerokit.js";
const log = new Logger("waku:rln");
async function loadWitnessCalculator(): Promise<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)}`
);
}
}
async function loadZkey(): Promise<Uint8Array> {
try {
const url = new URL("./resources/rln_final.zkey", import.meta.url);
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to fetch zkey: ${response.status} ${response.statusText}`
);
}
return new Uint8Array(await response.arrayBuffer());
} catch (error) {
log.error("Error loading zkey:", error);
throw new Error(
`Failed to load zkey: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Create an instance of RLN
* @returns RLNInstance
*/
export async function create(): Promise<RLNInstance> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (init as any)?.();
zerokitRLN.init_panic_hook();
const witnessCalculator = await loadWitnessCalculator();
const zkey = await loadZkey();
const stringEncoder = new TextEncoder();
const vkey = stringEncoder.encode(JSON.stringify(verificationKey));
const DEPTH = 20;
const zkRLN = zerokitRLN.newRLN(DEPTH, zkey, vkey);
const zerokit = new Zerokit(zkRLN, witnessCalculator, DEFAULT_RATE_LIMIT);
return new RLNInstance(zerokit);
} catch (error) {
log.error("Failed to initialize RLN:", error);
throw error;
}
}
type StartRLNOptions = {
/**
* If not set - will extract MetaMask account and get signer from it.
*/
signer?: ethers.Signer;
/**
* If not set - will use default LINEA_CONTRACT address.
*/
address?: string;
/**
* Credentials to use for generating proofs and connecting to the contract and network.
* If provided used for validating the network chainId and connecting to registry contract.
*/
credentials?: EncryptedCredentials | DecryptedCredentials;
/**
* Rate limit for the member.
*/
rateLimit?: number;
};
type RegisterMembershipOptions =
| { signature: string }
| { identity: IdentityCredential };
type WakuRLNEncoderOptions = WakuEncoderOptions & {
credentials: EncryptedCredentials | DecryptedCredentials;
};
export class RLNInstance {
private started = false;
private starting = false;
private _contract: undefined | RLNContract;
private _signer: undefined | ethers.Signer;
private keystore = Keystore.create();
private _credentials: undefined | DecryptedCredentials;
public constructor(public zerokit: Zerokit) {}
public get contract(): undefined | RLNContract {
return this._contract;
}
public get signer(): undefined | ethers.Signer {
return this._signer;
}
public async start(options: StartRLNOptions = {}): Promise<void> {
if (this.started || this.starting) {
return;
}
this.starting = true;
try {
const { credentials, keystore } =
await RLNInstance.decryptCredentialsIfNeeded(options.credentials);
const { signer, address, rateLimit } = await this.determineStartOptions(
options,
credentials
);
if (keystore) {
this.keystore = keystore;
}
this._credentials = credentials;
this._signer = signer!;
this._contract = await RLNContract.init(this, {
address: address!,
signer: signer!,
rateLimit: rateLimit ?? this.zerokit.getRateLimit
});
this.started = true;
} finally {
this.starting = false;
}
}
private async determineStartOptions(
options: StartRLNOptions,
credentials: KeystoreEntity | undefined
): Promise<StartRLNOptions> {
let chainId = credentials?.membership.chainId;
const address =
credentials?.membership.address ||
options.address ||
LINEA_CONTRACT.address;
if (address === LINEA_CONTRACT.address) {
chainId = LINEA_CONTRACT.chainId;
}
const signer = options.signer || (await extractMetaMaskSigner());
const currentChainId = (await signer.getChainId()).toString();
if (chainId && chainId !== currentChainId) {
throw Error(
`Failed to start RLN contract, chain ID of contract is different from current one: contract-${chainId}, current network-${currentChainId}`
);
}
return {
signer,
address
};
}
private static async decryptCredentialsIfNeeded(
credentials?: EncryptedCredentials | DecryptedCredentials
): Promise<{ credentials?: DecryptedCredentials; keystore?: Keystore }> {
if (!credentials) {
return {};
}
if ("identity" in credentials) {
return { credentials };
}
const keystore = Keystore.fromString(credentials.keystore);
if (!keystore) {
return {};
}
const decryptedCredentials = await keystore.readCredential(
credentials.id,
credentials.password
);
return {
keystore,
credentials: decryptedCredentials
};
}
public async registerMembership(
options: RegisterMembershipOptions
): Promise<undefined | DecryptedCredentials> {
if (!this.contract) {
throw Error("RLN Contract is not initialized.");
}
let identity = "identity" in options && options.identity;
if ("signature" in options) {
identity = this.zerokit.generateSeededIdentityCredential(
options.signature
);
}
if (!identity) {
throw Error("Missing signature or identity to register membership.");
}
return this.contract.registerWithIdentity(identity);
}
export class RLNInstance extends RLNCredentialsManager {
/**
* Changes credentials in use by relying on provided Keystore earlier in rln.start
* @param id: string, hash of credentials to select from Keystore
* @param password: string or bytes to use to decrypt credentials from Keystore
* Create an instance of RLN
* @returns RLNInstance
*/
public async useCredentials(id: string, password: Password): Promise<void> {
this._credentials = await this.keystore?.readCredential(id, password);
public static async create(): Promise<RLNInstance> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (init as any)?.();
zerokitRLN.init_panic_hook();
const witnessCalculator = await RLNInstance.loadWitnessCalculator();
const zkey = await RLNInstance.loadZkey();
const stringEncoder = new TextEncoder();
const vkey = stringEncoder.encode(JSON.stringify(verificationKey));
const DEPTH = 20;
const zkRLN = zerokitRLN.newRLN(DEPTH, zkey, vkey);
const zerokit = new Zerokit(zkRLN, witnessCalculator, DEFAULT_RATE_LIMIT);
return new RLNInstance(zerokit);
} catch (error) {
log.error("Failed to initialize RLN:", error);
throw error;
}
}
private constructor(public zerokit: Zerokit) {
super(zerokit);
}
public async createEncoder(
@ -275,7 +67,7 @@ export class RLNInstance {
): Promise<RLNEncoder> {
const { credentials: decryptedCredentials } =
await RLNInstance.decryptCredentialsIfNeeded(options.credentials);
const credentials = decryptedCredentials || this._credentials;
const credentials = decryptedCredentials || this.credentials;
if (!credentials) {
throw Error(
@ -293,33 +85,6 @@ export class RLNInstance {
});
}
private async verifyCredentialsAgainstContract(
credentials: KeystoreEntity
): Promise<void> {
if (!this._contract) {
throw Error(
"Failed to verify chain coordinates: no contract initialized."
);
}
const registryAddress = credentials.membership.address;
const currentRegistryAddress = this._contract.address;
if (registryAddress !== currentRegistryAddress) {
throw Error(
`Failed to verify chain coordinates: credentials contract address=${registryAddress} is not equal to registryContract address=${currentRegistryAddress}`
);
}
const chainId = credentials.membership.chainId;
const network = await this._contract.provider.getNetwork();
const currentChainId = network.chainId.toString();
if (chainId !== currentChainId) {
throw Error(
`Failed to verify chain coordinates: credentials chainID=${chainId} is not equal to registryContract chainID=${currentChainId}`
);
}
}
public createDecoder(
contentTopic: ContentTopic
): RLNDecoder<IDecodedMessage> {
@ -328,4 +93,47 @@ export class RLNInstance {
decoder: createDecoder(contentTopic)
});
}
public static async 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)}`
);
}
}
public static async loadZkey(): Promise<Uint8Array> {
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)}`
);
}
}
}

View File

@ -1,235 +0,0 @@
import { hmac } from "@noble/hashes/hmac";
import { sha256 } from "@noble/hashes/sha256";
import { Logger } from "@waku/utils";
import { ethers } from "ethers";
import { LINEA_CONTRACT } from "./contract/constants.js";
import { RLNLightContract } from "./contract/rln_light_contract.js";
import { IdentityCredential } from "./identity.js";
import { Keystore } from "./keystore/index.js";
import type {
DecryptedCredentials,
EncryptedCredentials
} from "./keystore/index.js";
import { KeystoreEntity, Password } from "./keystore/types.js";
import {
buildBigIntFromUint8Array,
extractMetaMaskSigner
} from "./utils/index.js";
const log = new Logger("waku:rln");
/**
* Create an instance of RLN
* @returns RLNInstance
*/
export async function create(): Promise<RLNLightInstance> {
try {
return new RLNLightInstance();
} catch (error) {
log.error("Failed to initialize RLN:", error);
throw error;
}
}
type StartRLNOptions = {
/**
* If not set - will extract MetaMask account and get signer from it.
*/
signer?: ethers.Signer;
/**
* If not set - will use default SEPOLIA_CONTRACT address.
*/
address?: string;
/**
* Credentials to use for generating proofs and connecting to the contract and network.
* If provided used for validating the network chainId and connecting to registry contract.
*/
credentials?: EncryptedCredentials | DecryptedCredentials;
/**
* Rate limit for the member.
*/
rateLimit?: number;
};
type RegisterMembershipOptions =
| { signature: string }
| { identity: IdentityCredential };
export class RLNLightInstance {
private started = false;
private starting = false;
private _contract: undefined | RLNLightContract;
private _signer: undefined | ethers.Signer;
private keystore = Keystore.create();
private _credentials: undefined | DecryptedCredentials;
public constructor() {}
public get contract(): undefined | RLNLightContract {
return this._contract;
}
public get signer(): undefined | ethers.Signer {
return this._signer;
}
public async start(options: StartRLNOptions = {}): Promise<void> {
if (this.started || this.starting) {
return;
}
this.starting = true;
try {
const { credentials, keystore } =
await RLNLightInstance.decryptCredentialsIfNeeded(options.credentials);
const { signer, address, rateLimit } = await this.determineStartOptions(
options,
credentials
);
if (keystore) {
this.keystore = keystore;
}
this._credentials = credentials;
this._signer = signer!;
this._contract = await RLNLightContract.init({
address: address!,
signer: signer!,
rateLimit: rateLimit
});
this.started = true;
} finally {
this.starting = false;
}
}
public get credentials(): DecryptedCredentials | undefined {
return this._credentials;
}
private async determineStartOptions(
options: StartRLNOptions,
credentials: KeystoreEntity | undefined
): Promise<StartRLNOptions> {
let chainId = credentials?.membership.chainId;
const address =
credentials?.membership.address ||
options.address ||
LINEA_CONTRACT.address;
if (address === LINEA_CONTRACT.address) {
chainId = LINEA_CONTRACT.chainId;
}
const signer = options.signer || (await extractMetaMaskSigner());
const currentChainId = (await signer.getChainId()).toString();
if (chainId && chainId !== currentChainId) {
throw Error(
`Failed to start RLN contract, chain ID of contract is different from current one: contract-${chainId}, current network-${currentChainId}`
);
}
return {
signer,
address
};
}
private static async decryptCredentialsIfNeeded(
credentials?: EncryptedCredentials | DecryptedCredentials
): Promise<{ credentials?: DecryptedCredentials; keystore?: Keystore }> {
if (!credentials) {
return {};
}
if ("identity" in credentials) {
return { credentials };
}
const keystore = Keystore.fromString(credentials.keystore);
if (!keystore) {
return {};
}
const decryptedCredentials = await keystore.readCredential(
credentials.id,
credentials.password
);
return {
keystore,
credentials: decryptedCredentials
};
}
/**
* Generates an identity credential from a seed string
* This is a pure implementation that doesn't rely on Zerokit
* @param seed A string seed to generate the identity from
* @returns IdentityCredential
*/
private generateSeededIdentityCredential(seed: string): IdentityCredential {
// Convert the seed to bytes
const encoder = new TextEncoder();
const seedBytes = encoder.encode(seed);
// Generate deterministic values using HMAC-SHA256
// We use different context strings for each component to ensure they're different
const idTrapdoor = hmac(sha256, seedBytes, encoder.encode("IDTrapdoor"));
const idNullifier = hmac(sha256, seedBytes, encoder.encode("IDNullifier"));
// Generate IDSecretHash as a hash of IDTrapdoor and IDNullifier
const combinedBytes = new Uint8Array([...idTrapdoor, ...idNullifier]);
const idSecretHash = sha256(combinedBytes);
// Generate IDCommitment as a hash of IDSecretHash
const idCommitment = sha256(idSecretHash);
// Convert IDCommitment to BigInt
const idCommitmentBigInt = buildBigIntFromUint8Array(idCommitment);
return new IdentityCredential(
idTrapdoor,
idNullifier,
idSecretHash,
idCommitment,
idCommitmentBigInt
);
}
public async registerMembership(
options: RegisterMembershipOptions
): Promise<undefined | DecryptedCredentials> {
if (!this.contract) {
throw Error("RLN Contract is not initialized.");
}
let identity = "identity" in options && options.identity;
if ("signature" in options) {
identity = this.generateSeededIdentityCredential(options.signature);
}
if (!identity) {
throw Error("Missing signature or identity to register membership.");
}
return this.contract.registerWithIdentity(identity);
}
/**
* Changes credentials in use by relying on provided Keystore earlier in rln.start
* @param id: string, hash of credentials to select from Keystore
* @param password: string or bytes to use to decrypt credentials from Keystore
*/
public async useCredentials(id: string, password: Password): Promise<void> {
this._credentials = await this.keystore?.readCredential(id, password);
}
}

31
packages/rln/src/types.ts Normal file
View File

@ -0,0 +1,31 @@
import { ethers } from "ethers";
import { IdentityCredential } from "./identity.js";
import {
DecryptedCredentials,
EncryptedCredentials
} from "./keystore/types.js";
export type StartRLNOptions = {
/**
* If not set - will extract MetaMask account and get signer from it.
*/
signer?: ethers.Signer;
/**
* If not set - will use default SEPOLIA_CONTRACT address.
*/
address?: string;
/**
* Credentials to use for generating proofs and connecting to the contract and network.
* If provided used for validating the network chainId and connecting to registry contract.
*/
credentials?: EncryptedCredentials | DecryptedCredentials;
/**
* Rate limit for the member.
*/
rateLimit?: number;
};
export type RegisterMembershipOptions =
| { signature: string }
| { identity: IdentityCredential };