@waku/rln
Version:
RLN (Rate Limiting Nullifier) implementation for Waku
534 lines • 22.4 kB
JavaScript
import { Logger } from "@waku/utils";
import { ethers } from "ethers";
import { RLN_ABI } from "./abi/rln.js";
import { DEFAULT_RATE_LIMIT, PRICE_CALCULATOR_CONTRACT, RATE_LIMIT_PARAMS } from "./constants.js";
import { MembershipState } from "./types.js";
const log = new Logger("rln:contract:base");
export class RLNBaseContract {
contract;
deployBlock;
rateLimit;
minRateLimit;
maxRateLimit;
_members = new Map();
_membersFilter;
_membershipErasedFilter;
_membersExpiredFilter;
/**
* Private constructor for RLNBaseContract. Use static create() instead.
*/
constructor(options) {
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.message);
}
// Initialize members and subscriptions
this.fetchMembers()
.then(() => {
this.subscribeToMembers();
})
.catch((error) => {
log.error("Failed to initialize members", { error });
});
}
/**
* Static async factory to create and initialize RLNBaseContract
*/
static async create(options) {
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();
instance.validateRateLimit(instance.rateLimit);
return instance;
}
/**
* Gets the current rate limit for this contract instance
*/
getRateLimit() {
return this.rateLimit;
}
/**
* Gets the contract address
*/
get address() {
return this.contract.address;
}
/**
* Gets the contract provider
*/
get provider() {
return this.contract.provider;
}
/**
* Gets the minimum allowed rate limit (cached)
*/
getMinRateLimit() {
if (this.minRateLimit === undefined)
throw new Error("minRateLimit not initialized");
return this.minRateLimit;
}
/**
* Gets the maximum allowed rate limit (cached)
*/
getMaxRateLimit() {
if (this.maxRateLimit === undefined)
throw new Error("maxRateLimit not initialized");
return this.maxRateLimit;
}
/**
* Gets the maximum total rate limit across all memberships
* @returns Promise<number> The maximum total rate limit in messages per epoch
*/
async getMaxTotalRateLimit() {
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
*/
async getCurrentTotalRateLimit() {
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
*/
async getRemainingTotalRateLimit() {
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
*/
setRateLimit(newRateLimit) {
this.validateRateLimit(newRateLimit);
this.rateLimit = newRateLimit;
}
get members() {
const sortedMembers = Array.from(this._members.values()).sort((left, right) => left.index.toNumber() - right.index.toNumber());
return sortedMembers;
}
async fetchMembers(options = {}) {
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,
...removedMemberEvents,
...expiredMemberEvents
];
this.processEvents(events);
}
static async queryFilter(contract, options) {
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 = [];
const chunks = RLNBaseContract.splitToChunks(fromBlock, toBlock, fetchRange);
for (const portion of RLNBaseContract.takeN(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);
}
processEvents(events) {
const toRemoveTable = new Map();
const toInsertTable = new Map();
events.forEach((evt) => {
if (!evt.args) {
return;
}
if (evt.event === "MembershipErased" ||
evt.event === "MembershipExpired") {
let index = evt.args.index;
if (!index) {
return;
}
if (typeof index === "number" || typeof index === "string") {
index = ethers.BigNumber.from(index);
}
const toRemoveVal = toRemoveTable.get(evt.blockNumber);
if (toRemoveVal != undefined) {
toRemoveVal.push(index.toNumber());
toRemoveTable.set(evt.blockNumber, toRemoveVal);
}
else {
toRemoveTable.set(evt.blockNumber, [index.toNumber()]);
}
}
else if (evt.event === "MembershipRegistered") {
let eventsPerBlock = toInsertTable.get(evt.blockNumber);
if (eventsPerBlock == undefined) {
eventsPerBlock = [];
}
eventsPerBlock.push(evt);
toInsertTable.set(evt.blockNumber, eventsPerBlock);
}
});
}
static splitToChunks(from, to, step) {
const chunks = [];
let left = from;
while (left < to) {
const right = left + step < to ? left + step : to;
chunks.push([left, right]);
left = right;
}
return chunks;
}
static *takeN(array, size) {
let start = 0;
while (start < array.length) {
const portion = array.slice(start, start + size);
yield portion;
start += size;
}
}
static async ignoreErrors(promise, defaultValue) {
try {
return await promise;
}
catch (err) {
if (err instanceof Error) {
log.info(`Ignoring an error during query: ${err.message}`);
}
else {
log.info(`Ignoring an unknown error during query`);
}
return defaultValue;
}
}
subscribeToMembers() {
this.contract.on(this.membersFilter, (_idCommitment, _membershipRateLimit, _index, event) => {
this.processEvents([event]);
});
this.contract.on(this.membershipErasedFilter, (_idCommitment, _membershipRateLimit, _index, event) => {
this.processEvents([event]);
});
this.contract.on(this.membersExpiredFilter, (_idCommitment, _membershipRateLimit, _index, event) => {
this.processEvents([event]);
});
}
async getMembershipInfo(idCommitmentBigInt) {
try {
const membershipData = await this.contract.memberships(idCommitmentBigInt);
const currentBlock = await this.contract.provider.getBlockNumber();
const [depositAmount, activeDuration, gracePeriodStartTimestamp, gracePeriodDuration, rateLimit, index, holder, token] = membershipData;
const gracePeriodEnd = gracePeriodStartTimestamp.add(gracePeriodDuration);
let state;
if (currentBlock < gracePeriodStartTimestamp.toNumber()) {
state = MembershipState.Active;
}
else if (currentBlock < gracePeriodEnd.toNumber()) {
state = MembershipState.GracePeriod;
}
else {
state = MembershipState.Expired;
}
return {
index,
idCommitment: idCommitmentBigInt.toString(),
rateLimit: Number(rateLimit),
startBlock: gracePeriodStartTimestamp.toNumber(),
endBlock: gracePeriodEnd.toNumber(),
state,
depositAmount,
activeDuration,
gracePeriodDuration,
holder,
token
};
}
catch (error) {
log.error("Error in getMembershipInfo:", error);
return undefined;
}
}
async extendMembership(idCommitmentBigInt) {
const tx = await this.contract.extendMemberships([idCommitmentBigInt]);
await tx.wait();
return tx;
}
async eraseMembership(idCommitmentBigInt, eraseFromMembershipSet = true) {
if (!(await this.isExpired(idCommitmentBigInt)) ||
!(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;
}
async registerMembership(idCommitmentBigInt, rateLimit = DEFAULT_RATE_LIMIT) {
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(idCommitmentBigInt, rateLimit, []);
}
async withdraw(token, walletAddress) {
try {
const tx = await this.contract.withdraw(token, walletAddress);
await tx.wait();
}
catch (error) {
log.error(`Error in withdraw: ${error.message}`);
}
}
async registerWithIdentity(identity) {
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);
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 = 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 = {
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 = Number(decodedData.index);
return {
identity,
membership: {
address,
treeIndex: 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
});
}
}
}
async registerWithPermitAndErase(identity, permit, idCommitmentsToErase) {
try {
log.info(`Registering identity with permit and rate limit: ${this.rateLimit} messages/epoch`);
const txRegisterResponse = 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 = {
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 = Number(decodedData.index);
return {
identity,
membership: {
address,
treeIndex: membershipId,
chainId: network.chainId.toString(),
rateLimit: decodedData.membershipRateLimit.toNumber()
}
};
}
catch (error) {
log.error(`Error in registerWithPermitAndErase: ${error.message}`);
return undefined;
}
}
/**
* Validates that the rate limit is within the allowed range (sync)
* @throws Error if the rate limit is outside the allowed range
*/
validateRateLimit(rateLimit) {
if (this.minRateLimit === undefined || this.maxRateLimit === undefined) {
throw new Error("Rate limits not initialized");
}
if (rateLimit < this.minRateLimit || rateLimit > this.maxRateLimit) {
throw new Error(`Rate limit must be between ${this.minRateLimit} and ${this.maxRateLimit} messages per epoch`);
}
}
get membersFilter() {
if (!this._membersFilter) {
throw Error("Members filter was not initialized.");
}
return this._membersFilter;
}
get membershipErasedFilter() {
if (!this._membershipErasedFilter) {
throw Error("MembershipErased filter was not initialized.");
}
return this._membershipErasedFilter;
}
get membersExpiredFilter() {
if (!this._membersExpiredFilter) {
throw Error("MembersExpired filter was not initialized.");
}
return this._membersExpiredFilter;
}
async getMemberIndex(idCommitmentBigInt) {
try {
const events = await this.contract.queryFilter(this.contract.filters.MembershipRegistered(idCommitmentBigInt));
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;
}
}
async getMembershipStatus(idCommitment) {
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
*/
async isExpired(idCommitmentBigInt) {
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
*/
async isInGracePeriod(idCommitmentBigInt) {
try {
return await this.contract.isInGracePeriod(idCommitmentBigInt);
}
catch (error) {
log.error("Error in isInGracePeriod:", error);
return false;
}
}
/**
* Calculates the price for a given rate limit using the PriceCalculator contract
* @param rateLimit The rate limit to calculate the price for
* @param contractFactory Optional factory for creating the contract (for testing)
*/
async getPriceForRateLimit(rateLimit, contractFactory) {
const provider = this.contract.provider;
const ContractCtor = contractFactory || ethers.Contract;
const priceCalculator = new ContractCtor(PRICE_CALCULATOR_CONTRACT.address, PRICE_CALCULATOR_CONTRACT.abi, provider);
const [token, price] = await priceCalculator.calculate(rateLimit);
// Defensive: if token or price is null/undefined, return nulls
if (!token || !price) {
return { token: null, price: null };
}
return { token, price };
}
}
//# sourceMappingURL=rln_base_contract.js.map