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
- 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.
- 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.
- 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.
- 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
Documentation
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.
Problem
tools/merkle-allowlistbuilds allowlist proofs for an Ethereum contract, not for the Sorobanaid_escrowcontract that actually verifies them. The tool's leaf and hash primitives are incompatible with the contract's verifier on every axis:tools/merkle-allowlist/package.jsondepends onethers ^5.7.2,merkletreejs, andkeccak256— the EVM stack. The contract verifier instead uses SHA-256 over a different leaf:Consequences, distinctly:
tools/merkle-allowlistis keccak256-based and amount-bound, butverify_merkle_proof_for_claimantrecomputes a sha256(address-string) leaf. Every recipient proof against such a root fails withError::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.maybeCallOnchaininstantiatesethers.JsonRpcProvider+ethers.ContractagainstMERKLE_CONTRACT_ADDRESS/MERKLE_CONTRACT_ABI_PATH, which cannot talk to a Soroban contract. The environment variables have no Stellar/Soroban equivalent.sha256(claimant.to_string()); nothing in the repo generates that leaf format, so there is no supported way to publish a root forclaim_with_proof'smerkle_rootmetadata.docs/security/audit-plan.mdexplicitly 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().sha256verifier and address-string leaf.Why this is architecturally hard
current <= sibling) and hashesleft || right(32+32 bytes) with SHA-256. The tool must replicate that pairing rule withcrypto's sha256, notmerkletreejs's keccak256 assumptions — a real implementation, not a dependency swap.merkle_rootmetadata (if any were generated) becomes invalid under a corrected encoding; the fix must state whether old roots are migrated or abandoned.amountinto 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-allowliston Node'scrypto.createHash('sha256')with a documented leaf format that matcheshash_address(sha256 of theAddressstring), implement the contract'sleft/rightsorted-pair combine, and dropethers/merkletreejs/keccak256. Keep the tamper/negative test scenarios but assert them against the Soroban verifier. Offer amakeLeafthat 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_rootmetadata consumed byclaim_with_proofinapp/onchain/contracts/aid_escrow/src/lib.rs. Any leaf/hash change here must be validated against that verifier, anddocs/onchain/api.md/ the contract README should document the canonical encoding so indexers and future tools reuse it.Acceptance criteria
Tool
node index.jsgenerates a root and proof thatverify_merkle_proof_for_claimant(sha256, sorted-pair) accepts for a known leaf; a test or smoke script demonstrates on-chain acceptance.valid,invalid_proof_path,wrong_recipient,wrong_leaf, andmismatched_rootscenarios all behave correctly against the Soroban leaf encoding.ethers,merkletreejs, andkeccak256are removed fromtools/merkle-allowlist/package.json.Documentation
tools/merkle-allowlist/README.mddocuments the exact leaf byte string and hash/pairing algorithm so Rust and JS stay in sync.Out of scope
Binding
amount(orpackage_id) into the contract leaf is a separate issue; do not silently add it here. Contract-side proof-length andmerkle_root_expires_atbehavior 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).Good first files to read:
src/lib.rshash_address/hash_pair/verify_merkle_proof_for_claimant(the exact algorithm to replicate), thentools/merkle-allowlist/index.js.