DEV Community

Mustafa Berke Bakır
Mustafa Berke Bakır

Posted on

Building Milestone-Escrow Crowdfunding on Soroban

A technical walkthrough of StellarFund — three cooperating Soroban contracts that custody XLM and release it only as a creator delivers.

Why milestone escrow

Traditional crowdfunding is all-or-nothing at a single point in time: the platform holds the money and trusts (or is trusted by) both sides. On a smart-contract platform we can do better — hold funds on-chain and release them incrementally, tying each payout to a milestone, with an automatic refund path if the campaign fails.

That's StellarFund. It runs on Stellar mainnet with three Soroban contracts and custodies native XLM.

The three contracts

Factory ──deploy + init──▶ Campaign (escrow) ──record_success──▶ Reputation
Enter fullscreen mode Exit fullscreen mode

1. Factory — one escrow per campaign

Instead of a monolith holding every campaign's money, the Factory deploys a fresh Campaign contract per campaign using env.deployer().with_current_contract(salt). Each campaign gets isolated storage and an isolated balance. The salt is a per-campaign counter, so addresses are deterministic and unique:

let campaign_addr = env
    .deployer()
    .with_current_contract(salt)
    .deploy_v2(wasm_hash, ());

campaign_contract::Client::new(&env, &campaign_addr).init(
    &creator, &goal, &deadline, &token, &reputation, &factory_addr, &milestones,
);
Enter fullscreen mode Exit fullscreen mode

The Factory also keeps a registry (list_campaigns, is_campaign) that other contracts use to verify a caller is a legitimate campaign.

2. Campaign — the escrow itself

On init the contract validates a core invariant: the milestone schedule must sum to the goal, and every tranche must be positive.

let mut sum: i128 = 0;
for amount in milestones.iter() {
    if amount <= 0 { panic!("milestone must be positive"); }
    sum += amount;
    schedule.push_back(Milestone { amount, released: false });
}
if sum != goal { panic!("milestones must sum to goal"); }
Enter fullscreen mode Exit fullscreen mode

Contributing pulls XLM from the backer into the contract via the token client, and records the per-backer amount so refunds are exact:

from.require_auth();
token::TokenClient::new(&env, &token).transfer(&from, &this, &amount);
Enter fullscreen mode Exit fullscreen mode

Because StellarFund uses the native XLM SAC, the same TokenClient interface that works for any Stellar asset also moves lumens — the escrow logic is asset-agnostic.

Releasing is where the milestone logic lives. Releases are strictly sequential, gated on the goal being met and the deadline passed, and each milestone releases once:

if index != released_count { panic!("releases must be sequential"); }
...
if new_count == schedule.len() {
    s.set(&DataKey::Status, &Status::Completed);
    ReputationClient::new(&env, &reputation).record_success(&this, &creator);
}
Enter fullscreen mode Exit fullscreen mode

Refunding is the safety net: after the deadline, if the goal wasn't reached, each backer can reclaim their exact contribution. Note the checks-effects-interactions ordering — the stored balance is zeroed before the transfer:

env.storage().persistent().set(&ckey, &0i128);
token::TokenClient::new(&env, &token).transfer(&this, &caller, &amount);
Enter fullscreen mode Exit fullscreen mode

3. Reputation — provable delivery, not vanity

The Reputation contract increments a creator's score, but only when the caller is a Factory-registered campaign that authorizes the sub-invocation:

campaign.require_auth();
let is_campaign = FactoryClient::new(&env, &factory).is_campaign(&campaign);
if !is_campaign { panic!("caller is not a registered campaign"); }
Enter fullscreen mode Exit fullscreen mode

This cross-contract check is what makes the score meaningful: nobody can inflate their reputation by calling the contract directly.

Lessons for mainnet

Moving from testnet to mainnet surfaced real considerations we wrote up in a security review:

  • State TTL / archival. Soroban entries expire on mainnet unless their TTL is extended. Long-running campaigns need extend_ttl on hot paths or active monitoring.
  • Checks-effects-interactions & checked_add. Safe with the trusted native SAC, but worth hardening as defense-in-depth.
  • No faucet. Testnet's mint-on-demand onboarding doesn't exist on mainnet — the app had to switch from a mintable test token to native XLM with real balances.

Try it

Built with Rust + soroban-sdk, Next.js, and @creit.tech/stellar-wallets-kit. Feedback and PRs welcome. #BuildOnStellar

Top comments (0)