YieldStreamdocsLaunch app

Protocol mechanics

Leaf hashing

Every allocation is committed as one leaf. The hash binds four values together so a proof cannot be replayed across campaigns, positions, or amounts:

leaf = SHA256( 0x00
       || XDR(distribution_id)
       || XDR(index)
       || XDR(recipient)
       || XDR(amount) )

distribution_id is u32, index is u32, recipient is an Address, and amount is i128 — all XDR-encoded by Soroban's ToXdr, not hand-rolled bytes. Including the campaign id means a leaf built for campaign 7 is invalid in campaign 8 even with an identical address and amount.

Why the 0x00 prefix

It is RFC 6962 domain separation. Without a distinct prefix, a leaf hash and an interior node hash share the same shape, so a tree where an interior node happens to equal a leaf value allows a second-preimage attack: a "proof" for one structure verifies against another. Leaves are prefixed 0x00, nodes 0x01, and the two can never be confused.

Node hashing

node = SHA256( 0x01 || min(a, b) || max(a, b) )

Siblings are sorted lexicographically before hashing. Two consequences:

  • a proof is a flat list of 32-byte hashes with no left/right direction flags, so there is no direction bit to lie about;
  • swapping a sibling pair cannot produce a different root — the classic malleability attack on naive Merkle trees.

Proof verification

current = leaf
for sibling in proof:
    current = node(current, sibling)
return current == root

Verification is O(log n) hashes with no tree stored on chain. A recipient claiming from a 10,000-leaf tree submits 14 hashes — 448 bytes of proof data.

One caveat worth knowing before you build a client: the loop is currently unbounded, so a caller can pass an arbitrarily long proof and the contract will hash all of it before comparing to the root. That is tracked as issue #6. A correct implementation should reject proofs longer than the tree depth implied by num_recipients.

Campaign lifecycle

A campaign is a Distribution record with a status field. The transitions:

                  ┌──────────────────────┐
                  │  Active (0)          │
                  │  accepting claims    │
                  └───┬────────┬─────┬───┘
        all claimed   │        │     │  creator calls cancel
        OR budget     ▼        │     ▼
        exhausted  Completed   │   Cancelled (1)
             (2)               │      │
             ▲                 │      │ creator calls sweep
             │  end_time       │      ▼
             │  passed +       │   Completed (2)
             │  sweep          │
        Expired (3) ◄──────────
        (time-gated; claims rejected)

Note that Expired is not written by a timer — there is no cron on a ledger. Expiry is evaluated on read: claim rejects when end_time > 0 and the ledger timestamp has passed, and sweep allows recovery under the same test. A campaign past its deadline sits in Active until someone calls sweep.

end_time == 0 means no expiration. Such a campaign can only be closed by cancel then sweep.

Storage layout

KeyScopeContents
Admininstanceadministrator Address
DistributionCountinstanceu32 auto-incrementing campaign id
Distribution(id)persistentfull campaign record
Claimed(id, index)persistentone entry per claimed leaf — see issue #13

TTL policy

Stellar ledger entries expire unless their time-to-live is extended. Soroban measures TTL in ledgers, roughly 5 seconds each, so 17,280 ledgers ≈ 1 day. The contract bumps on access:

  • instance — extended toward 30 days once it falls below a 29-day threshold;
  • persistent — campaign and claimed records extended toward 120 days once below a 90-day threshold.

The gap that matters: a campaign record is bumped whenever anyone reads it, but a Claimed entry is only touched by its own leaf index. If a campaign sits untouched past its TTL window, records can expire and rent reclaim. That is issue #14, and it is a fund-orphaning risk, not a cosmetic one.

Multi-tenancy

One deployed contract serves every issuer. Campaigns are namespaced by the auto-incremented id, each carries its own token address, root, budget, and deadline, and escrowed tokens sit under the contract address per campaign. No tenant writes another tenant's keys — the Claimed and Distribution keys both take the campaign id as their first component.

The tradeoff: because the leaf hash includes distribution_id, proofs are not portable between campaigns. Re-running a list as a new campaign means rebuilding the tree.

What the contract does not do

  • No pause switch. Admin is stored and readable but no function checks it. The admin cannot halt claims. See issue #9.
  • Soroban tokens only. Transfers go through token::Client, which speaks the Soroban token interface. Classic Stellar assets are not supported.
  • No upgradeability. The WASM is not versioned or upgradable in this contract.