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.

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