Back to App
Lumen WalletLumen
Stellar SorobanRust & WASMMainnet Production Ready

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.

Author: Muhamad Hamzah•Network: Stellar Mainnet & Testnet•SDK: soroban-sdk 22.0
1. Core Architecture & Milestone Lifecycle

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:

State 0

Created

Declared on-chain

State 1

Funded

Tokens locked in vault

State 2

Submitted

Work submitted for review

State 3

Released

Funds sent to freelancer

State 4

Disputed

Arbitrator review triggered

State 5

Resolved

Dispute split executed

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Milestone {
    pub amount: i128,
    pub status: u32,
}
2. Native Cryptographic Authorization (`require_auth`)

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);
    }
}
3. State Storage & TTL (Time-To-Live) Management

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);
4. Arbitration Quorum & Dispute Settlement

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);
    }
}