Skip to content

Implement a Merkle-proof allowlist verification module for gasless off-chain access checks #404

Description

@Lakes41

Difficulty: Expert
Type: Feature / Security

Background

The SDK's on-chain support today is entirely read-based (getGuildOwner, getMembershipTokenBalance, and their batch variants), performing a live eth_call per check against a configured rpcUrl (docs/api-reference.md). This works well for token-balance-based gating but is not the standard pattern for guilds that gate access based on a fixed, pre-computed allowlist (e.g. a snapshot of eligible wallets for an event, airdrop-style access, or a curated member list) — the conventional Web3 approach for that use case is a Merkle tree of eligible addresses, with the tree root stored on-chain (cheap, one value) and individual eligibility proven off-chain via a Merkle proof (free, no RPC call needed per check), which the current SDK has no support for at all.

Problem

Without Merkle-proof support, any guild wanting gasless, RPC-call-free allowlist verification (a very common Web3 access-control pattern, and materially cheaper/faster than an eth_call per check, especially at scale or in latency-sensitive contexts) has no SDK-native path and must implement tree construction, proof generation, and proof verification entirely outside the SDK, with no guarantee their hashing scheme matches whatever the GuildPass backend/contract expects if the guild's root is later checked on-chain.

Expected outcome

A new @guildpass/sdk/merkle subpath (consistent with the existing subpath-export pattern) exposes: (1) buildAllowlistTree(addresses: string[]) that constructs a Merkle tree from a list of (checksum-normalized, deduplicated) addresses using a documented, standard leaf-hashing scheme (keccak256 of the packed/encoded address, matching common on-chain verification conventions such as OpenZeppelin's MerkleProof library expectations, so a tree built by this function is verifiable both off-chain via this module and on-chain by a standard Solidity verifier) and returns the tree root plus a lookup structure; (2) getProof(tree, address) returning the Merkle proof (array of sibling hashes) for a given address; and (3) verifyProof(root: string, address: string, proof: string[]) returning a boolean, verifying the proof reconstructs the given root — all implemented with zero new runtime dependencies (a minimal keccak256 implementation, ideally shared with the EIP-55 checksum work if that lands first, is the only crypto primitive needed).

Suggested implementation

  • Implement keccak256 (or reuse a shared vendored implementation from the EIP-55 checksum issue if it's landed) and a standard "sorted pair hashing" Merkle tree construction (sort each pair of sibling hashes before concatenating and hashing, which is the widely-adopted convention — e.g. matching OpenZeppelin's MerkleProof.sol — specifically so trees built by this module remain provable against standard on-chain verifiers without requiring a custom contract).
  • buildAllowlistTree should validate and checksum-normalize every input address (rejecting invalid addresses up front via src/utils/validation.ts), deduplicate, and handle the odd-leaf-count case correctly (standard approach: promote the unpaired leaf to the next level rather than duplicating it, since leaf-duplication schemes have known vulnerabilities — this must be handled correctly and is a common source of real-world Merkle allowlist bugs).
  • getProof should be efficient for realistically large allowlists (thousands of addresses) — avoid O(n^2) approaches; build proper level-indexed tree storage during buildAllowlistTree so proof generation is O(log n) per lookup rather than recomputing the tree.
  • verifyProof must be fully deterministic and side-effect-free, suitable for running entirely client-side (e.g. in a browser, before ever hitting the network) as a pre-check, and should also be usable server-side by anything consuming this SDK.
  • Provide an integration point with the existing client.access.checkAccess flow: document (and optionally implement as a convenience) a pattern where a caller performs local verifyProof first as a fast pre-check before calling the network-backed checkAccess, and clearly document that verifyProof alone is a client-side convenience/UX optimization, not a substitute for authoritative server-side or on-chain verification (a client could lie about a proof passing locally — the actual access decision must remain the API's/contract's responsibility).
  • Extensive tests: correct root computation against known/hand-computed small trees, correct proof generation and verification for various tree sizes including odd counts, negative tests (tampered proof, wrong address, proof from a different tree), and a performance test asserting getProof scales acceptably for a several-thousand-address allowlist.
  • Cross-verify the sorted-pair-hashing scheme against a reference on-chain MerkleProof-style verifier (can be a documented manual verification against a well-known reference implementation/test vectors rather than requiring a live contract deployment) to guarantee on-chain compatibility claims are accurate, not just internally self-consistent.
  • New docs/merkle-allowlists.md documenting the full workflow (build tree off-chain → publish root on-chain or via GuildPass config → generate/distribute proofs → verify client-side and/or server-side) with a clear security note about client-side verification not being authoritative.

Acceptance criteria

  • buildAllowlistTree/getProof/verifyProof exported from a new @guildpass/sdk/merkle subpath.
  • Leaf hashing and sorted-pair internal-node hashing match a documented, standard scheme verifiable against a reference on-chain-style implementation (test vectors included).
  • Odd-leaf-count trees are handled via level-promotion, not duplication, and tested explicitly.
  • getProof is O(log n) per call against a pre-built tree structure, verified acceptable for a multi-thousand-address allowlist in a performance test.
  • verifyProof correctly rejects tampered proofs, wrong addresses, and proofs from unrelated trees.
  • No new runtime dependency added; keccak256 implementation is dependency-free (shared with the EIP-55 work if available).
  • docs/merkle-allowlists.md documents the full workflow and explicitly notes that client-side proof verification is a UX optimization, not an authoritative access decision.
  • CHANGELOG.md updated.

Likely affected files/directories

  • src/merkle/ (new)
  • src/utils/ (shared keccak256 if not already present from EIP-55 work)
  • package.json (new subpath export), tsup.config.ts, api-extractor.json
  • tests/merkle.test.ts (new)
  • docs/merkle-allowlists.md (new), CHANGELOG.md

Metadata

Metadata

Assignees

Labels

GrantFox OSSGrantFox Open Source Sponsorship program tagMaybe RewardedIssue may qualify for a reward upon successful completion per campaign rulesOfficial Campaign | FWC26Official FWC26 campaign issue — eligible for campaign scoring and rewardsexpertExpert difficulty tasks requiring deep expertise and architectural decision-makingfeatureNew feature, enhancement, or functional additionsecuritySecurity-related fix, hardening, audit, or vulnerability remediation

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions