Skip to content

claim_with_proof Merkle leaves bind only the claimant address: a single root cannot authorize per-recipient amounts #436

Description

@kilodesodiq-arch

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

  1. 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).
  2. 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.
  3. 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.
  4. 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

  • A proof generated for (recipient, amount A) fails when the package's amount differs 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 -- --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).

Metadata

Metadata

Assignees

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