feat(rln)!: generate contract types, migrate from ethers to viem (#2705)

* feat: use wagmi to generate contract types

* feat: migrate rln from ethers to viem

* fix: remove .gitmodules

* fix: update readme

* fix: refactor to use a single viem client object

* fix: update comments, tsconfig

* feat: remove membership event tracking

* fix: script name in package.json and readme

* fix: only allow linea sepolia

* fix: consolidate viem types, typed window

* fix: use viem to infer type of decoded event

* fix: use js for generate abi script

* feat: generate abi and build rln package as release condition

* fix: check that eth_requestAccounts returns an array

* fix: handle error messages

* fix: use https instead of git for cloning in script

* fix: add warning annotations for contract typings check

* fix: install deps for rln package before building

* fix: use pnpm when installing rln contracts

* fix: use workspace flag to run abi script

* fix: add ref to checkout action

* fix: include pnpm in ci
This commit is contained in:
Arseniy Klempner 2025-12-01 17:32:35 -08:00 committed by GitHub
parent 788f7e62c5
commit f2ad23ad43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 2169 additions and 2158 deletions

View File

@ -104,6 +104,7 @@
"reactjs",
"recid",
"rlnrelay",
"rlnv",
"roadmap",
"sandboxed",
"scanf",
@ -132,7 +133,9 @@
"upgrader",
"vacp",
"varint",
"viem",
"vkey",
"wagmi",
"waku",
"wakuconnect",
"wakunode",

View File

@ -113,12 +113,44 @@ jobs:
node-version: ${{ env.NODE_JS }}
registry-url: "https://registry.npmjs.org"
- uses: pnpm/action-setup@v4
if: ${{ steps.release.outputs.releases_created }}
with:
version: 9
- run: npm install
if: ${{ steps.release.outputs.releases_created }}
- run: npm run build
if: ${{ steps.release.outputs.releases_created }}
- name: Setup Foundry
if: ${{ steps.release.outputs.releases_created }}
uses: foundry-rs/foundry-toolchain@v1
with:
version: nightly
- name: Generate RLN contract ABIs
id: rln-abi
if: ${{ steps.release.outputs.releases_created }}
run: |
npm run setup:contract-abi -w @waku/rln || {
echo "::warning::Failed to generate contract ABIs, marking @waku/rln as private to skip publishing"
cd packages/rln
node -e "const fs = require('fs'); const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); pkg.private = true; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));"
echo "failed=true" >> $GITHUB_OUTPUT
}
- name: Rebuild with new ABIs
if: ${{ steps.release.outputs.releases_created && steps.rln-abi.outputs.failed != 'true' }}
run: |
npm install -w packages/rln
npm run build -w @waku/rln || {
echo "::warning::Failed to build @waku/rln, marking as private to skip publishing"
cd packages/rln
node -e "const fs = require('fs'); const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); pkg.private = true; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));"
}
- run: npm run publish
if: ${{ steps.release.outputs.releases_created }}
env:

View File

@ -17,16 +17,46 @@ jobs:
- uses: actions/checkout@v4
with:
repository: waku-org/js-waku
ref: ${{ github.ref }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_JS }}
registry-url: "https://registry.npmjs.org"
- uses: pnpm/action-setup@v4
with:
version: 9
- run: npm install
- run: npm run build
- name: Setup Foundry
uses: foundry-rs/foundry-toolchain@v1
with:
version: nightly
- name: Generate RLN contract ABIs
id: rln-abi
run: |
npm run setup:contract-abi -w @waku/rln || {
echo "::warning::Failed to generate contract ABIs, marking @waku/rln as private to skip publishing"
cd packages/rln
node -e "const fs = require('fs'); const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); pkg.private = true; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));"
echo "failed=true" >> $GITHUB_OUTPUT
}
- name: Rebuild with new ABIs
if: steps.rln-abi.outputs.failed != 'true'
run: |
npm install -w packages/rln
npm run build -w @waku/rln || {
echo "::warning::Failed to build @waku/rln, marking as private to skip publishing"
cd packages/rln
node -e "const fs = require('fs'); const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); pkg.private = true; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));"
}
- run: npm run publish -- --tag next
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_JS_WAKU_PUBLISH }}

1
.gitignore vendored
View File

@ -20,3 +20,4 @@ packages/discovery/mock_local_storage
CLAUDE.md
.env
postgres-data/
packages/rln/waku-rlnv2-contract/

1373
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,5 +3,10 @@ module.exports = {
tsconfigRootDir: __dirname,
project: "./tsconfig.dev.json"
},
ignorePatterns: ["src/resources/**/*"]
ignorePatterns: ["src/resources/**/*"],
overrides: [
{
files: ["*.config.ts", "*.config.js"]
}
]
};

View File

@ -12,6 +12,18 @@ This package provides RLN functionality for the Waku protocol, enabling rate-lim
npm install @waku/rln
```
## Smart Contract Type Generation
We use `wagmi` to generate TypeScript bindings for interacting with the RLN smart contracts.
When changes are pushed to the `waku-rlnv2-contract` repository, run the following script to fetch and build the latest contracts and generate the TypeScript bindings:
```
npm run setup:contract-abi
```
Note that we commit/bundle the generated typings, so it's not necessary to run this script unless the contracts are updated.
## Usage
```typescript
@ -20,11 +32,6 @@ import { RLN } from '@waku/rln';
// Usage examples coming soon
```
## Constants
- Implementation contract: 0xde2260ca49300357d5af4153cda0d18f7b3ea9b3
- Proxy contract: 0xb9cd878c90e49f797b4431fbf4fb333108cb90e6
## License
MIT OR Apache-2.0
MIT OR Apache-2.0

View File

@ -0,0 +1,66 @@
import { execSync } from "child_process";
import { existsSync, rmSync } from "fs";
import { dirname, join } from "path";
import process from "process";
import { fileURLToPath } from "url";
// Get script directory (equivalent to BASH_SOURCE in bash)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const CONTRACT_DIR = join(__dirname, "waku-rlnv2-contract");
const REPO_URL = "https://github.com/waku-org/waku-rlnv2-contract.git";
/**
* Execute a shell command and print output in real-time
* @param {string} command - The command to execute
* @param {object} options - Options for execSync
*/
function exec(command, options = {}) {
execSync(command, {
stdio: "inherit",
cwd: options.cwd || __dirname,
...options
});
}
async function main() {
try {
console.log("📦 Setting up waku-rlnv2-contract...");
// Remove existing directory if it exists
if (existsSync(CONTRACT_DIR)) {
console.log("🗑️ Removing existing waku-rlnv2-contract directory...");
rmSync(CONTRACT_DIR, { recursive: true, force: true });
}
// Clone the repository
console.log("📥 Cloning waku-rlnv2-contract...");
exec(`git clone ${REPO_URL} ${CONTRACT_DIR}`);
// Install dependencies
console.log("📦 Installing dependencies...");
exec("pnpm i", { cwd: CONTRACT_DIR });
// Build contracts with Foundry
console.log("🔨 Building contracts with Foundry...");
exec("forge build", { cwd: CONTRACT_DIR });
// Generate ABIs with wagmi
console.log("⚙️ Generating ABIs with wagmi...");
exec("npx wagmi generate");
console.log("✅ Contract ABIs generated successfully!");
} catch (error) {
console.log(
"❌ Error generating contract ABIs:",
error instanceof Error ? error.message : error
);
process.exit(1);
}
}
main().catch((error) => {
console.log(error);
process.exit(1);
});

View File

@ -43,7 +43,8 @@
"watch:build": "tsc -p tsconfig.json -w",
"watch:test": "mocha --watch",
"prepublish": "npm run build",
"reset-hard": "git clean -dfx -e .idea && git reset --hard && npm i && npm run build"
"reset-hard": "git clean -dfx -e .idea && git reset --hard && npm i && npm run build",
"setup:contract-abi": "node generate_contract_abi.js"
},
"engines": {
"node": ">=22"
@ -54,12 +55,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",
"@wagmi/cli": "^2.7.0",
"@waku/build-utils": "^1.0.0",
"@waku/message-encryption": "^0.0.38",
"@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"
@ -76,18 +78,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,93 +0,0 @@
export const PRICE_CALCULATOR_ABI = [
{
inputs: [
{ internalType: "address", name: "_token", type: "address" },
{
internalType: "uint256",
name: "_pricePerMessagePerEpoch",
type: "uint256"
}
],
stateMutability: "nonpayable",
type: "constructor"
},
{ inputs: [], name: "OnlyTokensAllowed", type: "error" },
{
anonymous: false,
inputs: [
{
indexed: true,
internalType: "address",
name: "previousOwner",
type: "address"
},
{
indexed: true,
internalType: "address",
name: "newOwner",
type: "address"
}
],
name: "OwnershipTransferred",
type: "event"
},
{
inputs: [{ internalType: "uint32", name: "_rateLimit", type: "uint32" }],
name: "calculate",
outputs: [
{ internalType: "address", name: "", type: "address" },
{ internalType: "uint256", name: "", type: "uint256" }
],
stateMutability: "view",
type: "function"
},
{
inputs: [],
name: "owner",
outputs: [{ internalType: "address", name: "", type: "address" }],
stateMutability: "view",
type: "function"
},
{
inputs: [],
name: "pricePerMessagePerEpoch",
outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
stateMutability: "view",
type: "function"
},
{
inputs: [],
name: "renounceOwnership",
outputs: [],
stateMutability: "nonpayable",
type: "function"
},
{
inputs: [
{ internalType: "address", name: "_token", type: "address" },
{
internalType: "uint256",
name: "_pricePerMessagePerEpoch",
type: "uint256"
}
],
name: "setTokenAndPrice",
outputs: [],
stateMutability: "nonpayable",
type: "function"
},
{
inputs: [],
name: "token",
outputs: [{ internalType: "address", name: "", type: "address" }],
stateMutability: "view",
type: "function"
},
{
inputs: [{ internalType: "address", name: "newOwner", type: "address" }],
name: "transferOwnership",
outputs: [],
stateMutability: "nonpayable",
type: "function"
}
];

View File

@ -1,646 +0,0 @@
export const RLN_ABI = [
{ inputs: [], stateMutability: "nonpayable", type: "constructor" },
{
inputs: [
{ internalType: "uint256", name: "idCommitment", type: "uint256" }
],
name: "CannotEraseActiveMembership",
type: "error"
},
{ inputs: [], name: "CannotExceedMaxTotalRateLimit", type: "error" },
{
inputs: [
{ internalType: "uint256", name: "idCommitment", type: "uint256" }
],
name: "CannotExtendNonGracePeriodMembership",
type: "error"
},
{
inputs: [
{ internalType: "uint256", name: "idCommitment", type: "uint256" }
],
name: "InvalidIdCommitment",
type: "error"
},
{ inputs: [], name: "InvalidMembershipRateLimit", type: "error" },
{
inputs: [
{ internalType: "uint256", name: "startIndex", type: "uint256" },
{ internalType: "uint256", name: "endIndex", type: "uint256" }
],
name: "InvalidPaginationQuery",
type: "error"
},
{
inputs: [
{ internalType: "uint256", name: "idCommitment", type: "uint256" }
],
name: "MembershipDoesNotExist",
type: "error"
},
{
inputs: [
{ internalType: "uint256", name: "idCommitment", type: "uint256" }
],
name: "NonHolderCannotEraseGracePeriodMembership",
type: "error"
},
{
inputs: [
{ internalType: "uint256", name: "idCommitment", type: "uint256" }
],
name: "NonHolderCannotExtend",
type: "error"
},
{
anonymous: false,
inputs: [
{
indexed: false,
internalType: "address",
name: "previousAdmin",
type: "address"
},
{
indexed: false,
internalType: "address",
name: "newAdmin",
type: "address"
}
],
name: "AdminChanged",
type: "event"
},
{
anonymous: false,
inputs: [
{
indexed: true,
internalType: "address",
name: "beacon",
type: "address"
}
],
name: "BeaconUpgraded",
type: "event"
},
{
anonymous: false,
inputs: [
{ indexed: false, internalType: "uint8", name: "version", type: "uint8" }
],
name: "Initialized",
type: "event"
},
{
anonymous: false,
inputs: [
{
indexed: false,
internalType: "uint256",
name: "idCommitment",
type: "uint256"
},
{
indexed: false,
internalType: "uint32",
name: "membershipRateLimit",
type: "uint32"
},
{ indexed: false, internalType: "uint32", name: "index", type: "uint32" }
],
name: "MembershipErased",
type: "event"
},
{
anonymous: false,
inputs: [
{
indexed: false,
internalType: "uint256",
name: "idCommitment",
type: "uint256"
},
{
indexed: false,
internalType: "uint32",
name: "membershipRateLimit",
type: "uint32"
},
{ indexed: false, internalType: "uint32", name: "index", type: "uint32" }
],
name: "MembershipExpired",
type: "event"
},
{
anonymous: false,
inputs: [
{
indexed: false,
internalType: "uint256",
name: "idCommitment",
type: "uint256"
},
{
indexed: false,
internalType: "uint32",
name: "membershipRateLimit",
type: "uint32"
},
{ indexed: false, internalType: "uint32", name: "index", type: "uint32" },
{
indexed: false,
internalType: "uint256",
name: "newGracePeriodStartTimestamp",
type: "uint256"
}
],
name: "MembershipExtended",
type: "event"
},
{
anonymous: false,
inputs: [
{
indexed: false,
internalType: "uint256",
name: "idCommitment",
type: "uint256"
},
{
indexed: false,
internalType: "uint256",
name: "membershipRateLimit",
type: "uint256"
},
{ indexed: false, internalType: "uint32", name: "index", type: "uint32" }
],
name: "MembershipRegistered",
type: "event"
},
{
anonymous: false,
inputs: [
{
indexed: true,
internalType: "address",
name: "previousOwner",
type: "address"
},
{
indexed: true,
internalType: "address",
name: "newOwner",
type: "address"
}
],
name: "OwnershipTransferred",
type: "event"
},
{
anonymous: false,
inputs: [
{
indexed: true,
internalType: "address",
name: "implementation",
type: "address"
}
],
name: "Upgraded",
type: "event"
},
{
inputs: [],
name: "MAX_MEMBERSHIP_SET_SIZE",
outputs: [{ internalType: "uint32", name: "", type: "uint32" }],
stateMutability: "view",
type: "function"
},
{
inputs: [],
name: "MERKLE_TREE_DEPTH",
outputs: [{ internalType: "uint8", name: "", type: "uint8" }],
stateMutability: "view",
type: "function"
},
{
inputs: [],
name: "Q",
outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
stateMutability: "view",
type: "function"
},
{
inputs: [],
name: "activeDurationForNewMemberships",
outputs: [{ internalType: "uint32", name: "", type: "uint32" }],
stateMutability: "view",
type: "function"
},
{
inputs: [],
name: "currentTotalRateLimit",
outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
stateMutability: "view",
type: "function"
},
{
inputs: [],
name: "deployedBlockNumber",
outputs: [{ internalType: "uint32", name: "", type: "uint32" }],
stateMutability: "view",
type: "function"
},
{
inputs: [
{ internalType: "address", name: "holder", type: "address" },
{ internalType: "address", name: "token", type: "address" }
],
name: "depositsToWithdraw",
outputs: [{ internalType: "uint256", name: "balance", type: "uint256" }],
stateMutability: "view",
type: "function"
},
{
inputs: [
{ internalType: "uint256[]", name: "idCommitments", type: "uint256[]" }
],
name: "eraseMemberships",
outputs: [],
stateMutability: "nonpayable",
type: "function"
},
{
inputs: [
{ internalType: "uint256[]", name: "idCommitments", type: "uint256[]" },
{ internalType: "bool", name: "eraseFromMembershipSet", type: "bool" }
],
name: "eraseMemberships",
outputs: [],
stateMutability: "nonpayable",
type: "function"
},
{
inputs: [
{ internalType: "uint256[]", name: "idCommitments", type: "uint256[]" }
],
name: "extendMemberships",
outputs: [],
stateMutability: "nonpayable",
type: "function"
},
{
inputs: [
{ internalType: "uint256", name: "idCommitment", type: "uint256" }
],
name: "getMembershipInfo",
outputs: [
{ internalType: "uint32", name: "", type: "uint32" },
{ internalType: "uint32", name: "", type: "uint32" },
{ internalType: "uint256", name: "", type: "uint256" }
],
stateMutability: "view",
type: "function"
},
{
inputs: [{ internalType: "uint40", name: "index", type: "uint40" }],
name: "getMerkleProof",
outputs: [{ internalType: "uint256[20]", name: "", type: "uint256[20]" }],
stateMutability: "view",
type: "function"
},
{
inputs: [
{ internalType: "uint32", name: "startIndex", type: "uint32" },
{ internalType: "uint32", name: "endIndex", type: "uint32" }
],
name: "getRateCommitmentsInRangeBoundsInclusive",
outputs: [{ internalType: "uint256[]", name: "", type: "uint256[]" }],
stateMutability: "view",
type: "function"
},
{
inputs: [],
name: "gracePeriodDurationForNewMemberships",
outputs: [{ internalType: "uint32", name: "", type: "uint32" }],
stateMutability: "view",
type: "function"
},
{
inputs: [{ internalType: "uint256", name: "", type: "uint256" }],
name: "indicesOfLazilyErasedMemberships",
outputs: [{ internalType: "uint32", name: "", type: "uint32" }],
stateMutability: "view",
type: "function"
},
{
inputs: [
{ internalType: "address", name: "_priceCalculator", type: "address" },
{ internalType: "uint32", name: "_maxTotalRateLimit", type: "uint32" },
{
internalType: "uint32",
name: "_minMembershipRateLimit",
type: "uint32"
},
{
internalType: "uint32",
name: "_maxMembershipRateLimit",
type: "uint32"
},
{ internalType: "uint32", name: "_activeDuration", type: "uint32" },
{ internalType: "uint32", name: "_gracePeriod", type: "uint32" }
],
name: "initialize",
outputs: [],
stateMutability: "nonpayable",
type: "function"
},
{
inputs: [
{ internalType: "uint256", name: "_idCommitment", type: "uint256" }
],
name: "isExpired",
outputs: [{ internalType: "bool", name: "", type: "bool" }],
stateMutability: "view",
type: "function"
},
{
inputs: [
{ internalType: "uint256", name: "_idCommitment", type: "uint256" }
],
name: "isInGracePeriod",
outputs: [{ internalType: "bool", name: "", type: "bool" }],
stateMutability: "view",
type: "function"
},
{
inputs: [
{ internalType: "uint256", name: "idCommitment", type: "uint256" }
],
name: "isInMembershipSet",
outputs: [{ internalType: "bool", name: "", type: "bool" }],
stateMutability: "view",
type: "function"
},
{
inputs: [
{ internalType: "uint256", name: "idCommitment", type: "uint256" }
],
name: "isValidIdCommitment",
outputs: [{ internalType: "bool", name: "", type: "bool" }],
stateMutability: "pure",
type: "function"
},
{
inputs: [{ internalType: "uint32", name: "rateLimit", type: "uint32" }],
name: "isValidMembershipRateLimit",
outputs: [{ internalType: "bool", name: "", type: "bool" }],
stateMutability: "view",
type: "function"
},
{
inputs: [],
name: "maxMembershipRateLimit",
outputs: [{ internalType: "uint32", name: "", type: "uint32" }],
stateMutability: "view",
type: "function"
},
{
inputs: [],
name: "maxTotalRateLimit",
outputs: [{ internalType: "uint32", name: "", type: "uint32" }],
stateMutability: "view",
type: "function"
},
{
inputs: [
{ internalType: "uint256", name: "_idCommitment", type: "uint256" }
],
name: "membershipExpirationTimestamp",
outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
stateMutability: "view",
type: "function"
},
{
inputs: [
{ internalType: "uint256", name: "idCommitment", type: "uint256" }
],
name: "memberships",
outputs: [
{ internalType: "uint256", name: "depositAmount", type: "uint256" },
{ internalType: "uint32", name: "activeDuration", type: "uint32" },
{
internalType: "uint256",
name: "gracePeriodStartTimestamp",
type: "uint256"
},
{ internalType: "uint32", name: "gracePeriodDuration", type: "uint32" },
{ internalType: "uint32", name: "rateLimit", type: "uint32" },
{ internalType: "uint32", name: "index", type: "uint32" },
{ internalType: "address", name: "holder", type: "address" },
{ internalType: "address", name: "token", type: "address" }
],
stateMutability: "view",
type: "function"
},
{
inputs: [],
name: "merkleTree",
outputs: [
{ internalType: "uint40", name: "maxIndex", type: "uint40" },
{ internalType: "uint40", name: "numberOfLeaves", type: "uint40" }
],
stateMutability: "view",
type: "function"
},
{
inputs: [],
name: "minMembershipRateLimit",
outputs: [{ internalType: "uint32", name: "", type: "uint32" }],
stateMutability: "view",
type: "function"
},
{
inputs: [],
name: "nextFreeIndex",
outputs: [{ internalType: "uint32", name: "", type: "uint32" }],
stateMutability: "view",
type: "function"
},
{
inputs: [],
name: "owner",
outputs: [{ internalType: "address", name: "", type: "address" }],
stateMutability: "view",
type: "function"
},
{
inputs: [],
name: "priceCalculator",
outputs: [
{ internalType: "contract IPriceCalculator", name: "", type: "address" }
],
stateMutability: "view",
type: "function"
},
{
inputs: [],
name: "proxiableUUID",
outputs: [{ internalType: "bytes32", name: "", type: "bytes32" }],
stateMutability: "view",
type: "function"
},
{
inputs: [
{ internalType: "uint256", name: "idCommitment", type: "uint256" },
{ internalType: "uint32", name: "rateLimit", type: "uint32" },
{
internalType: "uint256[]",
name: "idCommitmentsToErase",
type: "uint256[]"
}
],
name: "register",
outputs: [],
stateMutability: "nonpayable",
type: "function"
},
{
inputs: [
{ internalType: "address", name: "owner", type: "address" },
{ internalType: "uint256", name: "deadline", type: "uint256" },
{ internalType: "uint8", name: "v", type: "uint8" },
{ internalType: "bytes32", name: "r", type: "bytes32" },
{ internalType: "bytes32", name: "s", type: "bytes32" },
{ internalType: "uint256", name: "idCommitment", type: "uint256" },
{ internalType: "uint32", name: "rateLimit", type: "uint32" },
{
internalType: "uint256[]",
name: "idCommitmentsToErase",
type: "uint256[]"
}
],
name: "registerWithPermit",
outputs: [],
stateMutability: "nonpayable",
type: "function"
},
{
inputs: [],
name: "renounceOwnership",
outputs: [],
stateMutability: "nonpayable",
type: "function"
},
{
inputs: [],
name: "root",
outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
stateMutability: "view",
type: "function"
},
{
inputs: [
{
internalType: "uint32",
name: "_activeDurationForNewMembership",
type: "uint32"
}
],
name: "setActiveDuration",
outputs: [],
stateMutability: "nonpayable",
type: "function"
},
{
inputs: [
{
internalType: "uint32",
name: "_gracePeriodDurationForNewMembership",
type: "uint32"
}
],
name: "setGracePeriodDuration",
outputs: [],
stateMutability: "nonpayable",
type: "function"
},
{
inputs: [
{
internalType: "uint32",
name: "_maxMembershipRateLimit",
type: "uint32"
}
],
name: "setMaxMembershipRateLimit",
outputs: [],
stateMutability: "nonpayable",
type: "function"
},
{
inputs: [
{ internalType: "uint32", name: "_maxTotalRateLimit", type: "uint32" }
],
name: "setMaxTotalRateLimit",
outputs: [],
stateMutability: "nonpayable",
type: "function"
},
{
inputs: [
{
internalType: "uint32",
name: "_minMembershipRateLimit",
type: "uint32"
}
],
name: "setMinMembershipRateLimit",
outputs: [],
stateMutability: "nonpayable",
type: "function"
},
{
inputs: [
{ internalType: "address", name: "_priceCalculator", type: "address" }
],
name: "setPriceCalculator",
outputs: [],
stateMutability: "nonpayable",
type: "function"
},
{
inputs: [{ internalType: "address", name: "newOwner", type: "address" }],
name: "transferOwnership",
outputs: [],
stateMutability: "nonpayable",
type: "function"
},
{
inputs: [
{ internalType: "address", name: "newImplementation", type: "address" }
],
name: "upgradeTo",
outputs: [],
stateMutability: "nonpayable",
type: "function"
},
{
inputs: [
{ internalType: "address", name: "newImplementation", type: "address" },
{ internalType: "bytes", name: "data", type: "bytes" }
],
name: "upgradeToAndCall",
outputs: [],
stateMutability: "payable",
type: "function"
},
{
inputs: [{ internalType: "address", name: "token", type: "address" }],
name: "withdraw",
outputs: [],
stateMutability: "nonpayable",
type: "function"
}
];

View File

@ -1,16 +1,15 @@
import { PRICE_CALCULATOR_ABI } from "./abi/price_calculator.js";
import { RLN_ABI } from "./abi/rln.js";
import { linearPriceCalculatorAbi, wakuRlnV2Abi } from "./wagmi/generated.js";
export const RLN_CONTRACT = {
chainId: 59141,
address: "0xb9cd878c90e49f797b4431fbf4fb333108cb90e6",
abi: RLN_ABI
abi: wakuRlnV2Abi
};
export const PRICE_CALCULATOR_CONTRACT = {
chainId: 59141,
address: "0xBcfC0660Df69f53ab409F32bb18A3fb625fcE644",
abi: PRICE_CALCULATOR_ABI
abi: linearPriceCalculatorAbi
};
/**

View File

@ -1,28 +1,39 @@
import { expect, use } from "chai";
import chaiAsPromised from "chai-as-promised";
import { ethers } from "ethers";
import sinon from "sinon";
import { RLNBaseContract } from "./rln_base_contract.js";
use(chaiAsPromised);
function createMockRLNBaseContract(provider: any): RLNBaseContract {
function createMockRLNBaseContract(
mockContract: any,
mockRpcClient: any
): RLNBaseContract {
const dummy = Object.create(RLNBaseContract.prototype);
dummy.contract = { provider };
dummy.contract = mockContract;
dummy.rpcClient = mockRpcClient;
return dummy as RLNBaseContract;
}
describe("RLNBaseContract.getPriceForRateLimit (unit)", function () {
let provider: any;
let calculateStub: sinon.SinonStub;
let mockContractFactory: any;
let mockContract: any;
let mockRpcClient: 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
}
};
mockRpcClient = {
readContract: readContractStub
};
});
@ -32,35 +43,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, mockRpcClient);
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, mockRpcClient);
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, mockRpcClient);
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,92 +1,74 @@
import { Logger } from "@waku/utils";
import { ethers } from "ethers";
import {
type Address,
decodeEventLog,
getContract,
type GetContractReturnType,
type Hash,
type PublicClient,
type WalletClient
} from "viem";
import { IdentityCredential } from "../identity.js";
import { DecryptedCredentials } from "../keystore/types.js";
import type { DecryptedCredentials } from "../keystore/types.js";
import type { RpcClient } from "../utils/index.js";
import { RLN_ABI } from "./abi/rln.js";
import {
DEFAULT_RATE_LIMIT,
PRICE_CALCULATOR_CONTRACT,
RATE_LIMIT_PARAMS
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");
export class RLNBaseContract {
public contract: ethers.Contract;
private deployBlock: undefined | number;
public contract: GetContractReturnType<
typeof wakuRlnV2Abi,
PublicClient | WalletClient
>;
public rpcClient: RpcClient;
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) {
const {
address,
signer,
rateLimit = DEFAULT_RATE_LIMIT,
contract
} = options;
protected constructor(options: RLNContractOptions) {
const { address, rpcClient, rateLimit = DEFAULT_RATE_LIMIT } = options;
log.info("Initializing RLNBaseContract", { address, rateLimit });
this.contract = contract || new ethers.Contract(address, RLN_ABI, signer);
this.rpcClient = rpcClient;
this.contract = getContract({
address,
abi: wakuRlnV2Abi,
client: this.rpcClient
});
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(() => {
this.subscribeToMembers();
})
.catch((error) => {
log.error("Failed to initialize members", { error });
});
}
/**
* Static async factory to create and initialize RLNBaseContract
*/
public static async create(
options: RLNContractInitOptions
options: RLNContractOptions
): Promise<RLNBaseContract> {
const instance = new RLNBaseContract(options);
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;
@ -106,13 +88,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)
*/
@ -136,8 +111,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();
}
/**
@ -145,8 +119,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());
}
/**
@ -154,11 +127,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())
);
}
/**
@ -170,233 +142,35 @@ export class RLNBaseContract {
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;
/**
* 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();
}
public async fetchMembers(options: FetchMembersOptions = {}): Promise<void> {
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);
}
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[]>();
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);
}
});
}
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,
(
_idCommitment: bigint,
_membershipRateLimit: ethers.BigNumber,
_index: ethers.BigNumber,
event: ethers.Event
) => {
this.processEvents([event]);
}
);
this.contract.on(
this.membershipErasedFilter,
(
_idCommitment: bigint,
_membershipRateLimit: ethers.BigNumber,
_index: ethers.BigNumber,
event: ethers.Event
) => {
this.processEvents([event]);
}
);
this.contract.on(
this.membersExpiredFilter,
(
_idCommitment: bigint,
_membershipRateLimit: ethers.BigNumber,
_index: ethers.BigNumber,
event: ethers.Event
) => {
this.processEvents([event]);
}
);
/**
* 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 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.rpcClient.getBlockNumber();
const [
depositAmount,
activeDuration,
@ -408,12 +182,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;
@ -422,9 +197,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,
@ -438,43 +213,87 @@ 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.rpcClient.account) {
throw new Error(
"Failed to extendMembership: no account set in wallet client"
);
}
try {
await this.contract.simulate.extendMemberships([[idCommitmentBigInt]], {
chain: this.rpcClient.chain,
account: this.rpcClient.account.address
});
} catch (err) {
if (err instanceof Error) {
throw new Error(
"Error simulating extending membership: " + err.message
);
} else {
throw new Error("Error simulating extending membership", {
cause: err
});
}
}
const hash = await this.contract.write.extendMemberships(
[[idCommitmentBigInt]],
{
account: this.rpcClient.account,
chain: this.rpcClient.chain
}
);
await this.rpcClient.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.rpcClient.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.rpcClient.chain,
account: this.rpcClient.account.address
}
);
} catch (err) {
if (err instanceof Error) {
throw new Error("Error simulating eraseMemberships: " + err.message);
} else {
throw new Error("Error simulating eraseMemberships", { cause: err });
}
}
const tx = await this.contract["eraseMemberships(uint256[],bool)"](
[idCommitmentBigInt],
eraseFromMembershipSet,
{ gasLimit }
const hash = await this.contract.write.eraseMemberships(
[[idCommitmentBigInt], eraseFromMembershipSet],
{
chain: this.rpcClient.chain,
account: this.rpcClient.account
}
);
await tx.wait();
return tx;
await this.rpcClient.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
@ -483,21 +302,80 @@ 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.rpcClient.account) {
throw new Error(
"Failed to registerMembership: no account set in wallet client"
);
}
try {
await this.contract.simulate.register(
[idCommitmentBigInt, rateLimit, []],
{
chain: this.rpcClient.chain,
account: this.rpcClient.account.address
}
);
} catch (err) {
if (err instanceof Error) {
throw new Error("Error simulating register membership: " + err.message);
} else {
throw new Error("Error simulating register membership", { cause: err });
}
}
const hash = await this.contract.write.register(
[idCommitmentBigInt, rateLimit, []],
{
chain: this.rpcClient.chain,
account: this.rpcClient.account
}
);
await this.rpcClient.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.
* The smart contract validates that the sender is the holder of the membership,
* and will only send tokens to that address.
* @param token - Token address to withdraw
*/
public async withdraw(token: string): Promise<Hash> {
if (!this.rpcClient.account) {
throw new Error("Failed to withdraw: no account set in wallet client");
}
try {
await this.contract.simulate.withdraw([token as Address], {
chain: this.rpcClient.chain,
account: this.rpcClient.account.address
});
} catch (err) {
if (err instanceof Error) {
throw new Error("Error simulating withdraw: " + err.message);
} else {
throw new Error("Error simulating withdraw", { cause: err });
}
}
const hash = await this.contract.write.withdraw([token as Address], {
chain: this.rpcClient.chain,
account: this.rpcClient.account
});
await this.rpcClient.waitForTransactionReceipt({ hash });
return hash;
}
public async registerWithIdentity(
identity: IdentityCredential
): Promise<DecryptedCredentials | undefined> {
try {
if (!this.rpcClient.account) {
throw new Error(
"Failed to registerWithIdentity: no account set in wallet client"
);
}
log.info(
`Registering identity with rate limit: ${this.rateLimit} messages/epoch`
);
@ -520,62 +398,71 @@ 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.rpcClient.chain,
account: this.rpcClient.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.rpcClient.chain,
account: this.rpcClient.account
}
);
const txRegisterReceipt = await txRegisterResponse.wait();
const txRegisterReceipt = await this.rpcClient.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,
eventName: "MembershipRegistered"
});
log.info(
`Successfully registered membership with index ${decodedData.index} ` +
`and rate limit ${decodedData.membershipRateLimit}`
`Successfully registered membership with index ${decoded.args.index} ` +
`and rate limit ${decoded.args.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: decoded.args.index,
chainId: String(RLN_CONTRACT.chainId),
rateLimit: Number(decoded.args.membershipRateLimit)
}
};
} catch (error) {
@ -608,78 +495,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
@ -695,50 +510,17 @@ 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> {
// Current version of the contract has the index at position 5 in the membership struct
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";
@ -753,7 +535,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;
@ -767,7 +549,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;
@ -779,21 +561,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.rpcClient.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,22 @@
import { ethers } from "ethers";
import { Address } from "viem";
export interface CustomQueryOptions extends FetchMembersOptions {
membersFilter: ethers.EventFilter;
}
import { RpcClient } from "../utils/index.js";
export type Member = {
idCommitment: string;
index: ethers.BigNumber;
index: bigint;
};
export interface RLNContractOptions {
signer: ethers.Signer;
address: string;
rpcClient: RpcClient;
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 +26,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;

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
import { Logger } from "@waku/utils";
import { ethers } from "ethers";
import { publicActions } 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 { createViemClientFromWindow, RpcClient } from "./utils/index.js";
import { Zerokit } from "./zerokit.js";
const log = new Logger("rln:credentials");
@ -24,7 +24,7 @@ export class RLNCredentialsManager {
protected starting = false;
public contract: undefined | RLNBaseContract;
public signer: undefined | ethers.Signer;
public rpcClient: undefined | RpcClient;
protected keystore = Keystore.create();
public credentials: undefined | DecryptedCredentials;
@ -36,10 +36,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 +55,8 @@ export class RLNCredentialsManager {
log.info("Credentials successfully decrypted");
}
const { signer, address, rateLimit } = await this.determineStartOptions(
options,
credentials
);
const { rpcClient, address, rateLimit } =
await this.determineStartOptions(options, credentials);
log.info(`Using contract address: ${address}`);
@ -72,10 +66,10 @@ export class RLNCredentialsManager {
}
this.credentials = credentials;
this.signer = signer!;
this.rpcClient = rpcClient!;
this.contract = await RLNBaseContract.create({
address: address!,
signer: signer!,
address: address! as `0x${string}`,
rpcClient: this.rpcClient,
rateLimit: rateLimit ?? this.zerokit.rateLimit
});
@ -134,7 +128,7 @@ export class RLNCredentialsManager {
protected async determineStartOptions(
options: StartRLNOptions,
credentials: KeystoreEntity | undefined
): Promise<StartRLNOptions> {
): Promise<StartRLNOptions & { rpcClient: RpcClient }> {
let chainId = credentials?.membership.chainId;
const address =
credentials?.membership.address ||
@ -146,11 +140,14 @@ export class RLNCredentialsManager {
log.info(`Using Linea contract with chainId: ${chainId}`);
}
const signer = options.signer || (await extractMetaMaskSigner());
const currentChainId = await signer.getChainId();
const rpcClient: RpcClient = options.walletClient
? options.walletClient.extend(publicActions)
: await createViemClientFromWindow();
const currentChainId = rpcClient.chain?.id;
log.info(`Current chain ID: ${currentChainId}`);
if (chainId && chainId !== currentChainId.toString()) {
if (chainId && chainId !== currentChainId?.toString()) {
log.error(
`Chain ID mismatch: contract=${chainId}, current=${currentChainId}`
);
@ -160,7 +157,7 @@ export class RLNCredentialsManager {
}
return {
signer,
rpcClient,
address
};
}
@ -206,9 +203,9 @@ export class RLNCredentialsManager {
protected async verifyCredentialsAgainstContract(
credentials: KeystoreEntity
): Promise<void> {
if (!this.contract) {
if (!this.contract || !this.rpcClient) {
throw Error(
"Failed to verify chain coordinates: no contract initialized."
"Failed to verify chain coordinates: no contract or viem client initialized."
);
}
@ -221,8 +218,7 @@ export class RLNCredentialsManager {
}
const chainId = credentials.membership.chainId;
const network = await this.contract.provider.getNetwork();
const currentChainId = network.chainId;
const currentChainId = await this.rpcClient.getChainId();
if (chainId !== currentChainId.toString()) {
throw Error(
`Failed to verify chain coordinates: credentials chainID=${chainId} is not equal to registryContract chainID=${currentChainId}`

View File

@ -1,11 +1,10 @@
import { RLN_ABI } from "./contract/abi/rln.js";
import { RLN_CONTRACT } from "./contract/index.js";
import { RLNBaseContract } from "./contract/rln_base_contract.js";
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 { createViemClientFromWindow } from "./utils/index.js";
export {
RLNBaseContract,
@ -14,10 +13,16 @@ export {
RLNInstance,
IdentityCredential,
RLN_CONTRACT,
extractMetaMaskSigner,
RLN_ABI
createViemClientFromWindow
};
export {
wakuRlnV2Abi,
linearPriceCalculatorAbi,
iPriceCalculatorAbi,
membershipUpgradeableAbi
} from "./contract/wagmi/generated.js";
export type {
DecryptedCredentials,
EncryptedCredentials,

View File

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

View File

@ -1,4 +1,4 @@
export { extractMetaMaskSigner } from "./metamask.js";
export { createViemClientFromWindow, RpcClient } from "./rpcClient.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,61 @@
import "viem/window";
import {
type Address,
createWalletClient,
custom,
PublicActions,
publicActions,
WalletClient
} from "viem";
import { lineaSepolia } from "viem/chains";
export type RpcClient = WalletClient & PublicActions;
/**
* Checks window for injected Ethereum provider, requests user to connect, and creates an RPC client object
* capable of performing both read and write operations on the blockchain.
*
* If the wallet is not connected to the Linea Sepolia network, it will attempt to switch to it.
* If the wallet does not have the Linea Sepolia network added, it will attempt to add it.
*/
export const createViemClientFromWindow = async (): Promise<RpcClient> => {
const ethereum = window.ethereum;
if (!ethereum) {
throw Error(
"Missing or invalid Ethereum provider. Please install a Web3 wallet such as MetaMask."
);
}
const accounts = await ethereum.request({ method: "eth_requestAccounts" });
if (!Array.isArray(accounts)) {
throw Error("Failed to get accounts");
}
const account = accounts[0] as Address;
const rpcClient: RpcClient = createWalletClient({
account: account as Address,
chain: lineaSepolia,
transport: custom(window.ethereum!)
}).extend(publicActions);
// Ensure wallet is connected to Linea Sepolia
try {
await rpcClient.switchChain({ id: lineaSepolia.id });
} catch (error: unknown) {
// This error code indicates that the chain has not been added to the wallet
if (
typeof error === "object" &&
error !== null &&
"code" in error &&
error.code === 4902
) {
await rpcClient.addChain({ chain: lineaSepolia });
await rpcClient.switchChain({ id: lineaSepolia.id });
} else {
throw error;
}
}
return rpcClient;
};

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", "src/**/*.spec.ts", "src/test_utils"]
}

View File

@ -0,0 +1,18 @@
import { defineConfig } from "@wagmi/cli";
import { foundry } from "@wagmi/cli/plugins";
export default defineConfig({
out: "src/contract/wagmi/generated.ts",
plugins: [
foundry({
project: "./waku-rlnv2-contract",
artifacts: "out",
include: [
"WakuRlnV2.sol/**",
"Membership.sol/**",
"LinearPriceCalculator.sol/**",
"IPriceCalculator.sol/**"
]
})
]
});