Skip to content

[BLOCKCHAIN]: Build a Soroban Merkle escrow distributor enabling trustless batch payout initialization without per-recipient transactions #229

Description

@libby-coder

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_ledgerNotExpired
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_ledgerExpired
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

  • contracts/merkle-distributor/ compiles with cargo build --target wasm32-unknown-unknown --release and passes all tests with cargo test
  • scripts/merkle-tree.ts builds with tsc --noEmit and produces proofs that verify in the Soroban testenv cross-check test
  • All 10 contract tests pass
  • app/distributor/[id]/claim/page.tsx builds with next build (no type errors)
  • The frontend correctly reflects is_claimed status for each milestone leaf
  • contracts/merkle-distributor/Cargo.toml denies clippy::unwrap_used
  • No floating-point arithmetic anywhere in the amount encoding path

Why this is hard

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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().

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions