Problem
The Merkle allowlist proves membership only; it cannot bind a recipient to a specific amount or package. The contract hashes the claimant address and nothing else:
// app/onchain/contracts/aid_escrow/src/lib.rs
fn hash_address(env: &Env, address: &Address) -> [u8; 32] {
// ... sha256(claimant_address_string)
}
verify_merkle_proof_for_claimant then compares the recomputed sha256(address-string) leaf against the stored merkle_root. The doc comment for claim_with_proof states this explicitly: the leaf is sha256(claimant_address_string). By contrast, the (separately broken) generator tool attempts to bind an amount into the leaf — tools/merkle-allowlist/index.js builds keccak256(abi.encodePacked(address, amount)) — showing the intended design was amount-bound even though the contract is not.
Consequence: a single merkle_root authorises a claimant for every package that carries that root in its merkle_root metadata, regardless of each package's amount. An operator cannot publish a root meaning "recipient A may claim 1,000 units and recipient B may claim 500 units" — any allowlisted address can claim any amount on any package sharing the root. If a root is reused across campaigns, or a package is created with a larger amount than the allowlist intended, the contract cannot detect the mismatch. This is an authorization-scoping gap on the disbursement path, inside the Merkle surface that docs/security/audit-plan.md names for review.
Root cause
The leaf construction was reduced to the address string alone, so the proof encodes "is a member" instead of "is entitled to this amount on this package"; the generator tool encodes the richer intent that the contract never enforced.
Why this is architecturally hard
- The leaf format is a cross-ecosystem contract. Changing the leaf to include
amount (or package_id) must be mirrored exactly in any future allowlist tool and in every indexer that precomputes leaves; a mismatch silently turns every proof invalid (the same failure class as the keccak256-vs-sha256 issue).
- Existing roots and packages break. A deployed contract (
CDSBJ27PKTNFTRW6OKPCVXDRUSSRUIQUG6DW5PUTKLDXTDT23NQIS6JG) may already hold address-only roots; the fix must define a migration or a versioned merkle_root format (e.g. a metadata flag or a merkle_root v2 key) so old packages either keep working or fail loudly.
- It must coordinate with the generator tool. The fix should land alongside the tool rewrite so the tool and contract converge on one canonical leaf (address + amount + package_id?) instead of diverging further.
- It touches claim authorization.
claim_with_proof currently passes only the claimant to verification; binding amount means the proof must also carry the amount (and the contract must pass package.amount into the leaf) — a change to the verifier's input shape, not just hash_address.
Proposed design
Define a versioned leaf, e.g. sha256(address_string || amount_be_bytes) (and optionally package_id), document the exact byte layout, and have verify_merkle_proof_for_claimant reconstruct the leaf from claimant + package.amount. Introduce a metadata flag for the leaf version so old address-only roots are rejected explicitly rather than misinterpreted.
Downstream impact
Any leaf change alters claim_with_proof verification in app/onchain/contracts/aid_escrow/src/lib.rs and must be mirrored in tools/merkle-allowlist and in the docs (docs/onchain/api.md, the contract README). The deployed testnet contract requires a redeploy/migration decision.
Acceptance criteria
Contract
Tests
Documentation
Out of scope
The keccak256→sha256 tool rewrite and proof-length caps are separate issues (coordinate, but do not bundle).
Getting started
Files: app/onchain/contracts/aid_escrow/src/lib.rs (hash_address, verify_merkle_proof_for_claimant, claim_with_proof), tools/merkle-allowlist/index.js, app/onchain/contracts/aid_escrow/tests/.
cd app/onchain
make test # cargo test -- --nocapture
Good first files to read: src/lib.rs hash_address/verify_merkle_proof_for_claimant (the current address-only leaf) and tools/merkle-allowlist/index.js makeLeaf (the amount-bound intent to reconcile).
Problem
The Merkle allowlist proves membership only; it cannot bind a recipient to a specific amount or package. The contract hashes the claimant address and nothing else:
verify_merkle_proof_for_claimantthen compares the recomputedsha256(address-string)leaf against the storedmerkle_root. The doc comment forclaim_with_proofstates this explicitly: the leaf issha256(claimant_address_string). By contrast, the (separately broken) generator tool attempts to bind an amount into the leaf —tools/merkle-allowlist/index.jsbuildskeccak256(abi.encodePacked(address, amount))— showing the intended design was amount-bound even though the contract is not.Consequence: a single
merkle_rootauthorises a claimant for every package that carries that root in itsmerkle_rootmetadata, regardless of each package'samount. An operator cannot publish a root meaning "recipient A may claim 1,000 units and recipient B may claim 500 units" — any allowlisted address can claim any amount on any package sharing the root. If a root is reused across campaigns, or a package is created with a larger amount than the allowlist intended, the contract cannot detect the mismatch. This is an authorization-scoping gap on the disbursement path, inside the Merkle surface thatdocs/security/audit-plan.mdnames for review.Root cause
The leaf construction was reduced to the address string alone, so the proof encodes "is a member" instead of "is entitled to this amount on this package"; the generator tool encodes the richer intent that the contract never enforced.
Why this is architecturally hard
amount(orpackage_id) must be mirrored exactly in any future allowlist tool and in every indexer that precomputes leaves; a mismatch silently turns every proof invalid (the same failure class as the keccak256-vs-sha256 issue).CDSBJ27PKTNFTRW6OKPCVXDRUSSRUIQUG6DW5PUTKLDXTDT23NQIS6JG) may already hold address-only roots; the fix must define a migration or a versionedmerkle_rootformat (e.g. a metadata flag or amerkle_rootv2 key) so old packages either keep working or fail loudly.claim_with_proofcurrently passes only the claimant to verification; binding amount means the proof must also carry the amount (and the contract must passpackage.amountinto the leaf) — a change to the verifier's input shape, not justhash_address.Proposed design
Define a versioned leaf, e.g.
sha256(address_string || amount_be_bytes)(and optionallypackage_id), document the exact byte layout, and haveverify_merkle_proof_for_claimantreconstruct the leaf fromclaimant+package.amount. Introduce a metadata flag for the leaf version so old address-only roots are rejected explicitly rather than misinterpreted.Downstream impact
Any leaf change alters
claim_with_proofverification inapp/onchain/contracts/aid_escrow/src/lib.rsand must be mirrored intools/merkle-allowlistand in the docs (docs/onchain/api.md, the contract README). The deployed testnet contract requires a redeploy/migration decision.Acceptance criteria
Contract
(recipient, amount A)fails when the package'samountdiffers from A.(recipient, amount)succeeds.Tests
Documentation
Out of scope
The keccak256→sha256 tool rewrite and proof-length caps are separate issues (coordinate, but do not bundle).
Getting started
Files:
app/onchain/contracts/aid_escrow/src/lib.rs(hash_address,verify_merkle_proof_for_claimant,claim_with_proof),tools/merkle-allowlist/index.js,app/onchain/contracts/aid_escrow/tests/.Good first files to read:
src/lib.rshash_address/verify_merkle_proof_for_claimant(the current address-only leaf) andtools/merkle-allowlist/index.jsmakeLeaf(the amount-bound intent to reconcile).