From feb8b87d7a86debe395adb20366b37ef32e17935 Mon Sep 17 00:00:00 2001 From: Arseniy Klempner Date: Tue, 28 Oct 2025 19:26:18 -0700 Subject: [PATCH] feat: implement proof generation and verification --- package-lock.json | 7 +- packages/rln/.mocha.reporters.json | 6 + packages/rln/.mocharc.cjs | 5 +- packages/rln/karma.conf.cjs | 16 + packages/rln/package.json | 4 +- .../src/contract/proof.integration.spec.ts | 205 +++++++++++ .../rln/src/contract/rln_base_contract.ts | 336 ------------------ packages/rln/src/rln.ts | 2 + packages/rln/src/utils/bytes.ts | 37 ++ packages/rln/src/utils/epoch.ts | 8 +- packages/rln/src/utils/hash.ts | 13 +- packages/rln/src/utils/merkle.ts | 179 ++++++++++ packages/rln/src/utils/test_keystore.ts | 7 + packages/rln/src/zerokit.ts | 126 ++++++- 14 files changed, 591 insertions(+), 360 deletions(-) create mode 100644 packages/rln/.mocha.reporters.json create mode 100644 packages/rln/src/contract/proof.integration.spec.ts create mode 100644 packages/rln/src/utils/merkle.ts create mode 100644 packages/rln/src/utils/test_keystore.ts diff --git a/package-lock.json b/package-lock.json index 564859de98..a147832ae0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7154,6 +7154,11 @@ "integrity": "sha512-2Xp7e92y4qZpsiTPGBSVr4gVJ9mJTLaudlo0DQxNpxJUBtoJKpxdH5xDCQDiorbkWZC2j9EId+ohhxHO/xC1QQ==", "license": "MIT or Apache2" }, + "node_modules/@waku/zerokit-rln-wasm-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@waku/zerokit-rln-wasm-utils/-/zerokit-rln-wasm-utils-0.1.0.tgz", + "integrity": "sha512-3ccyg9+CtRXFJfWaxI/kx8Aec5B2S9YUmZAVhPRdN1EG6iQYG2hgvAurx8ZF9/zOppdrhzzyvCgDPg5kRUlOfQ==" + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -35489,6 +35494,7 @@ "@waku/core": "^0.0.40", "@waku/utils": "^0.0.27", "@waku/zerokit-rln-wasm": "^0.2.1", + "@waku/zerokit-rln-wasm-utils": "^0.1.0", "chai": "^5.1.2", "chai-as-promised": "^8.0.1", "chai-spies": "^1.1.0", @@ -35510,7 +35516,6 @@ "@types/sinon": "^17.0.3", "@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", diff --git a/packages/rln/.mocha.reporters.json b/packages/rln/.mocha.reporters.json new file mode 100644 index 0000000000..8b32b1de9c --- /dev/null +++ b/packages/rln/.mocha.reporters.json @@ -0,0 +1,6 @@ +{ + "reporterEnabled": "spec, allure-mocha", + "allureMochaReporter": { + "outputDir": "allure-results" + } + } \ No newline at end of file diff --git a/packages/rln/.mocharc.cjs b/packages/rln/.mocharc.cjs index 268cf0c611..2f663971cd 100644 --- a/packages/rln/.mocharc.cjs +++ b/packages/rln/.mocharc.cjs @@ -20,8 +20,11 @@ if (process.env.CI) { config.reporterOptions = { configFile: '.mocha.reporters.json' }; + // Exclude integration tests in CI (they require RPC access) + console.log("Excluding integration tests in CI environment"); + config.ignore = 'src/**/*.integration.spec.ts'; } else { console.log("Running tests serially. To enable parallel execution update mocha config"); } -module.exports = config; \ No newline at end of file +module.exports = config; diff --git a/packages/rln/karma.conf.cjs b/packages/rln/karma.conf.cjs index f922aa1d9c..6befe4047b 100644 --- a/packages/rln/karma.conf.cjs +++ b/packages/rln/karma.conf.cjs @@ -38,9 +38,19 @@ module.exports = function (config) { watched: false, type: "wasm", nocache: true + }, + { + pattern: "../../node_modules/@waku/zerokit-rln-wasm-utils/*.wasm", + included: false, + served: true, + watched: false, + type: "wasm", + nocache: true } ], + exclude: process.env.CI ? ["src/**/*.integration.spec.ts"] : [], + preprocessors: { "src/**/*.spec.ts": ["webpack"] }, @@ -82,6 +92,12 @@ module.exports = function (config) { __dirname, "../../node_modules/@waku/zerokit-rln-wasm/rln_wasm_bg.wasm" ), + "/base/rln_wasm_utils_bg.wasm": + "/absolute" + + path.resolve( + __dirname, + "../../node_modules/@waku/zerokit-rln-wasm-utils/rln_wasm_utils_bg.wasm" + ), "/base/rln.wasm": "/absolute" + path.resolve(__dirname, "src/resources/rln.wasm"), "/base/rln_final.zkey": diff --git a/packages/rln/package.json b/packages/rln/package.json index 9eb5beaecd..211703506a 100644 --- a/packages/rln/package.json +++ b/packages/rln/package.json @@ -39,6 +39,8 @@ "check:lint": "eslint \"src/!(resources)/**/*.{ts,js}\" *.js", "check:spelling": "cspell \"{README.md,src/**/*.ts}\"", "test": "NODE_ENV=test run-s test:*", + "test:unit": "NODE_ENV=test mocha 'src/**/*.spec.ts' --ignore 'src/**/*.integration.spec.ts'", + "test:integration": "NODE_ENV=test mocha 'src/**/*.integration.spec.ts'", "test:browser": "karma start karma.conf.cjs", "watch:build": "tsc -p tsconfig.json -w", "watch:test": "mocha --watch", @@ -60,7 +62,6 @@ "@types/sinon": "^17.0.3", "@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", @@ -83,6 +84,7 @@ "@waku/core": "^0.0.40", "@waku/utils": "^0.0.27", "@waku/zerokit-rln-wasm": "^0.2.1", + "@waku/zerokit-rln-wasm-utils": "^0.1.0", "chai": "^5.1.2", "chai-as-promised": "^8.0.1", "chai-spies": "^1.1.0", diff --git a/packages/rln/src/contract/proof.integration.spec.ts b/packages/rln/src/contract/proof.integration.spec.ts new file mode 100644 index 0000000000..740dd55c0a --- /dev/null +++ b/packages/rln/src/contract/proof.integration.spec.ts @@ -0,0 +1,205 @@ +import { expect } from "chai"; +import { type Address, createPublicClient, http } from "viem"; +import { lineaSepolia } from "viem/chains"; + +import { Keystore } from "../keystore/index.js"; +import { RLNInstance } from "../rln.js"; +import { BytesUtils } from "../utils/index.js"; +import { + calculateRateCommitment, + extractPathDirectionsFromProof, + MERKLE_TREE_DEPTH, + reconstructMerkleRoot +} from "../utils/merkle.js"; +import { TEST_KEYSTORE_DATA } from "../utils/test_keystore.js"; + +import { RLN_CONTRACT } from "./constants.js"; +import { RLNBaseContract } from "./rln_base_contract.js"; + +describe("RLN Proof Integration Tests", function () { + this.timeout(30000); + + let rpcUrl: string; + + before(async function () { + this.timeout(10000); // Allow time for WASM initialization + + // Initialize WASM module before running tests + await RLNInstance.create(); + + rpcUrl = process.env.RPC_URL || "https://rpc.sepolia.linea.build"; + + if (!rpcUrl) { + console.log( + "Skipping integration tests - RPC_URL environment variable not set" + ); + console.log( + "To run these tests, set RPC_URL to a Linea Sepolia RPC endpoint" + ); + this.skip(); + } + }); + + it("get merkle proof from contract, construct rln proof, verify rln proof", async function () { + // Load the test keystore from constant (browser-compatible) + const keystore = Keystore.fromString(TEST_KEYSTORE_DATA.keystoreJson); + if (!keystore) { + throw new Error("Failed to load test keystore"); + } + + // Use the known credential hash and password from the test data + const credentialHash = TEST_KEYSTORE_DATA.credentialHash; + const password = TEST_KEYSTORE_DATA.password; + console.log(`Using credential hash: ${credentialHash}`); + const credential = await keystore.readCredential(credentialHash, password); + if (!credential) { + throw new Error("Failed to unlock credential with provided password"); + } + + // Extract the ID commitment from the credential + const idCommitment = credential.identity.IDCommitmentBigInt; + console.log(`ID Commitment from keystore: ${idCommitment.toString()}`); + + const publicClient = createPublicClient({ + chain: lineaSepolia, + transport: http(rpcUrl) + }); + + const dummyWalletClient = createPublicClient({ + chain: lineaSepolia, + transport: http(rpcUrl) + }) as any; + + const contract = await RLNBaseContract.create({ + address: RLN_CONTRACT.address as Address, + publicClient, + walletClient: dummyWalletClient + }); + + // First, get membership info to find the index + const membershipInfo = await contract.getMembershipInfo(idCommitment); + + if (!membershipInfo) { + console.log( + `ID commitment ${idCommitment.toString()} not found in membership set` + ); + this.skip(); + return; + } + + console.log(`Found membership at index: ${membershipInfo.index}`); + console.log(`Membership state: ${membershipInfo.state}`); + + // Get the merkle proof for this member's index + const merkleProof = await contract.getMerkleProof(membershipInfo.index); + + expect(merkleProof).to.be.an("array"); + expect(merkleProof).to.have.lengthOf(MERKLE_TREE_DEPTH); // RLN uses fixed depth merkle tree + + console.log(`Merkle proof for ID commitment ${idCommitment.toString()}:`); + console.log(`Index: ${membershipInfo.index}`); + console.log(`Proof elements (${merkleProof.length}):`); + merkleProof.forEach((element, i) => { + console.log( + ` [${i}]: ${element.toString()} (0x${element.toString(16)})` + ); + }); + + // Verify all proof elements are valid bigints + merkleProof.forEach((element, i) => { + expect(element).to.be.a( + "bigint", + `Proof element ${i} should be a bigint` + ); + expect(element).to.not.equal(0n, `Proof element ${i} should not be zero`); + }); + }); + + it.only("should generate a valid RLN proof", async function () { + const publicClient = createPublicClient({ + chain: lineaSepolia, + transport: http(rpcUrl) + }); + + const dummyWalletClient = createPublicClient({ + chain: lineaSepolia, + transport: http(rpcUrl) + }) as any; + + const contract = await RLNBaseContract.create({ + address: RLN_CONTRACT.address as Address, + publicClient, + walletClient: dummyWalletClient + }); + // get credential from keystore + const keystore = Keystore.fromString(TEST_KEYSTORE_DATA.keystoreJson); + if (!keystore) { + throw new Error("Failed to load test keystore"); + } + const credentialHash = TEST_KEYSTORE_DATA.credentialHash; + const password = TEST_KEYSTORE_DATA.password; + const credential = await keystore.readCredential(credentialHash, password); + if (!credential) { + throw new Error("Failed to unlock credential with provided password"); + } + const idCommitment = credential.identity.IDCommitmentBigInt; + const membershipInfo = await contract.getMembershipInfo(idCommitment); + if (!membershipInfo) { + throw new Error("Failed to get membership info"); + } + const rateLimit = BigInt(membershipInfo.rateLimit); + + const merkleProof = await contract.getMerkleProof(membershipInfo.index); + const merkleRoot = await contract.getMerkleRoot(); + const rateCommitment = calculateRateCommitment(idCommitment, rateLimit); + + // Get the array of indexes that correspond to each proof element + const proofElementIndexes = extractPathDirectionsFromProof( + merkleProof, + rateCommitment, + merkleRoot + ); + if (!proofElementIndexes) { + throw new Error("Failed to extract proof element indexes"); + } + + // Verify the array has the correct length + expect(proofElementIndexes).to.have.lengthOf(MERKLE_TREE_DEPTH); + + // Verify that we can reconstruct the root using these indexes + const reconstructedRoot = reconstructMerkleRoot( + merkleProof as bigint[], + BigInt(membershipInfo.index), + rateCommitment + ); + + expect(reconstructedRoot).to.equal( + merkleRoot, + "Reconstructed root should match contract root" + ); + + const testMessage = new TextEncoder().encode("test"); + const rlnInstance = await RLNInstance.create(); + + const proof = await rlnInstance.zerokit.generateRLNProof( + testMessage, + membershipInfo.index, + new Date(), + credential.identity.IDSecretHash, + merkleProof.map((proof) => BytesUtils.fromBigInt(proof, 32, "little")), + proofElementIndexes.map((index) => + BytesUtils.writeUIntLE(new Uint8Array(1), index, 0, 1) + ), + Number(rateLimit), + 0 + ); + + const isValid = rlnInstance.zerokit.verifyRLNProof( + BytesUtils.writeUIntLE(new Uint8Array(8), testMessage.length, 0, 8), + testMessage, + proof, + [BytesUtils.fromBigInt(merkleRoot, 32, "little")] + ); + expect(isValid).to.be.true; + }); +}); diff --git a/packages/rln/src/contract/rln_base_contract.ts b/packages/rln/src/contract/rln_base_contract.ts index c603dae247..75c4f7a4ef 100644 --- a/packages/rln/src/contract/rln_base_contract.ts +++ b/packages/rln/src/contract/rln_base_contract.ts @@ -3,18 +3,10 @@ import { type Address, decodeEventLog, getContract, -<<<<<<< HEAD type GetContractReturnType, type Hash, type PublicClient, type WalletClient -======= - GetContractEventsReturnType, - GetContractReturnType, - type Hash, - PublicClient, - WalletClient ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) } from "viem"; import { IdentityCredential } from "../identity.js"; @@ -27,11 +19,6 @@ import { RLN_CONTRACT } from "./constants.js"; import { -<<<<<<< HEAD -======= - FetchMembersOptions, - Member, ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) MembershipInfo, MembershipState, RLNContractOptions @@ -40,36 +27,20 @@ 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: GetContractReturnType< typeof wakuRlnV2Abi, PublicClient | WalletClient >; -<<<<<<< HEAD public rpcClient: RpcClient; -======= - public publicClient: PublicClient; - public walletClient: WalletClient; - private deployBlock: undefined | number; ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) private rateLimit: number; private minRateLimit?: number; private maxRateLimit?: number; -<<<<<<< HEAD -======= - protected _members: Map = new Map(); - ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) /** * Private constructor for RLNBaseContract. Use static create() instead. */ protected constructor(options: RLNContractOptions) { -<<<<<<< HEAD const { address, rpcClient, rateLimit = DEFAULT_RATE_LIMIT } = options; log.info("Initializing RLNBaseContract", { address, rateLimit }); @@ -81,34 +52,6 @@ export class RLNBaseContract { client: this.rpcClient }); this.rateLimit = rateLimit; -======= - const { - address, - publicClient, - walletClient, - rateLimit = DEFAULT_RATE_LIMIT - } = options; - - log.info("Initializing RLNBaseContract", { address, rateLimit }); - - this.publicClient = publicClient; - this.walletClient = walletClient; - this.contract = getContract({ - address, - abi: wakuRlnV2Abi, - client: { wallet: walletClient, public: publicClient } - }); - this.rateLimit = rateLimit; - - // Initialize members and subscriptions - this.fetchMembers() - .then(() => { - this.subscribeToMembers(); - }) - .catch((error) => { - log.error("Failed to initialize members", { error }); - }); ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) } /** @@ -119,11 +62,6 @@ export class RLNBaseContract { ): Promise { const instance = new RLNBaseContract(options); -<<<<<<< HEAD -======= - instance.deployBlock = await instance.contract.read.deployedBlockNumber(); - ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) const [min, max] = await Promise.all([ instance.contract.read.minMembershipRateLimit(), instance.contract.read.maxMembershipRateLimit() @@ -211,7 +149,6 @@ export class RLNBaseContract { */ public async getMerkleRoot(): Promise { return this.contract.read.root(); -<<<<<<< HEAD } /** @@ -222,156 +159,6 @@ export class RLNBaseContract { */ public async getMerkleProof(index: number): Promise { return await this.contract.read.getMerkleProof([index]); -======= - } - - /** - * Gets the Merkle proof for a member at a given index - * @param index The index of the member in the membership set - * @returns Promise Array of 20 Merkle proof elements - * - */ - public async getMerkleProof(index: number): Promise { - return await this.contract.read.getMerkleProof([index]); - } - - public get members(): Member[] { - const sortedMembers = Array.from(this._members.values()).sort( - (left, right) => Number(left.index) - Number(right.index) - ); - return sortedMembers; - } - - public async fetchMembers(options: FetchMembersOptions = {}): Promise { - 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, - toBlock: fromBlock + BigInt(options.fetchRange!) - } - ); - - const events = [ - ...registeredMemberEvents, - ...removedMemberEvents, - ...expiredMemberEvents - ]; - this.processEvents(events); - } - - public processEvents(events: MembershipEvents): void { - const toRemoveTable = new Map(); - const toInsertTable = new Map(); - - events.forEach((evt) => { - if (!evt.args) { - return; - } - const blockNumber = Number(evt.blockNumber); - if ( - evt.eventName === "MembershipErased" || - evt.eventName === "MembershipExpired" - ) { - const index = evt.args.index; - - if (!index) { - return; - } - - const toRemoveVal = toRemoveTable.get(blockNumber); - if (toRemoveVal != undefined) { - toRemoveVal.push(index); - toRemoveTable.set(blockNumber, toRemoveVal); - } else { - toRemoveTable.set(blockNumber, [index]); - } - } else if (evt.eventName === "MembershipRegistered") { - let eventsPerBlock = toInsertTable.get(blockNumber); - if (eventsPerBlock == undefined) { - eventsPerBlock = []; - } - - eventsPerBlock.push(evt); - toInsertTable.set(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(array: T[], size: number): Iterable { - let start = 0; - - while (start < array.length) { - const portion = array.slice(start, start + size); - - yield portion; - - start += size; - } - } - - public static async ignoreErrors( - promise: Promise, - defaultValue: T - ): Promise { - 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.watchEvent.MembershipRegistered({ - onLogs: (logs) => { - this.processEvents(logs); - } - }); - this.contract.watchEvent.MembershipExpired({ - onLogs: (logs) => { - this.processEvents(logs); - } - }); - this.contract.watchEvent.MembershipErased({ - onLogs: (logs) => { - this.processEvents(logs); - } - }); ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) } public async getMembershipInfo( @@ -382,11 +169,7 @@ export class RLNBaseContract { idCommitmentBigInt ]); -<<<<<<< HEAD const currentBlock = await this.rpcClient.getBlockNumber(); -======= - const currentBlock = await this.publicClient.getBlockNumber(); ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) const [ depositAmount, @@ -431,24 +214,15 @@ export class RLNBaseContract { } public async extendMembership(idCommitmentBigInt: bigint): Promise { -<<<<<<< HEAD if (!this.rpcClient.account) { -======= - if (!this.walletClient.account) { ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) throw new Error( "Failed to extendMembership: no account set in wallet client" ); } try { await this.contract.simulate.extendMemberships([[idCommitmentBigInt]], { -<<<<<<< HEAD chain: this.rpcClient.chain, account: this.rpcClient.account.address -======= - chain: this.walletClient.chain, - account: this.walletClient.account!.address ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) }); } catch (err) { throw new Error("Simulating extending membership failed: " + err); @@ -456,21 +230,12 @@ export class RLNBaseContract { const hash = await this.contract.write.extendMemberships( [[idCommitmentBigInt]], { -<<<<<<< HEAD account: this.rpcClient.account, chain: this.rpcClient.chain } ); await this.rpcClient.waitForTransactionReceipt({ hash }); -======= - account: this.walletClient.account!, - chain: this.walletClient.chain - } - ); - - await this.publicClient.waitForTransactionReceipt({ hash }); ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) return hash; } @@ -484,11 +249,7 @@ export class RLNBaseContract { ) { throw new Error("Membership is not expired or in grace period"); } -<<<<<<< HEAD if (!this.rpcClient.account) { -======= - if (!this.walletClient.account) { ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) throw new Error( "Failed to eraseMembership: no account set in wallet client" ); @@ -498,13 +259,8 @@ export class RLNBaseContract { await this.contract.simulate.eraseMemberships( [[idCommitmentBigInt], eraseFromMembershipSet], { -<<<<<<< HEAD chain: this.rpcClient.chain, account: this.rpcClient.account.address -======= - chain: this.walletClient.chain, - account: this.walletClient.account!.address ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) } ); } catch (err) { @@ -514,19 +270,11 @@ export class RLNBaseContract { const hash = await this.contract.write.eraseMemberships( [[idCommitmentBigInt], eraseFromMembershipSet], { -<<<<<<< HEAD chain: this.rpcClient.chain, account: this.rpcClient.account } ); await this.rpcClient.waitForTransactionReceipt({ hash }); -======= - chain: this.walletClient.chain, - account: this.walletClient.account! - } - ); - await this.publicClient.waitForTransactionReceipt({ hash }); ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) return hash; } @@ -542,11 +290,7 @@ export class RLNBaseContract { `Rate limit must be between ${RATE_LIMIT_PARAMS.MIN_RATE} and ${RATE_LIMIT_PARAMS.MAX_RATE}` ); } -<<<<<<< HEAD if (!this.rpcClient.account) { -======= - if (!this.walletClient.account) { ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) throw new Error( "Failed to registerMembership: no account set in wallet client" ); @@ -555,13 +299,8 @@ export class RLNBaseContract { await this.contract.simulate.register( [idCommitmentBigInt, rateLimit, []], { -<<<<<<< HEAD chain: this.rpcClient.chain, account: this.rpcClient.account.address -======= - chain: this.walletClient.chain, - account: this.walletClient.account!.address ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) } ); } catch (err) { @@ -571,24 +310,15 @@ export class RLNBaseContract { const hash = await this.contract.write.register( [idCommitmentBigInt, rateLimit, []], { -<<<<<<< HEAD chain: this.rpcClient.chain, account: this.rpcClient.account } ); await this.rpcClient.waitForTransactionReceipt({ hash }); -======= - chain: this.walletClient.chain, - account: this.walletClient.account! - } - ); - await this.publicClient.waitForTransactionReceipt({ hash }); ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) return hash; } /** -<<<<<<< HEAD * 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. @@ -596,45 +326,24 @@ export class RLNBaseContract { */ public async withdraw(token: string): Promise { if (!this.rpcClient.account) { -======= - * 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 { - if (!this.walletClient.account) { ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) throw new Error("Failed to withdraw: no account set in wallet client"); } try { await this.contract.simulate.withdraw([token as Address], { -<<<<<<< HEAD chain: this.rpcClient.chain, account: this.rpcClient.account.address -======= - chain: this.walletClient.chain, - account: this.walletClient.account!.address ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) }); } catch (err) { throw new Error("Error simulating withdraw: " + err); } const hash = await this.contract.write.withdraw([token as Address], { -<<<<<<< HEAD chain: this.rpcClient.chain, account: this.rpcClient.account }); await this.rpcClient.waitForTransactionReceipt({ hash }); -======= - chain: this.walletClient.chain, - account: this.walletClient.account! - }); - - await this.publicClient.waitForTransactionReceipt({ hash }); ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) return hash; } public async registerWithIdentity( @@ -672,20 +381,14 @@ export class RLNBaseContract { await this.contract.simulate.register( [identity.IDCommitmentBigInt, this.rateLimit, []], { -<<<<<<< HEAD chain: this.rpcClient.chain, account: this.rpcClient.account.address -======= - chain: this.walletClient.chain, - account: this.walletClient.account!.address ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) } ); const hash: Hash = await this.contract.write.register( [identity.IDCommitmentBigInt, this.rateLimit, []], { -<<<<<<< HEAD chain: this.rpcClient.chain, account: this.rpcClient.account } @@ -694,17 +397,6 @@ export class RLNBaseContract { const txRegisterReceipt = await this.rpcClient.waitForTransactionReceipt({ hash }); -======= - chain: this.walletClient.chain, - account: this.walletClient.account! - } - ); - - const txRegisterReceipt = - await this.publicClient.waitForTransactionReceipt({ - hash - }); ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) if (txRegisterReceipt.status === "reverted") { throw new Error("Transaction failed on-chain"); @@ -735,7 +427,6 @@ export class RLNBaseContract { const decoded = decodeEventLog({ abi: wakuRlnV2Abi, data: memberRegisteredLog.data, -<<<<<<< HEAD topics: memberRegisteredLog.topics, eventName: "MembershipRegistered" }); @@ -743,35 +434,15 @@ export class RLNBaseContract { log.info( `Successfully registered membership with index ${decoded.args.index} ` + `and rate limit ${decoded.args.membershipRateLimit}` -======= - topics: memberRegisteredLog.topics - }); - - const decodedArgs = decoded.args as { - idCommitment: bigint; - membershipRateLimit: number; - index: number; - }; - - log.info( - `Successfully registered membership with index ${decodedArgs.index} ` + - `and rate limit ${decodedArgs.membershipRateLimit}` ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) ); return { identity, membership: { address: this.contract.address, -<<<<<<< HEAD treeIndex: decoded.args.index, chainId: String(RLN_CONTRACT.chainId), rateLimit: Number(decoded.args.membershipRateLimit) -======= - treeIndex: decodedArgs.index, - chainId: String(RLN_CONTRACT.chainId), - rateLimit: decodedArgs.membershipRateLimit ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) } }; } catch (error) { @@ -820,10 +491,7 @@ export class RLNBaseContract { } private async getMemberIndex(idCommitmentBigInt: bigint): Promise { -<<<<<<< HEAD // Current version of the contract has the index at position 5 in the membership struct -======= ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) return (await this.contract.read.memberships([idCommitmentBigInt]))[5]; } @@ -878,11 +546,7 @@ export class RLNBaseContract { price: bigint | null; }> { const address = await this.contract.read.priceCalculator(); -<<<<<<< HEAD const [token, price] = await this.rpcClient.readContract({ -======= - const [token, price] = await this.publicClient.readContract({ ->>>>>>> a88dd8cdbd (feat: migrate rln from ethers to viem) address, abi: iPriceCalculatorAbi, functionName: "calculate", diff --git a/packages/rln/src/rln.ts b/packages/rln/src/rln.ts index 491fd14d44..739dcefd3a 100644 --- a/packages/rln/src/rln.ts +++ b/packages/rln/src/rln.ts @@ -1,5 +1,6 @@ import { Logger } from "@waku/utils"; import init, * as zerokitRLN from "@waku/zerokit-rln-wasm"; +import initUtils from "@waku/zerokit-rln-wasm-utils"; import { DEFAULT_RATE_LIMIT } from "./contract/constants.js"; import { RLNCredentialsManager } from "./credentials_manager.js"; @@ -16,6 +17,7 @@ export class RLNInstance extends RLNCredentialsManager { */ public static async create(): Promise { try { + await initUtils(); await init(); zerokitRLN.initPanicHook(); diff --git a/packages/rln/src/utils/bytes.ts b/packages/rln/src/utils/bytes.ts index 4df17bd380..296ced4dd8 100644 --- a/packages/rln/src/utils/bytes.ts +++ b/packages/rln/src/utils/bytes.ts @@ -49,6 +49,43 @@ export class BytesUtils { return result; } + /** + * Convert a BigInt to a Uint8Array with configurable output endianness + * @param value - The BigInt to convert + * @param byteLength - The desired byte length of the output (optional, auto-calculated if not provided) + * @param outputEndianness - Endianness of the output bytes ('big' or 'little') + * @returns Uint8Array representation of the BigInt + */ + public static fromBigInt( + value: bigint, + byteLength: number, + outputEndianness: "big" | "little" = "little" + ): Uint8Array { + if (value < 0n) { + throw new Error("Cannot convert negative BigInt to bytes"); + } + + if (value === 0n) { + return new Uint8Array(byteLength); + } + + const result = new Uint8Array(byteLength); + let workingValue = value; + + // Extract bytes in big-endian order + for (let i = byteLength - 1; i >= 0; i--) { + result[i] = Number(workingValue & 0xffn); + workingValue = workingValue >> 8n; + } + + // If we need little-endian output, reverse the array + if (outputEndianness === "little") { + result.reverse(); + } + + return result; + } + /** * Writes an unsigned integer to a buffer in little-endian format */ diff --git a/packages/rln/src/utils/epoch.ts b/packages/rln/src/utils/epoch.ts index 19b2f81108..bf89d40aa5 100644 --- a/packages/rln/src/utils/epoch.ts +++ b/packages/rln/src/utils/epoch.ts @@ -1,5 +1,7 @@ import { Logger } from "@waku/utils"; +import { BytesUtils } from "./bytes.js"; + const DefaultEpochUnitSeconds = 10; // the rln-relay epoch length in seconds const log = new Logger("rln:epoch"); @@ -15,11 +17,7 @@ export function dateToEpoch( } export function epochIntToBytes(epoch: number): Uint8Array { - const bytes = new Uint8Array(32); - const db = new DataView(bytes.buffer); - db.setUint32(0, epoch, true); - log.info("encoded epoch", epoch, bytes); - return bytes; + return BytesUtils.writeUIntLE(new Uint8Array(32), epoch, 0, 32); } export function epochBytesToInt(bytes: Uint8Array): number { diff --git a/packages/rln/src/utils/hash.ts b/packages/rln/src/utils/hash.ts index 6aa8e29277..43908ce45d 100644 --- a/packages/rln/src/utils/hash.ts +++ b/packages/rln/src/utils/hash.ts @@ -1,4 +1,4 @@ -import * as zerokitRLN from "@waku/zerokit-rln-wasm"; +import { hash, poseidonHash as poseidon } from "@waku/zerokit-rln-wasm-utils"; import { BytesUtils } from "./bytes.js"; @@ -10,16 +10,9 @@ export function poseidonHash(...input: Array): Uint8Array { 8 ); const lenPrefixedData = BytesUtils.concatenate(inputLen, ...input); - return zerokitRLN.poseidonHash(lenPrefixedData); + return poseidon(lenPrefixedData, true); } export function sha256(input: Uint8Array): Uint8Array { - const inputLen = BytesUtils.writeUIntLE( - new Uint8Array(8), - input.length, - 0, - 8 - ); - const lenPrefixedData = BytesUtils.concatenate(inputLen, input); - return zerokitRLN.hash(lenPrefixedData); + return hash(input, true); } diff --git a/packages/rln/src/utils/merkle.ts b/packages/rln/src/utils/merkle.ts new file mode 100644 index 0000000000..2080c74439 --- /dev/null +++ b/packages/rln/src/utils/merkle.ts @@ -0,0 +1,179 @@ +import { BytesUtils } from "./bytes.js"; +import { poseidonHash } from "./hash.js"; + +/** + * The fixed depth of the Merkle tree used in the RLN contract + * This is a constant that will never change for the on-chain implementation + */ +export const MERKLE_TREE_DEPTH = 20; + +/** + * Reconstructs a Merkle tree root from a proof and leaf information + * + * @param proof - Array of MERKLE_TREE_DEPTH bigint elements representing the Merkle proof + * @param leafIndex - The index of the leaf in the tree (used to determine left/right positioning) + * @param leafValue - The value of the leaf (typically the rate commitment) + * @returns The reconstructed root as a bigint + */ +export function reconstructMerkleRoot( + proof: readonly bigint[], + leafIndex: bigint, + leafValue: bigint +): bigint { + if (proof.length !== MERKLE_TREE_DEPTH) { + throw new Error( + `Expected proof of length ${MERKLE_TREE_DEPTH}, got ${proof.length}` + ); + } + + let currentValue = leafValue; + + // Process each level of the tree (0 to MERKLE_TREE_DEPTH-1) + for (let level = 0; level < MERKLE_TREE_DEPTH; level++) { + // Check if bit `level` is set in the leaf index + const bit = (leafIndex >> BigInt(level)) & 1n; + + // Convert bigints to Uint8Array for hashing + const currentBytes = bigIntToBytes32(currentValue); + const proofBytes = bigIntToBytes32(proof[level]); + + let hashResult: Uint8Array; + + if (bit === 0n) { + // Current node is a left child: hash(current, proof[level]) + hashResult = poseidonHash(currentBytes, proofBytes); + } else { + // Current node is a right child: hash(proof[level], current) + hashResult = poseidonHash(proofBytes, currentBytes); + } + + // Convert hash result back to bigint for next iteration + currentValue = BytesUtils.toBigInt(hashResult, "little"); + } + + return currentValue; +} + +/** + * Extracts index information from a Merkle proof by attempting to reconstruct + * the root with different possible indices and comparing against the expected root + * + * @param proof - Array of MERKLE_TREE_DEPTH bigint elements representing the Merkle proof + * @param leafValue - The value of the leaf (typically the rate commitment) + * @param expectedRoot - The expected root to match against + * @param maxIndex - Maximum index to try (default: 2^MERKLE_TREE_DEPTH - 1) + * @returns The index that produces the expected root, or null if not found + */ +function extractIndexFromProof( + proof: readonly bigint[], + leafValue: bigint, + expectedRoot: bigint, + maxIndex: bigint = (1n << BigInt(MERKLE_TREE_DEPTH)) - 1n +): bigint | null { + // Try different indices to see which one produces the expected root + for (let index = 0n; index <= maxIndex; index++) { + try { + const reconstructedRoot = reconstructMerkleRoot(proof, index, leafValue); + if (reconstructedRoot === expectedRoot) { + return index; + } + } catch (error) { + // Continue trying other indices if reconstruction fails + continue; + } + } + + return null; +} + +/** + * Calculates the rate commitment from an ID commitment and rate limit + * This matches the contract's calculation: PoseidonT3.hash([idCommitment, rateLimit]) + * + * @param idCommitment - The identity commitment as a bigint + * @param rateLimit - The rate limit as a bigint + * @returns The rate commitment as a bigint + */ +export function calculateRateCommitment( + idCommitment: bigint, + rateLimit: bigint +): bigint { + const idBytes = bigIntToBytes32(idCommitment); + const rateLimitBytes = bigIntToBytes32(rateLimit); + + const hashResult = poseidonHash(idBytes, rateLimitBytes); + return BytesUtils.toBigInt(hashResult, "little"); +} + +/** + * Converts a bigint to a 32-byte Uint8Array in little-endian format + * + * @param value - The bigint value to convert + * @returns 32-byte Uint8Array representation + */ +function bigIntToBytes32(value: bigint): Uint8Array { + const bytes = new Uint8Array(32); + let temp = value; + + for (let i = 0; i < 32; i++) { + bytes[i] = Number(temp & 0xffn); + temp >>= 8n; + } + + return bytes; +} + +/** + * Extracts the path direction bits from a Merkle proof by finding the leaf index + * that produces the expected root, then converting that index to path directions + * + * @param proof - Array of MERKLE_TREE_DEPTH bigint elements representing the Merkle proof + * @param leafValue - The value of the leaf (typically the rate commitment) + * @param expectedRoot - The expected root to match against + * @param maxIndex - Maximum index to try (default: 2^MERKLE_TREE_DEPTH - 1) + * @returns Array of MERKLE_TREE_DEPTH numbers (0 or 1) representing path directions, or null if no valid path found + * - 0 means the node is a left child (hash order: current, sibling) + * - 1 means the node is a right child (hash order: sibling, current) + */ +export function extractPathDirectionsFromProof( + proof: readonly bigint[], + leafValue: bigint, + expectedRoot: bigint, + maxIndex: bigint = (1n << BigInt(MERKLE_TREE_DEPTH)) - 1n +): number[] | null { + // First, find the leaf index that produces the expected root + const leafIndex = extractIndexFromProof( + proof, + leafValue, + expectedRoot, + maxIndex + ); + + if (leafIndex === null) { + return null; + } + + // Convert the leaf index to path directions + return getPathDirectionsFromIndex(leafIndex); +} + +/** + * Converts a leaf index to an array of path direction bits + * + * @param leafIndex - The index of the leaf in the tree + * @returns Array of MERKLE_TREE_DEPTH numbers (0 or 1) representing path directions + * - 0 means the node is a left child (hash order: current, sibling) + * - 1 means the node is a right child (hash order: sibling, current) + */ +function getPathDirectionsFromIndex(leafIndex: bigint): number[] { + const pathDirections: number[] = []; + + // For each level (0 to MERKLE_TREE_DEPTH-1), extract the bit that determines left/right + for (let level = 0; level < MERKLE_TREE_DEPTH; level++) { + // Check if bit `level` is set in the leaf index + const bit = (leafIndex >> BigInt(level)) & 1n; + pathDirections.push(Number(bit)); + } + + return pathDirections; +} diff --git a/packages/rln/src/utils/test_keystore.ts b/packages/rln/src/utils/test_keystore.ts new file mode 100644 index 0000000000..e5c5960f9a --- /dev/null +++ b/packages/rln/src/utils/test_keystore.ts @@ -0,0 +1,7 @@ +export const TEST_KEYSTORE_DATA = { + keystoreJson: + '{"application":"waku-rln-relay","appIdentifier":"01234567890abcdef","version":"0.2","credentials":{"E0A8AC077B95F64C1B2C4B116468B22EFA3B1CFF250069AE07422F645BAA555E":{"crypto":{"cipher":"aes-128-ctr","cipherparams":{"iv":"96aff104d7bb23cefb57a4c5e816a3b9"},"ciphertext":"1ae2c7a47274d12d6a4b439da48abfa89be29e4ba3308d153e2e808d3e120cc85da472ab1e0278c945231092162d31d753ecb48484ac0c3a7efe6380d08f5dedecc9cda26bd156a30d232b9da4313c5ec92b21cd3dc3ca03cff68afde94a063799b658cc3e4a5c648e620d584a8a184d2d473e3e94c897e21e0de7580639dcf40c0133f36896ac5bee2dd5fe8810a5441e31e1938ecc4b195db57c1b6d320a374508406dfb7a4879081b70100140515b4c6c551f25f9b4c9a7214ac2dc222410bf74666407343dfd4af477c85cf2f316bb7a512a88948d88f5474374563d51d02c13eede6b6cf64fab7991e529157d7de39033099d26f323d9710159b47d2511695b4fb428e3b02c760e1470a3ece712c6a03692d067e0e17930bc25ce7dc4ad2634e07ef51fa7369de6b4d495c7ae1d8ad8dccdd2fa12802db4203c527887adf5eb42e2551e120b8a455892d0ac9369faf708465a983c03c7c8f77c268f85cacc7b718a1e9e2800b160ca1f7a78f2c160cbc97396f5dfe0e0f3b35addb4f8d667021c79eec5248122d8c983075b9e8ca20679e90a12bdbeefb33df21523b4e1ea7ab57ddc706b43bf4827fbc3530d20cb906468af5c5c31ac08815f3ed1d00341be7e287a3fb7ef67aecf2e56f694c51ba6db8641ac873e26659c92a8527c42df2d5ac15ff6201bdfa8a5ee34b6a90ff864fba89370a8c51efcb4ed1b69f3ed0e37ee97c66eb84763f107e1214e088e3149b2433a8da595293343b2290b0a84b7f796b70005d1672446d98d45da7c89c3eb8d91ece94ee41099f9f43c6810ce71d9f75ac3dffe1de0c79e40baad486ecaefbd0cc0e89aed7e0a16ea271a371d3f5927a1c7b813608de5715692e58322260a4bcd4ccba4b2376df01f58645c16a7b37c8473b94c7577ae774e5c72132ed15507ab2027ddabf137aa417b134b653eda247314","kdf":"pbkdf2","kdfparams":{"dklen":32,"c":262144,"prf":"hmac-sha256","salt":"5f2081f089e9e277873bf1f538c60d714749a2bb910d8f1ed119d8d403235a8c"},"mac":"8d0667893b7d3b5f0b37c43edef616a8d295dc58292c98655eec8b5fe2ad69c3"}}}}', + credentialHash: + "E0A8AC077B95F64C1B2C4B116468B22EFA3B1CFF250069AE07422F645BAA555E", + password: "12345678" +}; diff --git a/packages/rln/src/zerokit.ts b/packages/rln/src/zerokit.ts index 47df182f00..ca743fec86 100644 --- a/packages/rln/src/zerokit.ts +++ b/packages/rln/src/zerokit.ts @@ -1,14 +1,21 @@ import * as zerokitRLN from "@waku/zerokit-rln-wasm"; +import { generateSeededExtendedMembershipKey } from "@waku/zerokit-rln-wasm-utils"; -import { DEFAULT_RATE_LIMIT } from "./contract/constants.js"; +import { DEFAULT_RATE_LIMIT, RATE_LIMIT_PARAMS } from "./contract/constants.js"; import { IdentityCredential } from "./identity.js"; import { WitnessCalculator } from "./resources/witness_calculator"; +import { BytesUtils } from "./utils/bytes.js"; +import { dateToEpoch, epochIntToBytes } from "./utils/epoch.js"; +import { poseidonHash, sha256 } from "./utils/hash.js"; export class Zerokit { public constructor( private readonly zkRLN: number, private readonly witnessCalculator: WitnessCalculator, - private readonly _rateLimit: number = DEFAULT_RATE_LIMIT + private readonly _rateLimit: number = DEFAULT_RATE_LIMIT, + private readonly rlnIdentifier: Uint8Array = new TextEncoder().encode( + "rln/waku-rln-relay/v2.0.0" + ) ) {} public get getZkRLN(): number { @@ -26,10 +33,117 @@ export class Zerokit { public generateSeededIdentityCredential(seed: string): IdentityCredential { const stringEncoder = new TextEncoder(); const seedBytes = stringEncoder.encode(seed); - const memKeys = zerokitRLN.generateSeededExtendedMembershipKey( - this.zkRLN, - seedBytes - ); + const memKeys = generateSeededExtendedMembershipKey(seedBytes, true); return IdentityCredential.fromBytes(memKeys); } + + public async serializeWitness( + idSecretHash: Uint8Array, + pathElements: Uint8Array[], + identityPathIndex: Uint8Array[], + x: Uint8Array, + epoch: Uint8Array, + rateLimit: number, + messageId: number // number of message sent by the user in this epoch + ): Promise { + const externalNullifier = poseidonHash( + sha256(epoch), + sha256(this.rlnIdentifier) + ); + const pathElementsBytes = new Uint8Array(8 + pathElements.length * 32); + BytesUtils.writeUIntLE(pathElementsBytes, pathElements.length, 0, 8); + for (let i = 0; i < pathElements.length; i++) { + // We assume that the path elements are already in little-endian format + pathElementsBytes.set(pathElements[i], 8 + i * 32); + } + const identityPathIndexBytes = new Uint8Array( + 8 + identityPathIndex.length * 1 + ); + BytesUtils.writeUIntLE( + identityPathIndexBytes, + identityPathIndex.length, + 0, + 8 + ); + for (let i = 0; i < identityPathIndex.length; i++) { + // We assume that each identity path index is already in little-endian format + identityPathIndexBytes.set(identityPathIndex[i], 8 + i * 1); + } + return BytesUtils.concatenate( + idSecretHash, + BytesUtils.writeUIntLE(new Uint8Array(32), rateLimit, 0, 32), + BytesUtils.writeUIntLE(new Uint8Array(32), messageId, 0, 32), + pathElementsBytes, + identityPathIndexBytes, + x, + externalNullifier + ); + } + + public async generateRLNProof( + msg: Uint8Array, + index: number, // index of the leaf in the merkle tree + epoch: Uint8Array | Date | undefined, + idSecretHash: Uint8Array, + pathElements: Uint8Array[], + identityPathIndex: Uint8Array[], + rateLimit: number, + messageId: number // number of message sent by the user in this epoch + ): Promise { + if (epoch === undefined) { + epoch = epochIntToBytes(dateToEpoch(new Date())); + } else if (epoch instanceof Date) { + epoch = epochIntToBytes(dateToEpoch(epoch)); + } + + if (epoch.length !== 32) throw new Error("invalid epoch"); + if (idSecretHash.length !== 32) throw new Error("invalid id secret hash"); + if (index < 0) throw new Error("index must be >= 0"); + if ( + rateLimit < RATE_LIMIT_PARAMS.MIN_RATE || + rateLimit > RATE_LIMIT_PARAMS.MAX_RATE + ) { + throw new Error( + `Rate limit must be between ${RATE_LIMIT_PARAMS.MIN_RATE} and ${RATE_LIMIT_PARAMS.MAX_RATE}` + ); + } + + const x = sha256(msg); + + const serializedWitness = await this.serializeWitness( + idSecretHash, + pathElements, + identityPathIndex, + x, + epoch, + rateLimit, + messageId + ); + const witnessJson: Record = zerokitRLN.rlnWitnessToJson( + this.zkRLN, + serializedWitness + ) as Record; + const calculatedWitness: bigint[] = + await this.witnessCalculator.calculateWitness(witnessJson); + return zerokitRLN.generateRLNProofWithWitness( + this.zkRLN, + calculatedWitness, + serializedWitness + ); + } + + public verifyRLNProof( + signalLength: Uint8Array, + signal: Uint8Array, + proof: Uint8Array, + roots: Uint8Array[] + ): boolean { + if (signalLength.length !== 8) + throw new Error("signalLength must be 8 bytes"); + return zerokitRLN.verifyWithRoots( + this.zkRLN, + BytesUtils.concatenate(proof, signalLength, signal), + BytesUtils.concatenate(...roots) + ); + } }