Building a Decentralized Milestone Escrow Contract on Stellar Soroban
As Web3 applications transition into commercial utility, trustless payment architectures such as milestone escrows have become essential. In this guide, we break down the architecture and implementation of the production-ready LumenFlow Escrow Smart Contract deployed live on Stellar Mainnet.
Traditional freelance and supply agreements suffer from counterparty trust deficits. Either the client pays upfront risking abandonment, or the contractor works with no guarantee of payment.
Our smart contract defines an immutable milestone structure where funds are locked on-chain in Soroban contract storage, transitioning through verified state machines:
Created
Declared on-chain
Funded
Tokens locked in vault
Submitted
Work submitted for review
Released
Funds sent to freelancer
Disputed
Arbitrator review triggered
Resolved
Dispute split executed
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Milestone {
pub amount: i128,
pub status: u32,
}Stellar Soroban features a native authorization framework that removes the need to manually verify cryptographic signature payloads inside the contract. Calling client.require_auth() mandates that the transaction was signed by the key owning that address.
#[contractimpl]
impl EscrowContract {
pub fn initialize(
env: Env,
client: Address,
freelancer: Address,
arbitrator: Address,
token: Address,
) {
if env.storage().instance().has(&DataKey::Client) {
panic!("already initialized");
}
env.storage().instance().set(&DataKey::Client, &client);
env.storage().instance().set(&DataKey::Freelancer, &freelancer);
env.storage().instance().set(&DataKey::Arbitrator, &arbitrator);
env.storage().instance().set(&DataKey::Token, &token);
}
pub fn deposit(env: Env, id: u32, amount: i128) {
let client: Address = env.storage().instance().get(&DataKey::Client).unwrap();
client.require_auth(); // Cryptographic signature validation
if amount <= 0 {
panic!("amount must be positive");
}
// Transfer tokens from client's wallet into contract instance vault
let token: Address = env.storage().instance().get(&DataKey::Token).unwrap();
let token_client = token::Client::new(&env, &token);
token_client.transfer(&client, &env.current_contract_address(), &amount);
let milestone = Milestone { amount, status: 1 };
env.storage().instance().set(&DataKey::Milestone(id), &milestone);
}
}To prevent state bloat, Soroban uses a storage rent model. Persistent storage entries must have their Time-To-Live extended to remain accessible. Our deployment logic extends entry validity to guarantee data availability:
// Extend contract instance TTL to prevent ledger expiration
env.storage().instance().extend_ttl(10000, 50000);If a disagreement occurs, either party can trigger the dispute state. The neutral arbitrator reviews proof of deliverables off-chain and executes the on-chain settlement, partitioning the funds with mathematical precision:
pub fn resolve(env: Env, id: u32, freelancer_share: i128, client_share: i128) {
let arbitrator: Address = env.storage().instance().get(&DataKey::Arbitrator).unwrap();
arbitrator.require_auth(); // Cryptographic arbitrator consent
let milestone_key = DataKey::Milestone(id);
let mut milestone: Milestone = env.storage().instance().get(&milestone_key).expect("milestone not found");
if milestone.status != 4 { // Must be in Disputed state
panic!("milestone must be in dispute state");
}
if freelancer_share + client_share != milestone.amount {
panic!("shares sum must equal total milestone amount");
}
// Distribute settled payments
let token: Address = env.storage().instance().get(&DataKey::Token).unwrap();
let token_client = token::Client::new(&env, &token);
if freelancer_share > 0 {
token_client.transfer(&env.current_contract_address(), &freelancer, &freelancer_share);
}
if client_share > 0 {
token_client.transfer(&env.current_contract_address(), &client, &client_share);
}
}