Repository navigation
claim_with_proof Merkle leaves bind only the claimant address: a single root cannot authorize per-recipient amounts #436
Description
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(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). - Existing roots and packages break. A deployed contract (
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. - 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_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 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
- A proof generated for
(recipient, amount A)fails when the package'samountdiffers from A. - A proof for the correct
(recipient, amount)succeeds. - Address-only roots (legacy) are rejected with a clear error rather than silently accepted.
Tests
- Tests pin amount-mismatch rejection and correct-amount acceptance, plus the legacy-root rejection path.
Documentation
- The canonical leaf byte layout is documented in the contract README and the allowlist tool README.
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 -- --nocaptureGood 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).
Activity
Devadakene commentedon Aug 19, 2026
I am experienced enough to implement this issue having worked on similar issues in the past. Please assign
portableDD commentedon Aug 20, 2026
can i work on this?
grantfox-oss commentedon Aug 20, 2026
🦊 GrantFox — @portableDD has been assigned to this issue as part of the Third Campaign campaign!
Next steps:
Closes #436)Good luck! Track your progress on GrantFox.
grantfox-oss commentedon Aug 20, 2026
🎉 This issue has been marked as completed on GrantFox as part of the Third Campaign campaign!
@portableDD's PR #442 was approved and merged by @kilodesodiq-arch.
🏆 @portableDD: You earned 35 FoxPoints for this contribution! Your current tier: Builder (1,025 total points). Track your full progress on GrantFox.
👏 Great work, @portableDD! Keep contributing to ChainForgee.
fix: bind address and amount in Merkle leaf for claim_with_proof (Cha…