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 == rootVerification 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
| Key | Scope | Contents |
|---|---|---|
| Admin | instance | administrator Address |
| DistributionCount | instance | u32 auto-incrementing campaign id |
| Distribution(id) | persistent | full campaign record |
| Claimed(id, index) | persistent | one 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.
Adminis 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.