feat: migrate rln from ethers to viem

This commit is contained in:
Arseniy Klempner 2025-10-24 15:41:14 -07:00
parent 904d648fe5
commit 89defd388a
No known key found for this signature in database
GPG Key ID: 51653F18863BD24B
16 changed files with 664 additions and 1331 deletions

View File

@ -132,7 +132,9 @@
"upgrader",
"vacp",
"varint",
"viem",
"vkey",
"wagmi",
"waku",
"wakuconnect",
"wakunode",

1007
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,43 @@
#!/bin/bash
set -e
# Script to generate contract ABIs from waku-rlnv2-contract
# Usage: ./generate_contract_abi.sh
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONTRACT_DIR="$SCRIPT_DIR/waku-rlnv2-contract"
REPO_URL="git@github.com:waku-org/waku-rlnv2-contract.git"
echo "📦 Setting up waku-rlnv2-contract..."
# Remove existing directory if it exists
if [ -d "$CONTRACT_DIR" ]; then
echo "🗑️ Removing existing waku-rlnv2-contract directory..."
rm -rf "$CONTRACT_DIR"
fi
# Clone the repository
echo "📥 Cloning waku-rlnv2-contract..."
git clone "$REPO_URL" "$CONTRACT_DIR"
# Navigate to contract directory
cd "$CONTRACT_DIR"
# Install dependencies
echo "📦 Installing dependencies..."
npm install
# Build contracts with Foundry
echo "🔨 Building contracts with Foundry..."
forge build
# Navigate back to rln package
cd "$SCRIPT_DIR"
# Generate ABIs with wagmi
echo "⚙️ Generating ABIs with wagmi..."
npx wagmi generate
echo "✅ Contract ABIs generated successfully!"
echo "📄 Output: src/contract/wagmi/generated.ts"

View File

@ -56,13 +56,13 @@
"@rollup/plugin-node-resolve": "^15.2.3",
"@types/chai": "^5.0.1",
"@types/chai-spies": "^1.0.6",
"@waku/interfaces": "0.0.34",
"@types/deep-equal-in-any-order": "^1.0.4",
"@types/lodash": "^4.17.15",
"@types/sinon": "^17.0.3",
"@waku/build-utils": "^1.0.0",
"@waku/message-encryption": "^0.0.37",
"@wagmi/cli": "^2.7.0",
"@waku/build-utils": "^1.0.0",
"@waku/interfaces": "0.0.34",
"@waku/message-encryption": "^0.0.37",
"deep-equal-in-any-order": "^2.0.6",
"fast-check": "^3.23.2",
"rollup-plugin-copy": "^3.5.0"
@ -79,18 +79,19 @@
],
"dependencies": {
"@chainsafe/bls-keystore": "3.0.0",
"@noble/hashes": "^1.2.0",
"@wagmi/core": "^2.22.1",
"@waku/core": "^0.0.40",
"@waku/utils": "^0.0.27",
"@noble/hashes": "^1.2.0",
"@waku/zerokit-rln-wasm": "^0.2.1",
"ethereum-cryptography": "^3.1.0",
"ethers": "^5.7.2",
"lodash": "^4.17.21",
"uuid": "^11.0.5",
"chai": "^5.1.2",
"chai-as-promised": "^8.0.1",
"chai-spies": "^1.1.0",
"chai-subset": "^1.6.0",
"sinon": "^19.0.2"
"ethereum-cryptography": "^3.1.0",
"lodash": "^4.17.21",
"sinon": "^19.0.2",
"uuid": "^11.0.5",
"viem": "^2.38.4"
}
}

View File

@ -1,30 +0,0 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SUBMODULE_DIR="$SCRIPT_DIR/waku-rlnv2-contract"
echo "Setting up waku-rlnv2-contract submodule..."
# Initialize submodule if needed
if [ ! -d "$SUBMODULE_DIR/.git" ]; then
echo "Initializing submodule..."
cd "$SCRIPT_DIR/../.."
git submodule update --init --recursive packages/rln/waku-rlnv2-contract
fi
# Install dependencies
echo "Installing submodule dependencies..."
cd "$SUBMODULE_DIR"
npm install
# Build contracts with Foundry
echo "Building contracts with Foundry..."
forge build
# Generate ABIs
echo "Generating contract ABIs..."
cd "$SCRIPT_DIR"
npx wagmi generate
echo "✅ Contract ABI setup complete!"

View File

@ -1,28 +1,40 @@
import { expect, use } from "chai";
import chaiAsPromised from "chai-as-promised";
import { ethers } from "ethers";
import sinon from "sinon";
import { PublicClient } from "viem";
import { RLNBaseContract } from "./rln_base_contract.js";
use(chaiAsPromised);
function createMockRLNBaseContract(provider: any): RLNBaseContract {
function createMockRLNBaseContract(
mockContract: any,
mockPublicClient: PublicClient
): RLNBaseContract {
const dummy = Object.create(RLNBaseContract.prototype);
dummy.contract = { provider };
dummy.contract = mockContract;
dummy.publicClient = mockPublicClient;
return dummy as RLNBaseContract;
}
describe("RLNBaseContract.getPriceForRateLimit (unit)", function () {
let provider: any;
let calculateStub: sinon.SinonStub;
let mockContractFactory: any;
let mockContract: any;
let mockPublicClient: any;
let priceCalculatorReadStub: sinon.SinonStub;
let readContractStub: sinon.SinonStub;
beforeEach(() => {
provider = {};
calculateStub = sinon.stub();
mockContractFactory = function () {
return { calculate: calculateStub };
priceCalculatorReadStub = sinon.stub();
readContractStub = sinon.stub();
mockContract = {
read: {
priceCalculator: priceCalculatorReadStub
}
};
mockPublicClient = {
readContract: readContractStub
};
});
@ -32,35 +44,53 @@ describe("RLNBaseContract.getPriceForRateLimit (unit)", function () {
it("returns token and price for valid calculate", async () => {
const fakeToken = "0x1234567890abcdef1234567890abcdef12345678";
const fakePrice = ethers.BigNumber.from(42);
calculateStub.resolves([fakeToken, fakePrice]);
const fakePrice = 42n;
const priceCalculatorAddress = "0xabcdef1234567890abcdef1234567890abcdef12";
priceCalculatorReadStub.resolves(priceCalculatorAddress);
readContractStub.resolves([fakeToken, fakePrice]);
const rlnBase = createMockRLNBaseContract(mockContract, mockPublicClient);
const result = await rlnBase.getPriceForRateLimit(20);
const rlnBase = createMockRLNBaseContract(provider);
const result = await rlnBase.getPriceForRateLimit(20, mockContractFactory);
expect(result.token).to.equal(fakeToken);
expect(result.price).to.not.be.null;
if (result.price) {
expect(result.price.eq(fakePrice)).to.be.true;
}
expect(calculateStub.calledOnceWith(20)).to.be.true;
expect(result.price).to.equal(fakePrice);
expect(priceCalculatorReadStub.calledOnce).to.be.true;
expect(readContractStub.calledOnce).to.be.true;
const readContractCall = readContractStub.getCall(0);
expect(readContractCall.args[0]).to.deep.include({
address: priceCalculatorAddress,
functionName: "calculate",
args: [20]
});
});
it("throws if calculate throws", async () => {
calculateStub.rejects(new Error("fail"));
const priceCalculatorAddress = "0xabcdef1234567890abcdef1234567890abcdef12";
const rlnBase = createMockRLNBaseContract(provider);
await expect(
rlnBase.getPriceForRateLimit(20, mockContractFactory)
).to.be.rejectedWith("fail");
expect(calculateStub.calledOnceWith(20)).to.be.true;
priceCalculatorReadStub.resolves(priceCalculatorAddress);
readContractStub.rejects(new Error("fail"));
const rlnBase = createMockRLNBaseContract(mockContract, mockPublicClient);
await expect(rlnBase.getPriceForRateLimit(20)).to.be.rejectedWith("fail");
expect(priceCalculatorReadStub.calledOnce).to.be.true;
expect(readContractStub.calledOnce).to.be.true;
});
it("throws if calculate returns malformed data", async () => {
calculateStub.resolves([null, null]);
it("returns null values if calculate returns malformed data", async () => {
const priceCalculatorAddress = "0xabcdef1234567890abcdef1234567890abcdef12";
priceCalculatorReadStub.resolves(priceCalculatorAddress);
readContractStub.resolves([null, null]);
const rlnBase = createMockRLNBaseContract(mockContract, mockPublicClient);
const result = await rlnBase.getPriceForRateLimit(20);
const rlnBase = createMockRLNBaseContract(provider);
const result = await rlnBase.getPriceForRateLimit(20, mockContractFactory);
expect(result.token).to.be.null;
expect(result.price).to.be.null;
expect(priceCalculatorReadStub.calledOnce).to.be.true;
expect(readContractStub.calledOnce).to.be.true;
});
});

View File

@ -1,70 +1,74 @@
import { Logger } from "@waku/utils";
import { ethers } from "ethers";
import {
type Address,
decodeEventLog,
getContract,
GetContractEventsReturnType,
GetContractReturnType,
type Hash,
PublicClient,
WalletClient
} from "viem";
import { IdentityCredential } from "../identity.js";
import { DecryptedCredentials } from "../keystore/types.js";
import {
DEFAULT_RATE_LIMIT,
PRICE_CALCULATOR_CONTRACT,
RATE_LIMIT_PARAMS,
RLN_CONTRACT
} from "./constants.js";
import {
CustomQueryOptions,
FetchMembersOptions,
Member,
MembershipInfo,
MembershipRegisteredEvent,
MembershipState,
RLNContractInitOptions
RLNContractOptions
} from "./types.js";
import { iPriceCalculatorAbi, wakuRlnV2Abi } from "./wagmi/generated.js";
const log = new Logger("rln:contract:base");
type MembershipEvents = GetContractEventsReturnType<
typeof wakuRlnV2Abi,
"MembershipRegistered" | "MembershipErased" | "MembershipExpired"
>;
export class RLNBaseContract {
public contract: ethers.Contract;
public contract: GetContractReturnType<
typeof wakuRlnV2Abi,
PublicClient | WalletClient
>;
public publicClient: PublicClient;
public walletClient: WalletClient;
private deployBlock: undefined | number;
private rateLimit: number;
private minRateLimit?: number;
private maxRateLimit?: number;
protected _members: Map<number, Member> = new Map();
private _membersFilter: ethers.EventFilter;
private _membershipErasedFilter: ethers.EventFilter;
private _membersExpiredFilter: ethers.EventFilter;
/**
* Private constructor for RLNBaseContract. Use static create() instead.
*/
protected constructor(options: RLNContractInitOptions) {
protected constructor(options: RLNContractOptions) {
const {
address,
signer,
rateLimit = DEFAULT_RATE_LIMIT,
contract
publicClient,
walletClient,
rateLimit = DEFAULT_RATE_LIMIT
} = options;
log.info("Initializing RLNBaseContract", { address, rateLimit });
this.contract =
contract || new ethers.Contract(address, RLN_CONTRACT.abi, signer);
this.publicClient = publicClient;
this.walletClient = walletClient;
this.contract = getContract({
address,
abi: wakuRlnV2Abi,
client: { wallet: walletClient, public: publicClient }
});
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
this.fetchMembers()
.then(() => {
@ -79,15 +83,19 @@ export class RLNBaseContract {
* Static async factory to create and initialize RLNBaseContract
*/
public static async create(
options: RLNContractInitOptions
options: RLNContractOptions
): Promise<RLNBaseContract> {
const instance = new RLNBaseContract(options);
instance.deployBlock = await instance.contract.read.deployedBlockNumber();
const [min, max] = await Promise.all([
instance.contract.minMembershipRateLimit(),
instance.contract.maxMembershipRateLimit()
instance.contract.read.minMembershipRateLimit(),
instance.contract.read.maxMembershipRateLimit()
]);
instance.minRateLimit = ethers.BigNumber.from(min).toNumber();
instance.maxRateLimit = ethers.BigNumber.from(max).toNumber();
instance.minRateLimit = min;
instance.maxRateLimit = max;
instance.validateRateLimit(instance.rateLimit);
return instance;
@ -107,13 +115,6 @@ export class RLNBaseContract {
return this.contract.address;
}
/**
* Gets the contract provider
*/
public get provider(): ethers.providers.Provider {
return this.contract.provider;
}
/**
* Gets the minimum allowed rate limit (cached)
*/
@ -137,8 +138,7 @@ export class RLNBaseContract {
* @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();
return await this.contract.read.maxTotalRateLimit();
}
/**
@ -146,8 +146,7 @@ export class RLNBaseContract {
* @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();
return Number(await this.contract.read.currentTotalRateLimit());
}
/**
@ -155,11 +154,10 @@ export class RLNBaseContract {
* @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);
return (
(await this.contract.read.maxTotalRateLimit()) -
Number(await this.contract.read.currentTotalRateLimit())
);
}
/**
@ -171,36 +169,49 @@ export class RLNBaseContract {
this.rateLimit = newRateLimit;
}
/**
* Gets the Merkle tree root for RLN proof verification
* @returns Promise<bigint> The Merkle tree root
*
*/
public async getMerkleRoot(): Promise<bigint> {
return this.contract.read.root();
}
/**
* Gets the Merkle proof for a member at a given index
* @param index The index of the member in the membership set
* @returns Promise<bigint[]> Array of 20 Merkle proof elements
*
*/
public async getMerkleProof(index: number): Promise<readonly bigint[]> {
return await this.contract.read.getMerkleProof([index]);
}
public get members(): Member[] {
const sortedMembers = Array.from(this._members.values()).sort(
(left, right) => left.index.toNumber() - right.index.toNumber()
(left, right) => Number(left.index) - Number(right.index)
);
return sortedMembers;
}
public async fetchMembers(options: FetchMembersOptions = {}): Promise<void> {
const registeredMemberEvents = await RLNBaseContract.queryFilter(
this.contract,
const fromBlock = options.fromBlock
? BigInt(options.fromBlock!)
: BigInt(this.deployBlock!);
const registeredMemberEvents =
await this.contract.getEvents.MembershipRegistered({
fromBlock,
toBlock: fromBlock + BigInt(options.fetchRange!)
});
const removedMemberEvents = await this.contract.getEvents.MembershipErased({
fromBlock,
toBlock: fromBlock + BigInt(options.fetchRange!)
});
const expiredMemberEvents = await this.contract.getEvents.MembershipExpired(
{
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
fromBlock,
toBlock: fromBlock + BigInt(options.fetchRange!)
}
);
@ -212,96 +223,40 @@ export class RLNBaseContract {
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 {
public processEvents(events: MembershipEvents): void {
const toRemoveTable = new Map<number, number[]>();
const toInsertTable = new Map<number, ethers.Event[]>();
const toInsertTable = new Map<number, MembershipEvents>();
events.forEach((evt) => {
if (!evt.args) {
return;
}
const blockNumber = Number(evt.blockNumber);
if (
evt.event === "MembershipErased" ||
evt.event === "MembershipExpired"
evt.eventName === "MembershipErased" ||
evt.eventName === "MembershipExpired"
) {
let index = evt.args.index;
const 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);
const toRemoveVal = toRemoveTable.get(blockNumber);
if (toRemoveVal != undefined) {
toRemoveVal.push(index.toNumber());
toRemoveTable.set(evt.blockNumber, toRemoveVal);
toRemoveVal.push(index);
toRemoveTable.set(blockNumber, toRemoveVal);
} else {
toRemoveTable.set(evt.blockNumber, [index.toNumber()]);
toRemoveTable.set(blockNumber, [index]);
}
} else if (evt.event === "MembershipRegistered") {
let eventsPerBlock = toInsertTable.get(evt.blockNumber);
} else if (evt.eventName === "MembershipRegistered") {
let eventsPerBlock = toInsertTable.get(blockNumber);
if (eventsPerBlock == undefined) {
eventsPerBlock = [];
}
eventsPerBlock.push(evt);
toInsertTable.set(evt.blockNumber, eventsPerBlock);
toInsertTable.set(blockNumber, eventsPerBlock);
}
});
}
@ -354,50 +309,33 @@ export class RLNBaseContract {
}
public subscribeToMembers(): void {
this.contract.on(
this.membersFilter,
(
_idCommitment: bigint,
_membershipRateLimit: ethers.BigNumber,
_index: ethers.BigNumber,
event: ethers.Event
) => {
this.processEvents([event]);
this.contract.watchEvent.MembershipRegistered({
onLogs: (logs) => {
this.processEvents(logs);
}
);
this.contract.on(
this.membershipErasedFilter,
(
_idCommitment: bigint,
_membershipRateLimit: ethers.BigNumber,
_index: ethers.BigNumber,
event: ethers.Event
) => {
this.processEvents([event]);
});
this.contract.watchEvent.MembershipExpired({
onLogs: (logs) => {
this.processEvents(logs);
}
);
this.contract.on(
this.membersExpiredFilter,
(
_idCommitment: bigint,
_membershipRateLimit: ethers.BigNumber,
_index: ethers.BigNumber,
event: ethers.Event
) => {
this.processEvents([event]);
});
this.contract.watchEvent.MembershipErased({
onLogs: (logs) => {
this.processEvents(logs);
}
);
});
}
public async getMembershipInfo(
idCommitmentBigInt: bigint
): Promise<MembershipInfo | undefined> {
try {
const membershipData =
await this.contract.memberships(idCommitmentBigInt);
const currentBlock = await this.contract.provider.getBlockNumber();
const membershipData = await this.contract.read.memberships([
idCommitmentBigInt
]);
const currentBlock = await this.publicClient.getBlockNumber();
const [
depositAmount,
activeDuration,
@ -409,12 +347,13 @@ export class RLNBaseContract {
token
] = membershipData;
const gracePeriodEnd = gracePeriodStartTimestamp.add(gracePeriodDuration);
const gracePeriodEnd =
Number(gracePeriodStartTimestamp) + Number(gracePeriodDuration);
let state: MembershipState;
if (currentBlock < gracePeriodStartTimestamp.toNumber()) {
if (currentBlock < Number(gracePeriodStartTimestamp)) {
state = MembershipState.Active;
} else if (currentBlock < gracePeriodEnd.toNumber()) {
} else if (currentBlock < gracePeriodEnd) {
state = MembershipState.GracePeriod;
} else {
state = MembershipState.Expired;
@ -423,9 +362,9 @@ export class RLNBaseContract {
return {
index,
idCommitment: idCommitmentBigInt.toString(),
rateLimit: Number(rateLimit),
startBlock: gracePeriodStartTimestamp.toNumber(),
endBlock: gracePeriodEnd.toNumber(),
rateLimit: rateLimit,
startBlock: Number(gracePeriodStartTimestamp),
endBlock: gracePeriodEnd,
state,
depositAmount,
activeDuration,
@ -439,43 +378,75 @@ export class RLNBaseContract {
}
}
public async extendMembership(
idCommitmentBigInt: bigint
): Promise<ethers.ContractTransaction> {
const tx = await this.contract.extendMemberships([idCommitmentBigInt]);
await tx.wait();
return tx;
public async extendMembership(idCommitmentBigInt: bigint): Promise<Hash> {
if (!this.walletClient.account) {
throw new Error(
"Failed to extendMembership: no account set in wallet client"
);
}
try {
await this.contract.simulate.extendMemberships([[idCommitmentBigInt]], {
chain: this.walletClient.chain,
account: this.walletClient.account!.address
});
} catch (err) {
throw new Error("Simulating extending membership failed: " + err);
}
const hash = await this.contract.write.extendMemberships(
[[idCommitmentBigInt]],
{
account: this.walletClient.account!,
chain: this.walletClient.chain
}
);
await this.publicClient.waitForTransactionReceipt({ hash });
return hash;
}
public async eraseMembership(
idCommitmentBigInt: bigint,
eraseFromMembershipSet: boolean = true
): Promise<ethers.ContractTransaction> {
): Promise<Hash> {
if (
!(await this.isExpired(idCommitmentBigInt)) ||
!(await this.isInGracePeriod(idCommitmentBigInt))
) {
throw new Error("Membership is not expired or in grace period");
}
if (!this.walletClient.account) {
throw new Error(
"Failed to eraseMembership: no account set in wallet client"
);
}
const estimatedGas = await this.contract.estimateGas[
"eraseMemberships(uint256[],bool)"
]([idCommitmentBigInt], eraseFromMembershipSet);
const gasLimit = estimatedGas.add(10000);
try {
await this.contract.simulate.eraseMemberships(
[[idCommitmentBigInt], eraseFromMembershipSet],
{
chain: this.walletClient.chain,
account: this.walletClient.account!.address
}
);
} catch (err) {
throw new Error("Error simulating eraseMemberships: " + err);
}
const tx = await this.contract["eraseMemberships(uint256[],bool)"](
[idCommitmentBigInt],
eraseFromMembershipSet,
{ gasLimit }
const hash = await this.contract.write.eraseMemberships(
[[idCommitmentBigInt], eraseFromMembershipSet],
{
chain: this.walletClient.chain,
account: this.walletClient.account!
}
);
await tx.wait();
return tx;
await this.publicClient.waitForTransactionReceipt({ hash });
return hash;
}
public async registerMembership(
idCommitmentBigInt: bigint,
rateLimit: number = DEFAULT_RATE_LIMIT
): Promise<ethers.ContractTransaction> {
): Promise<Hash> {
if (
rateLimit < RATE_LIMIT_PARAMS.MIN_RATE ||
rateLimit > RATE_LIMIT_PARAMS.MAX_RATE
@ -484,16 +455,60 @@ export class RLNBaseContract {
`Rate limit must be between ${RATE_LIMIT_PARAMS.MIN_RATE} and ${RATE_LIMIT_PARAMS.MAX_RATE}`
);
}
return this.contract.register(idCommitmentBigInt, rateLimit, []);
if (!this.walletClient.account) {
throw new Error(
"Failed to registerMembership: no account set in wallet client"
);
}
try {
await this.contract.simulate.register(
[idCommitmentBigInt, rateLimit, []],
{
chain: this.walletClient.chain,
account: this.walletClient.account!.address
}
);
} catch (err) {
throw new Error("Failed to simulate register membership: " + err);
}
const hash = await this.contract.write.register(
[idCommitmentBigInt, rateLimit, []],
{
chain: this.walletClient.chain,
account: this.walletClient.account!
}
);
await this.publicClient.waitForTransactionReceipt({ hash });
return hash;
}
public async withdraw(token: string, walletAddress: string): Promise<void> {
try {
const tx = await this.contract.withdraw(token, walletAddress);
await tx.wait();
} catch (error) {
log.error(`Error in withdraw: ${(error as Error).message}`);
/**
* Withdraw deposited tokens after membership is erased
* @param token - Token address to withdraw
* NOTE: Funds are sent to msg.sender (the walletClient's address)
*/
public async withdraw(token: string): Promise<Hash> {
if (!this.walletClient.account) {
throw new Error("Failed to withdraw: no account set in wallet client");
}
try {
await this.contract.simulate.withdraw([token as Address], {
chain: this.walletClient.chain,
account: this.walletClient.account!.address
});
} catch (err) {
throw new Error("Error simulating withdraw: " + err);
}
const hash = await this.contract.write.withdraw([token as Address], {
chain: this.walletClient.chain,
account: this.walletClient.account!
});
await this.publicClient.waitForTransactionReceipt({ hash });
return hash;
}
public async registerWithIdentity(
identity: IdentityCredential
@ -521,62 +536,77 @@ export class RLNBaseContract {
);
}
const estimatedGas = await this.contract.estimateGas.register(
identity.IDCommitmentBigInt,
this.rateLimit,
[]
await this.contract.simulate.register(
[identity.IDCommitmentBigInt, this.rateLimit, []],
{
chain: this.walletClient.chain,
account: this.walletClient.account!.address
}
);
const gasLimit = estimatedGas.add(10000);
const txRegisterResponse: ethers.ContractTransaction =
await this.contract.register(
identity.IDCommitmentBigInt,
this.rateLimit,
[],
{
gasLimit
}
);
const hash: Hash = await this.contract.write.register(
[identity.IDCommitmentBigInt, this.rateLimit, []],
{
chain: this.walletClient.chain,
account: this.walletClient.account!
}
);
const txRegisterReceipt = await txRegisterResponse.wait();
const txRegisterReceipt =
await this.publicClient.waitForTransactionReceipt({
hash
});
if (txRegisterReceipt.status === 0) {
if (txRegisterReceipt.status === "reverted") {
throw new Error("Transaction failed on-chain");
}
const memberRegistered = txRegisterReceipt.events?.find(
(event: ethers.Event) => event.event === "MembershipRegistered"
);
// Parse MembershipRegistered event from logs
const memberRegisteredLog = txRegisterReceipt.logs.find((log) => {
try {
const decoded = decodeEventLog({
abi: wakuRlnV2Abi,
data: log.data,
topics: log.topics
});
return decoded.eventName === "MembershipRegistered";
} catch {
return false;
}
});
if (!memberRegistered || !memberRegistered.args) {
if (!memberRegisteredLog) {
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
// Decode the event
const decoded = decodeEventLog({
abi: wakuRlnV2Abi,
data: memberRegisteredLog.data,
topics: memberRegisteredLog.topics
});
const decodedArgs = decoded.args as {
idCommitment: bigint;
membershipRateLimit: number;
index: number;
};
log.info(
`Successfully registered membership with index ${decodedData.index} ` +
`and rate limit ${decodedData.membershipRateLimit}`
`Successfully registered membership with index ${decodedArgs.index} ` +
`and rate limit ${decodedArgs.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()
address: this.contract.address,
treeIndex: decodedArgs.index,
chainId: String(RLN_CONTRACT.chainId),
rateLimit: decodedArgs.membershipRateLimit
}
};
} catch (error) {
@ -609,78 +639,6 @@ export class RLNBaseContract {
}
}
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: ethers.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 = 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 as 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
@ -696,50 +654,16 @@ export class RLNBaseContract {
}
}
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(
idCommitmentBigInt: bigint
): Promise<ethers.BigNumber | undefined> {
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;
}
private async getMemberIndex(idCommitmentBigInt: bigint): Promise<number> {
return (await this.contract.read.memberships([idCommitmentBigInt]))[5];
}
public async getMembershipStatus(
idCommitment: bigint
): Promise<"expired" | "grace" | "active"> {
const [isExpired, isInGrace] = await Promise.all([
this.contract.isExpired(idCommitment),
this.contract.isInGracePeriod(idCommitment)
this.contract.read.isExpired([idCommitment]),
this.contract.read.isInGracePeriod([idCommitment])
]);
if (isExpired) return "expired";
@ -754,7 +678,7 @@ export class RLNBaseContract {
*/
public async isExpired(idCommitmentBigInt: bigint): Promise<boolean> {
try {
return await this.contract.isExpired(idCommitmentBigInt);
return await this.contract.read.isExpired([idCommitmentBigInt]);
} catch (error) {
log.error("Error in isExpired:", error);
return false;
@ -768,7 +692,7 @@ export class RLNBaseContract {
*/
public async isInGracePeriod(idCommitmentBigInt: bigint): Promise<boolean> {
try {
return await this.contract.isInGracePeriod(idCommitmentBigInt);
return await this.contract.read.isInGracePeriod([idCommitmentBigInt]);
} catch (error) {
log.error("Error in isInGracePeriod:", error);
return false;
@ -780,21 +704,18 @@ export class RLNBaseContract {
* @param rateLimit The rate limit to calculate the price for
* @param contractFactory Optional factory for creating the contract (for testing)
*/
public async getPriceForRateLimit(
rateLimit: number,
contractFactory?: typeof import("ethers").Contract
): Promise<{
public async getPriceForRateLimit(rateLimit: number): Promise<{
token: string | null;
price: import("ethers").BigNumber | null;
price: bigint | null;
}> {
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);
const address = await this.contract.read.priceCalculator();
const [token, price] = await this.publicClient.readContract({
address,
abi: iPriceCalculatorAbi,
functionName: "calculate",
args: [rateLimit]
});
// Defensive: if token or price is null/undefined, return nulls
if (!token || !price) {
return { token: null, price: null };

View File

@ -1,28 +1,21 @@
import { ethers } from "ethers";
export interface CustomQueryOptions extends FetchMembersOptions {
membersFilter: ethers.EventFilter;
}
import { Address, PublicClient, WalletClient } from "viem";
export type Member = {
idCommitment: string;
index: ethers.BigNumber;
index: bigint;
};
export interface RLNContractOptions {
signer: ethers.Signer;
address: string;
publicClient: PublicClient;
walletClient: WalletClient;
address: Address;
rateLimit?: number;
}
export interface RLNContractInitOptions extends RLNContractOptions {
contract?: ethers.Contract;
}
export interface MembershipRegisteredEvent {
idCommitment: string;
membershipRateLimit: ethers.BigNumber;
index: ethers.BigNumber;
membershipRateLimit: bigint;
index: bigint;
}
export type FetchMembersOptions = {
@ -32,13 +25,13 @@ export type FetchMembersOptions = {
};
export interface MembershipInfo {
index: ethers.BigNumber;
index: number;
idCommitment: string;
rateLimit: number;
startBlock: number;
endBlock: number;
state: MembershipState;
depositAmount: ethers.BigNumber;
depositAmount: bigint;
activeDuration: number;
gracePeriodDuration: number;
holder: string;

View File

@ -1,5 +1,5 @@
import { Logger } from "@waku/utils";
import { ethers } from "ethers";
import { PublicClient, WalletClient } from "viem";
import { RLN_CONTRACT } from "./contract/constants.js";
import { RLNBaseContract } from "./contract/rln_base_contract.js";
@ -10,7 +10,7 @@ import type {
} from "./keystore/index.js";
import { KeystoreEntity, Password } from "./keystore/types.js";
import { RegisterMembershipOptions, StartRLNOptions } from "./types.js";
import { extractMetaMaskSigner } from "./utils/index.js";
import { createViemClientsFromWindow } from "./utils/index.js";
import { Zerokit } from "./zerokit.js";
const log = new Logger("rln:credentials");
@ -24,7 +24,8 @@ export class RLNCredentialsManager {
protected starting = false;
public contract: undefined | RLNBaseContract;
public signer: undefined | ethers.Signer;
public walletClient: undefined | WalletClient;
public publicClient: undefined | PublicClient;
protected keystore = Keystore.create();
public credentials: undefined | DecryptedCredentials;
@ -36,10 +37,6 @@ export class RLNCredentialsManager {
this.zerokit = zerokit;
}
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");
@ -59,10 +56,8 @@ export class RLNCredentialsManager {
log.info("Credentials successfully decrypted");
}
const { signer, address, rateLimit } = await this.determineStartOptions(
options,
credentials
);
const { walletClient, publicClient, address, rateLimit } =
await this.determineStartOptions(options, credentials);
log.info(`Using contract address: ${address}`);
@ -72,10 +67,12 @@ export class RLNCredentialsManager {
}
this.credentials = credentials;
this.signer = signer!;
this.walletClient = walletClient!;
this.publicClient = publicClient!;
this.contract = await RLNBaseContract.create({
address: address!,
signer: signer!,
address: address! as `0x${string}`,
publicClient: publicClient!,
walletClient: walletClient!,
rateLimit: rateLimit ?? this.zerokit.rateLimit
});
@ -134,7 +131,9 @@ export class RLNCredentialsManager {
protected async determineStartOptions(
options: StartRLNOptions,
credentials: KeystoreEntity | undefined
): Promise<StartRLNOptions> {
): Promise<
StartRLNOptions & { walletClient: WalletClient; publicClient: PublicClient }
> {
let chainId = credentials?.membership.chainId;
const address =
credentials?.membership.address ||
@ -146,8 +145,18 @@ export class RLNCredentialsManager {
log.info(`Using Linea contract with chainId: ${chainId}`);
}
const signer = options.signer || (await extractMetaMaskSigner());
const currentChainId = await signer.getChainId();
const walletClient = options.walletClient;
const publicClient = options.publicClient;
let clients: { walletClient: WalletClient; publicClient: PublicClient };
if (!walletClient || !publicClient) {
clients = await createViemClientsFromWindow();
} else {
clients = { walletClient, publicClient };
}
const currentChainId = await clients.publicClient.getChainId();
log.info(`Current chain ID: ${currentChainId}`);
if (chainId && chainId !== currentChainId.toString()) {
@ -160,7 +169,8 @@ export class RLNCredentialsManager {
}
return {
signer,
walletClient: clients.walletClient,
publicClient: clients.publicClient,
address
};
}
@ -206,9 +216,9 @@ export class RLNCredentialsManager {
protected async verifyCredentialsAgainstContract(
credentials: KeystoreEntity
): Promise<void> {
if (!this.contract) {
if (!this.contract || !this.publicClient) {
throw Error(
"Failed to verify chain coordinates: no contract initialized."
"Failed to verify chain coordinates: no contract or publicClient initialized."
);
}
@ -221,8 +231,7 @@ export class RLNCredentialsManager {
}
const chainId = credentials.membership.chainId;
const network = await this.contract.provider.getNetwork();
const currentChainId = network.chainId;
const currentChainId = await this.publicClient.getChainId();
if (chainId !== currentChainId.toString()) {
throw Error(
`Failed to verify chain coordinates: credentials chainID=${chainId} is not equal to registryContract chainID=${currentChainId}`

View File

@ -4,7 +4,7 @@ import { createRLN } from "./create.js";
import { IdentityCredential } from "./identity.js";
import { Keystore } from "./keystore/index.js";
import { RLNInstance } from "./rln.js";
import { extractMetaMaskSigner } from "./utils/index.js";
import { createViemClientsFromWindow } from "./utils/index.js";
export {
RLNBaseContract,
@ -13,7 +13,7 @@ export {
RLNInstance,
IdentityCredential,
RLN_CONTRACT,
extractMetaMaskSigner
createViemClientsFromWindow as extractMetaMaskSigner
};
// Export wagmi-generated ABIs

View File

@ -1,4 +1,4 @@
import { ethers } from "ethers";
import { PublicClient, WalletClient } from "viem";
import { IdentityCredential } from "./identity.js";
import {
@ -8,9 +8,10 @@ import {
export type StartRLNOptions = {
/**
* If not set - will extract MetaMask account and get signer from it.
* If not set - will attempt to create from injected provider.
*/
signer?: ethers.Signer;
walletClient?: WalletClient;
publicClient?: PublicClient;
/**
* If not set - will use default SEPOLIA_CONTRACT address.
*/

View File

@ -1,4 +1,4 @@
export { extractMetaMaskSigner } from "./metamask.js";
export { createViemClientsFromWindow } from "./walletClient.js";
export { BytesUtils } from "./bytes.js";
export { sha256, poseidonHash } from "./hash.js";
export { dateToEpoch, epochIntToBytes, epochBytesToInt } from "./epoch.js";

View File

@ -1,17 +0,0 @@
import { ethers } from "ethers";
export const extractMetaMaskSigner = async (): Promise<ethers.Signer> => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ethereum = (window as any).ethereum;
if (!ethereum) {
throw Error(
"Missing or invalid Ethereum provider. Please install a Web3 wallet such as MetaMask."
);
}
await ethereum.request({ method: "eth_requestAccounts" });
const provider = new ethers.providers.Web3Provider(ethereum, "any");
return provider.getSigner();
};

View File

@ -0,0 +1,36 @@
import {
createPublicClient,
createWalletClient,
custom,
PublicClient,
WalletClient
} from "viem";
import { type Chain, lineaSepolia } from "viem/chains";
export const createViemClientsFromWindow = async (
chain: Chain = lineaSepolia
): Promise<{ walletClient: WalletClient; publicClient: PublicClient }> => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ethereum = (window as any).ethereum;
if (!ethereum) {
throw Error(
"Missing or invalid Ethereum provider. Please install a Web3 wallet such as MetaMask."
);
}
const [account] = await ethereum.request({ method: "eth_requestAccounts" });
const walletClient = createWalletClient({
account,
chain,
transport: custom(ethereum)
});
const publicClient = createPublicClient({
chain,
transport: custom(ethereum)
});
return { walletClient, publicClient };
};

View File

@ -1,3 +1,4 @@
{
"extends": "../../tsconfig.dev"
}
"extends": "../../tsconfig.dev",
"exclude": ["wagmi.config.ts"]
}

View File

@ -6,5 +6,5 @@
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"include": ["src"],
"exclude": ["src/**/*.spec.ts", "src/test_utils"]
}
"exclude": ["wagmi.config.ts"]
}