Skip to content

Conversation

@adklempner
Copy link
Member

@adklempner adklempner commented Dec 2, 2025

Problem / Description

The RLN contracts and zerokit libraries have been updated to work in a stateless manner, where maintaining a local merkle tree of memberships is no longer necessary; instead, the smart contracts provide a function for getting the latest root of the on-chain tree along with a proof for a given identity commitment in that tree.

The RLN package needs to be updated to use this functionality for generating and verifying RLN proofs in the browser.

Solution

  • Update zerokit WASM dependencies
  • Update library functions used to call the WASM functions (assumes most inputs have been serialized to bytes)
  • Add static membership keystore and root/proof for testing (based on actual membership registered in deployed contract)
  • Test to generate and validate RLN proof using above membership

Notes


Checklist

  • Code changes are covered by unit tests.
  • Code changes are covered by e2e tests, if applicable.
  • Dogfooding has been performed, if feasible.
  • A test version has been published, if required.
  • All CI checks pass successfully.

@github-actions
Copy link

github-actions bot commented Dec 2, 2025

size-limit report 📦

Path Size Loading time (3g) Running time (snapdragon) Total time
Waku node 96.24 KB (0%) 2 s (0%) 823 ms (-21.63% 🔽) 2.8 s
Waku Simple Light Node 147.6 KB (0%) 3 s (0%) 872 ms (+1.74% 🔺) 3.9 s
ECIES encryption 22.62 KB (0%) 453 ms (0%) 252 ms (-24.73% 🔽) 705 ms
Symmetric encryption 22 KB (0%) 440 ms (0%) 320 ms (-15.35% 🔽) 760 ms
DNS discovery 52.17 KB (0%) 1.1 s (0%) 662 ms (-21.2% 🔽) 1.8 s
Peer Exchange discovery 52.91 KB (0%) 1.1 s (0%) 511 ms (-14.21% 🔽) 1.6 s
Peer Cache Discovery 46.64 KB (0%) 933 ms (0%) 834 ms (+19.87% 🔺) 1.8 s
Privacy preserving protocols 77.31 KB (0%) 1.6 s (0%) 1.6 s (+172.83% 🔺) 3.1 s
Waku Filter 79.82 KB (0%) 1.6 s (0%) 1.2 s (-7.44% 🔽) 2.8 s
Waku LightPush 77.97 KB (0%) 1.6 s (0%) 838 ms (-0.5% 🔽) 2.4 s
History retrieval protocols 83.74 KB (0%) 1.7 s (0%) 925 ms (+21.51% 🔺) 2.6 s
Deterministic Message Hashing 28.98 KB (0%) 580 ms (0%) 397 ms (+27.03% 🔺) 977 ms

@adklempner adklempner force-pushed the feat/rln-proof-gen-verification branch from c6b6d03 to a15b352 Compare December 2, 2025 19:25
fix: update tests
fix: store merkle proof/root as constant, remove use of RPC in proof gen/verification test
@adklempner adklempner force-pushed the feat/rln-proof-gen-verification branch from a15b352 to 73a2c1d Compare December 2, 2025 19:31
@adklempner adklempner changed the title feat: implement proof generation and verification feat(rln): proof generation and verification with on-chain merkle proof/root Dec 2, 2025
@adklempner adklempner marked this pull request as ready for review December 2, 2025 23:30
public toString(): string {
return JSON.stringify(this.data);
// Custom replacer function to handle BigInt serialization
const bigIntReplacer = (_key: string, value: unknown): unknown => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: remove this duplication as there is literally the same function below

expect(merkleProof).to.be.an("array");
expect(merkleProof).to.have.lengthOf(MERKLE_TREE_DEPTH); // RLN uses fixed depth merkle tree

merkleProof.forEach((element, i) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: for loop would be better here

*/
public static async create(): Promise<RLNInstance> {
try {
await initUtils();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: how fast does it load?

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements stateless RLN proof generation and verification using on-chain Merkle proofs, eliminating the need to maintain a local Merkle tree. The implementation updates the zerokit WASM dependencies and adds comprehensive functionality for generating and verifying RLN proofs directly in the browser using smart contract-provided roots and proofs.

Key Changes:

  • Added generateRLNProof() and verifyRLNProof() methods to the Zerokit class for proof generation and verification
  • Implemented Merkle tree utilities for root reconstruction and path direction extraction from on-chain proofs
  • Updated hash utilities to use new zerokit-rln-wasm-utils package with simplified APIs
  • Fixed BigInt serialization in Keystore to handle JSON conversion properly

Reviewed changes

Copilot reviewed 11 out of 14 changed files in this pull request and generated 14 comments.

Show a summary per file
File Description
packages/rln/src/zerokit.ts Adds core proof generation/verification methods with witness serialization and external nullifier calculation
packages/rln/src/utils/merkle.ts New Merkle tree utilities for root reconstruction, rate commitment calculation, and path direction extraction
packages/rln/src/utils/hash.ts Updates hash functions to use new zerokit-rln-wasm-utils package
packages/rln/src/utils/bytes.ts Adds fromBigInt() method for converting BigInt to byte arrays with configurable endianness
packages/rln/src/utils/epoch.ts Simplifies epoch encoding using BytesUtils.writeUIntLE
packages/rln/src/proof.spec.ts Integration test validating end-to-end proof generation and verification with real keystore data
packages/rln/src/zerokit.browser.spec.ts Browser test for seeded identity credential generation consistency
packages/rln/src/utils/test_keystore.ts Static test data with membership keystore and on-chain Merkle proof/root
packages/rln/src/rln.ts Initializes new zerokit-rln-wasm-utils package
packages/rln/src/keystore/keystore.ts Fixes BigInt JSON serialization with custom replacer function
packages/rln/karma.conf.cjs Adds WASM file configuration for zerokit-rln-wasm-utils
packages/rln/package.json Adds zerokit-rln-wasm-utils dependency, removes unused @waku/interfaces
packages/rln/.mocharc.cjs Minor formatting fix (trailing newline)
package-lock.json Updates dependencies including viem, @wagmi/cli, and adds zerokit-rln-wasm-utils

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +138 to +143
export function extractPathDirectionsFromProof(
proof: readonly bigint[],
leafValue: bigint,
expectedRoot: bigint,
maxIndex: bigint = (1n << BigInt(MERKLE_TREE_DEPTH)) - 1n
): number[] | null {
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation mentions that the function can return null, but doesn't explain what a null return value means for the caller or what they should do when this happens. Consider adding guidance such as: "Returns null if no valid index can be found that produces the expected root, which may indicate an invalid proof or incorrect parameters."

Copilot uses AI. Check for mistakes.
Comment on lines +114 to +124
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;
}
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing validation for value parameter. The function should validate that the BigInt value fits within 32 bytes (i.e., value < 2n ** 256n). If a larger value is provided, the function will silently truncate it by only taking the lower 32 bytes, which could lead to incorrect results without any indication of error.

Copilot uses AI. Check for mistakes.
Comment on lines +59 to +87
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;
}
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing validation to ensure the BigInt value fits within the specified byteLength. If value requires more than byteLength bytes to represent, the function will silently truncate the higher-order bytes, leading to data loss without any error indication. Consider adding a check: if (value >= (1n << BigInt(byteLength * 8))) throw new Error("Value too large for specified byte length");

Copilot uses AI. Check for mistakes.
Number(membershipIndex),
new Date(),
credential.identity.IDSecretHash,
merkleProof.map((proof) => BytesUtils.fromBigInt(proof, 32, "little")),
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable name shadowing: the parameter name proof in the map callback shadows the outer variable merkleProof being mapped. This is confusing and could lead to errors. Consider renaming to: merkleProof.map((element) => BytesUtils.fromBigInt(element, 32, "little"))

Suggested change
merkleProof.map((proof) => BytesUtils.fromBigInt(proof, 32, "little")),
merkleProof.map((element) => BytesUtils.fromBigInt(element, 32, "little")),

Copilot uses AI. Check for mistakes.
leafValue: bigint,
expectedRoot: bigint,
maxIndex: bigint = (1n << BigInt(MERKLE_TREE_DEPTH)) - 1n
): bigint | null {
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing validation for maxIndex parameter. The function should validate that maxIndex >= 0 to prevent unexpected behavior with negative values. Although BigInt comparisons with negative values work, it's better to be explicit about the expected input range.

Suggested change
): bigint | null {
): bigint | null {
if (maxIndex < 0n) {
throw new Error(`maxIndex must be non-negative, got ${maxIndex}`);
}

Copilot uses AI. Check for mistakes.
epoch: Uint8Array,
rateLimit: number,
messageId: number // number of message sent by the user in this epoch
): Promise<Uint8Array> {
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing validation for x parameter. The function should validate that x.length === 32 since it represents a SHA-256 hash output. Incorrect length could lead to malformed witness data.

Suggested change
): Promise<Uint8Array> {
): Promise<Uint8Array> {
if (x.length !== 32) {
throw new Error("x must be a 32-byte SHA-256 hash");
}

Copilot uses AI. Check for mistakes.
const proofElementIndexes = extractPathDirectionsFromProof(
merkleProof,
rateCommitment,
merkleRoot
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function call to extractPathDirectionsFromProof does not pass the known membershipIndex as maxIndex parameter, which means it could potentially iterate through up to 2^20 (1,048,576) indices if the proof doesn't match early on. This could cause performance issues and test timeouts.

Consider passing the known index or a reasonable upper bound: extractPathDirectionsFromProof(merkleProof, rateCommitment, merkleRoot, membershipIndex + 100n) to limit the search space.

Suggested change
merkleRoot
merkleRoot,
membershipIndex + 100n

Copilot uses AI. Check for mistakes.
epoch: Uint8Array,
rateLimit: number,
messageId: number // number of message sent by the user in this epoch
): Promise<Uint8Array> {
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing validation for epoch parameter. The function should validate that epoch.length === 32 to ensure it represents a properly formatted epoch value. Incorrect length could lead to invalid external nullifier calculation.

Suggested change
): Promise<Uint8Array> {
): Promise<Uint8Array> {
if (!(epoch instanceof Uint8Array) || epoch.length !== 32) {
throw new Error("epoch must be a 32-byte Uint8Array");
}

Copilot uses AI. Check for mistakes.
Comment on lines +99 to +100
if (epoch.length !== 32) throw new Error("invalid epoch");
if (idSecretHash.length !== 32) throw new Error("invalid id secret hash");
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error messages "invalid epoch" and "invalid id secret hash" are not descriptive enough. They should specify the expected length. Consider: "Epoch must be 32 bytes, got ${epoch.length}" and "ID secret hash must be 32 bytes, got ${idSecretHash.length}"

Suggested change
if (epoch.length !== 32) throw new Error("invalid epoch");
if (idSecretHash.length !== 32) throw new Error("invalid id secret hash");
if (epoch.length !== 32) throw new Error(`Epoch must be 32 bytes, got ${epoch.length}`);
if (idSecretHash.length !== 32) throw new Error(`ID secret hash must be 32 bytes, got ${idSecretHash.length}`);

Copilot uses AI. Check for mistakes.
Comment on lines +67 to +87
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;
}
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance issue: extractIndexFromProof has O(2^20) time complexity by default, trying over 1 million indices sequentially. This is extremely inefficient and could cause significant delays or timeouts.

The function attempts to brute-force find the correct index by trying every possible value from 0 to 2^20-1 (1,048,576 iterations). In the test, this is avoided by passing the known membershipIndex, but the default behavior is problematic.

Consider either:

  1. Making maxIndex a required parameter rather than optional with a large default
  2. Adding a reasonable upper bound (e.g., 10,000) for when the index is unknown
  3. Documenting that this function should only be used when the approximate index range is known

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: generate proofs from roots without merkle tree

3 participants