Context
GrantFox's current escrow model requires one Soroban transaction per recipient to initialize a payout slot. For a hackathon or DAO round with hundreds of contributors, this creates an unbounded number of sequential transactions — O(n) ledger cost, O(n) grantor signatures, and no way to front-run the payout without trusting the platform.
This issue tracks a fundamentally different primitive: a Merkle escrow distributor where the grantor commits a single Merkle root on-chain (one transaction, one signature) encoding every recipient's entitlement, and each recipient self-serves their payout by submitting a Merkle inclusion proof. Recipients have no privileged knowledge of one another's amounts. Late-joiners and replayed proofs are handled by on-chain claim state. The design maps directly onto GrantFox's milestone semantics: a leaf encodes (recipient_address, milestone_id, amount_stroops), so a contributor who completes milestone 2 of 5 claims exactly that leaf, not the whole round.
What needs to be built
1. Soroban Merkle distributor contract (contracts/merkle-distributor/)
A new contract (separate from the existing escrow contract, composable via cross-contract call or standalone):
pub trait MerkleDistributorTrait {
/// Called once by the grantor. Pulls `total_amount` of `token` into the contract
/// and stores `merkle_root`. The root is immutable after this call.
fn commit(
env: Env,
grantor: Address,
token: Address,
total_amount: i128,
merkle_root: BytesN<32>,
expiry_ledger: u32,
);
/// Called by the recipient. Verifies the Merkle proof on-chain and transfers
/// `amount` to `recipient`. Reverts if the proof is invalid or the leaf has
/// already been claimed.
fn claim(
env: Env,
recipient: Address,
milestone_id: u32,
amount: i128,
proof: Vec<BytesN<32>>,
);
/// Called by the grantor after `expiry_ledger` to reclaim unclaimed funds.
fn reclaim(env: Env, grantor: Address);
/// Returns true if the leaf for (recipient, milestone_id, amount) has been claimed.
fn is_claimed(env: Env, recipient: Address, milestone_id: u32, amount: i128) -> bool;
}
On-chain Merkle verification must be implemented in pure Soroban using env.crypto().sha256(). The standard binary Merkle tree format should be used (left < right before hashing at each level, leaf = sha256(0x00 ‖ abi_encode(recipient, milestone_id, amount_stroops)), node = sha256(0x01 ‖ left ‖ right)) so the off-chain tooling and the contract agree exactly.
Claim tracking uses a single DataKey::Claimed(leaf_hash: BytesN<32>) persistent entry per claimed leaf — no bitmap, because Soroban's persistent storage already deduplicates by key. The leaf hash is the same value verified during claim, so there is no separate "compute leaf → look up" step.
Instruction budget: proof verification is O(log n) SHA-256 calls. At n = 1024 recipients a proof is 10 hashes deep — well within the per-transaction instruction limit. The contract must assert proof.len() <= 20 (covers 2^20 ≈ one million recipients) to prevent a crafted deep proof from exhausting the budget.
Expiry: once current_ledger() > expiry_ledger, claim must reject with DistributorError::Expired and reclaim must be callable. Before expiry, reclaim must reject with DistributorError::NotExpired. This prevents grantor reclaim racing with a valid but slow recipient claim.
2. Off-chain tree builder (scripts/merkle-tree.ts)
A TypeScript CLI that:
- Accepts a CSV of
(recipient_stellar_address, milestone_id, amount_xlm) rows
- Converts amounts to stroops (
amount * 10_000_000n, BigInt throughout — no floating-point)
- Encodes each leaf as
sha256(0x00 ‖ recipientBytes(32) ‖ milestone_id(4, big-endian) ‖ amount_stroops(16, big-endian, signed)) — using the same encoding the Soroban contract uses, so proofs generated here verify on-chain
- Builds the tree bottom-up, sorting sibling pairs so left < right before hashing
- Outputs
merkle-root.json containing the root hex, total amount in stroops, and the full proof for every leaf keyed by ${recipient}:${milestone_id}
The encoding must be byte-for-byte identical to what the Soroban contract hashes. A mismatch means every proof fails silently on-chain. Include a cross-check test: generate a tree from a known fixture, run claim in a Soroban testenv against each proof, and assert all succeed.
3. Frontend claiming UI (app/distributor/[id]/claim/page.tsx)
A page that:
- Loads the proof file (uploaded to GrantFox's off-chain storage or IPFS CID stored on-chain as contract metadata) for the connected wallet address
- Shows the recipient's entitlement per milestone (amount, milestone name, claimed/unclaimed status via
is_claimed)
- Lets the recipient claim individual milestone leaves in one click (builds and submits the
claim invocation with the proof from the file)
- Shows a countdown to expiry (
expiry_ledger translated to wall-clock time via (expiry_ledger - current_ledger) * 5s)
- Handles the
DistributorError::AlreadyClaimed case gracefully (show "Already claimed" badge, do not offer a retry button)
4. Tests
The contract must have a test for every reachable code path:
| Test |
What it asserts |
test_valid_single_leaf_proof |
A tree of one leaf: proof is empty, claim succeeds, balance transferred |
test_valid_proof_depth_10 |
1024-leaf tree, a leaf at each position proves correctly |
test_invalid_proof_wrong_amount |
Correct recipient and milestone but wrong amount → InvalidProof |
test_invalid_proof_tampered_node |
Flip one bit in the middle of a valid proof → InvalidProof |
test_double_claim_rejected |
Claim the same leaf twice → second call returns AlreadyClaimed |
test_reclaim_before_expiry_rejected |
reclaim before expiry_ledger → NotExpired |
test_reclaim_after_expiry_returns_unclaimed |
Advance ledger past expiry; reclaim returns the sum of unclaimed leaves |
test_claim_after_expiry_rejected |
claim after expiry_ledger → Expired |
test_proof_too_deep_rejected |
proof.len() == 21 → ProofTooDeep |
test_cross_check_typescript_builder |
Tree generated by scripts/merkle-tree.ts fixture, all proofs verify in testenv |
Definition of done
Why this is hard
-
Encoding parity is load-bearing. The TypeScript builder and the Soroban contract must hash identical byte sequences. Getting the byte layout of a Stellar Address (32-byte ed25519 public key, no StrKey encoding), a u32 milestone ID (4 bytes, big-endian), and an i128 stroops value (16 bytes, big-endian, two's-complement signed) exactly right in both TypeScript and Rust — and verifying with a cross-check test — takes real effort to get correct the first time.
-
Soroban instruction budget discipline. The verifier is O(depth) SHA-256 calls. At depth 20 that is 20 host function round-trips per claim. The contract must assert the proof depth bound before entering the loop, not after, to prevent a crafted proof from exhausting the budget before the bound check.
-
Leaf hash collision resistance. Without the 0x00/0x01 domain separators, a crafted proof can present a valid internal node as a leaf. The prefix must be enforced identically in both the TypeScript builder and the Soroban contract.
-
Expiry race condition. claim and reclaim share the same expiry_ledger threshold. If both are callable in the same ledger the contract must define which wins — claim should take precedence (a late but valid proof should not be denied because a reclaim was submitted in the same ledger). This requires explicit ordering logic, not just > vs >= on the ledger number.
-
No floating-point in the amount path. JavaScript's Number silently loses precision above 2^53. The builder must use BigInt for all stroops arithmetic and encode directly to a 16-byte ArrayBuffer, not via string formatting or Number().
Context
GrantFox's current escrow model requires one Soroban transaction per recipient to initialize a payout slot. For a hackathon or DAO round with hundreds of contributors, this creates an unbounded number of sequential transactions — O(n) ledger cost, O(n) grantor signatures, and no way to front-run the payout without trusting the platform.
This issue tracks a fundamentally different primitive: a Merkle escrow distributor where the grantor commits a single Merkle root on-chain (one transaction, one signature) encoding every recipient's entitlement, and each recipient self-serves their payout by submitting a Merkle inclusion proof. Recipients have no privileged knowledge of one another's amounts. Late-joiners and replayed proofs are handled by on-chain claim state. The design maps directly onto GrantFox's milestone semantics: a leaf encodes
(recipient_address, milestone_id, amount_stroops), so a contributor who completes milestone 2 of 5 claims exactly that leaf, not the whole round.What needs to be built
1. Soroban Merkle distributor contract (
contracts/merkle-distributor/)A new contract (separate from the existing escrow contract, composable via cross-contract call or standalone):
On-chain Merkle verification must be implemented in pure Soroban using
env.crypto().sha256(). The standard binary Merkle tree format should be used (left < right before hashing at each level, leaf =sha256(0x00 ‖ abi_encode(recipient, milestone_id, amount_stroops)), node =sha256(0x01 ‖ left ‖ right)) so the off-chain tooling and the contract agree exactly.Claim tracking uses a single
DataKey::Claimed(leaf_hash: BytesN<32>)persistent entry per claimed leaf — no bitmap, because Soroban's persistent storage already deduplicates by key. The leaf hash is the same value verified duringclaim, so there is no separate "compute leaf → look up" step.Instruction budget: proof verification is O(log n) SHA-256 calls. At n = 1024 recipients a proof is 10 hashes deep — well within the per-transaction instruction limit. The contract must assert
proof.len() <= 20(covers 2^20 ≈ one million recipients) to prevent a crafted deep proof from exhausting the budget.Expiry: once
current_ledger() > expiry_ledger,claimmust reject withDistributorError::Expiredandreclaimmust be callable. Before expiry,reclaimmust reject withDistributorError::NotExpired. This prevents grantor reclaim racing with a valid but slow recipient claim.2. Off-chain tree builder (
scripts/merkle-tree.ts)A TypeScript CLI that:
(recipient_stellar_address, milestone_id, amount_xlm)rowsamount * 10_000_000n, BigInt throughout — no floating-point)sha256(0x00 ‖ recipientBytes(32) ‖ milestone_id(4, big-endian) ‖ amount_stroops(16, big-endian, signed))— using the same encoding the Soroban contract uses, so proofs generated here verify on-chainmerkle-root.jsoncontaining the root hex, total amount in stroops, and the full proof for every leaf keyed by${recipient}:${milestone_id}The encoding must be byte-for-byte identical to what the Soroban contract hashes. A mismatch means every proof fails silently on-chain. Include a cross-check test: generate a tree from a known fixture, run
claimin a Soroban testenv against each proof, and assert all succeed.3. Frontend claiming UI (
app/distributor/[id]/claim/page.tsx)A page that:
is_claimed)claiminvocation with the proof from the file)expiry_ledgertranslated to wall-clock time via(expiry_ledger - current_ledger) * 5s)DistributorError::AlreadyClaimedcase gracefully (show "Already claimed" badge, do not offer a retry button)4. Tests
The contract must have a test for every reachable code path:
test_valid_single_leaf_prooftest_valid_proof_depth_10test_invalid_proof_wrong_amountInvalidProoftest_invalid_proof_tampered_nodeInvalidProoftest_double_claim_rejectedAlreadyClaimedtest_reclaim_before_expiry_rejectedreclaimbeforeexpiry_ledger→NotExpiredtest_reclaim_after_expiry_returns_unclaimedtest_claim_after_expiry_rejectedclaimafterexpiry_ledger→Expiredtest_proof_too_deep_rejectedProofTooDeeptest_cross_check_typescript_builderscripts/merkle-tree.tsfixture, all proofs verify in testenvDefinition of done
contracts/merkle-distributor/compiles withcargo build --target wasm32-unknown-unknown --releaseand passes all tests withcargo testscripts/merkle-tree.tsbuilds withtsc --noEmitand produces proofs that verify in the Soroban testenv cross-check testapp/distributor/[id]/claim/page.tsxbuilds withnext build(no type errors)is_claimedstatus for each milestone leafcontracts/merkle-distributor/Cargo.tomldeniesclippy::unwrap_usedWhy this is hard
Encoding parity is load-bearing. The TypeScript builder and the Soroban contract must hash identical byte sequences. Getting the byte layout of a Stellar
Address(32-byte ed25519 public key, no StrKey encoding), au32milestone ID (4 bytes, big-endian), and ani128stroops value (16 bytes, big-endian, two's-complement signed) exactly right in both TypeScript and Rust — and verifying with a cross-check test — takes real effort to get correct the first time.Soroban instruction budget discipline. The verifier is O(depth) SHA-256 calls. At depth 20 that is 20 host function round-trips per
claim. The contract must assert the proof depth bound before entering the loop, not after, to prevent a crafted proof from exhausting the budget before the bound check.Leaf hash collision resistance. Without the
0x00/0x01domain separators, a crafted proof can present a valid internal node as a leaf. The prefix must be enforced identically in both the TypeScript builder and the Soroban contract.Expiry race condition.
claimandreclaimshare the sameexpiry_ledgerthreshold. If both are callable in the same ledger the contract must define which wins —claimshould take precedence (a late but valid proof should not be denied because a reclaim was submitted in the same ledger). This requires explicit ordering logic, not just>vs>=on the ledger number.No floating-point in the amount path. JavaScript's
Numbersilently loses precision above2^53. The builder must useBigIntfor all stroops arithmetic and encode directly to a 16-byteArrayBuffer, not via string formatting orNumber().