Skip to content

merkle-allowlist tool emits keccak256 Ethereum proofs: incompatible with claim_with_proof SHA-256 verification #425

Description

@kilodesodiq-arch

Problem

tools/merkle-allowlist builds allowlist proofs for an Ethereum contract, not for the Soroban aid_escrow contract that actually verifies them. The tool's leaf and hash primitives are incompatible with the contract's verifier on every axis:

// tools/merkle-allowlist/index.js
const { ethers } = require('ethers');
const { MerkleTree } = require('merkletreejs');
const keccak256 = require('keccak256');

function makeLeaf(entry) {
  // keccak256(abi.encodePacked(address, amount))
  return ethers.utils.keccak256(
    ethers.utils.defaultAbiCoder.encode(['address','uint256'],
      [entry.address.toLowerCase(), ethers.BigNumber.from(entry.amount).toString()]));
}

tools/merkle-allowlist/package.json depends on ethers ^5.7.2, merkletreejs, and keccak256 — the EVM stack. The contract verifier instead uses SHA-256 over a different leaf:

// app/onchain/contracts/aid_escrow/src/lib.rs
fn hash_address(env: &Env, address: &Address) -> [u8; 32] {
    let digest = env.crypto().sha256(&data);   // sha256(claimant_address_string)
    ...
}

Consequences, distinctly:

  • Proofs are silently useless. A root generated by tools/merkle-allowlist is keccak256-based and amount-bound, but verify_merkle_proof_for_claimant recomputes a sha256(address-string) leaf. Every recipient proof against such a root fails with Error::InvalidProof. The tool prints a "valid" result locally (tree.verify(...) succeeds against its own keccak256 tree) while the on-chain claim always rejects it — a false-positive smoke signal.
  • Optional "on-chain" verification targets the wrong chain. maybeCallOnchain instantiates ethers.JsonRpcProvider + ethers.Contract against MERKLE_CONTRACT_ADDRESS/MERKLE_CONTRACT_ABI_PATH, which cannot talk to a Soroban contract. The environment variables have no Stellar/Soroban equivalent.
  • No tool produces the contract's actual leaf. The contract leaf is sha256(claimant.to_string()); nothing in the repo generates that leaf format, so there is no supported way to publish a root for claim_with_proof's merkle_root metadata.

docs/security/audit-plan.md explicitly scopes "Merkle Proofs: Verification limits and prevention of resource-exhaustion DoS vectors on Stellar/Soroban", so this mismatch sits exactly in the audited surface.

Root cause

The tool was built against the EVM Merkle pattern (keccak256 + abi.encodePacked + ethers) and never reconciled with the Soroban contract's env.crypto().sha256 verifier and address-string leaf.

Why this is architecturally hard

  1. The leaf encoding is a cross-repo contract. The tool and the contract must agree on a canonical leaf byte string (address string only vs. address+amount, and its exact byte layout). Getting this wrong only moves the bug; the fix is to define the encoding, document it, and encode it identically in JS and Rust.
  2. SHA-256 with sorted-pair Merkle in JS. The contract sorts sibling pairs by byte order (current <= sibling) and hashes left || right (32+32 bytes) with SHA-256. The tool must replicate that pairing rule with crypto's sha256, not merkletreejs's keccak256 assumptions — a real implementation, not a dependency swap.
  3. Existing roots break. Any root already published in package merkle_root metadata (if any were generated) becomes invalid under a corrected encoding; the fix must state whether old roots are migrated or abandoned.
  4. It feeds the amount-binding question. The tool binds amount into the leaf while the contract binds only the address; fixing the hash/ecosystem must be coordinated with the separate amount-binding issue so the tool and contract converge, not diverge further.

Proposed design

Rewrite tools/merkle-allowlist on Node's crypto.createHash('sha256') with a documented leaf format that matches hash_address (sha256 of the Address string), implement the contract's left/right sorted-pair combine, and drop ethers/merkletreejs/keccak256. Keep the tamper/negative test scenarios but assert them against the Soroban verifier. Offer a makeLeaf that matches the contract exactly, and note the amount-binding question as a dependency rather than silently encoding amount.

Downstream impact

The tool is the only path to producing merkle_root metadata consumed by claim_with_proof in app/onchain/contracts/aid_escrow/src/lib.rs. Any leaf/hash change here must be validated against that verifier, and docs/onchain/api.md / the contract README should document the canonical encoding so indexers and future tools reuse it.

Acceptance criteria

Tool

  • node index.js generates a root and proof that verify_merkle_proof_for_claimant (sha256, sorted-pair) accepts for a known leaf; a test or smoke script demonstrates on-chain acceptance.
  • The valid, invalid_proof_path, wrong_recipient, wrong_leaf, and mismatched_root scenarios all behave correctly against the Soroban leaf encoding.
  • ethers, merkletreejs, and keccak256 are removed from tools/merkle-allowlist/package.json.

Documentation

  • tools/merkle-allowlist/README.md documents the exact leaf byte string and hash/pairing algorithm so Rust and JS stay in sync.

Out of scope

Binding amount (or package_id) into the contract leaf is a separate issue; do not silently add it here. Contract-side proof-length and merkle_root_expires_at behavior are also separate.

Getting started

Files: tools/merkle-allowlist/index.js, tools/merkle-allowlist/package.json, app/onchain/contracts/aid_escrow/src/lib.rs (hash_address, hash_pair, verify_merkle_proof_for_claimant).

cd tools/merkle-allowlist
npm start        # node index.js

Good first files to read: src/lib.rs hash_address/hash_pair/verify_merkle_proof_for_claimant (the exact algorithm to replicate), then tools/merkle-allowlist/index.js.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardThird CampaignCampaign: Third Campaignarea:onchainOn-chain (Soroban) areabugSomething isn't workinghighHigh severity issues

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions