← Writing

Building an investment escrow on Stellar with Soroban

I’m building a platform that connects project owners with their investor community. The funding mechanics were already in place — fiat payments, deal management, soft caps. What was missing was the on-chain layer: give investors proof of ownership as tradeable tokens, not just a database row.

That sent me down the Soroban rabbit hole. Here’s what I built and what I learned.

Why Stellar, not Ethereum

The obvious choice would have been an EVM chain — the tooling is mature, the documentation is everywhere. But for a financial application with many small investors, gas costs on Ethereum are a real problem. A $200 investment shouldn’t come with a $30 gas fee to claim tokens.

Stellar is built for financial applications. Transactions settle in ~5 seconds, fees are fractions of a cent, and the network has a built-in DEX. Soroban is Stellar’s smart contract layer, introduced in 2024 — Rust-compiled to WASM, running deterministically on every validator node.

The token standard is SEP-41, Stellar’s equivalent of ERC-20. The key interfaces are identical (transfer, approve, allowance, burn), which made reasoning about it straightforward coming from the EVM world.

Starting with hello-world

Every Soroban project starts the same way. The hello-world contract is two dozen lines and teaches you everything fundamental:

#![no_std]
use soroban_sdk::{contract, contractimpl, vec, Env, String, Vec};

#[contract]
pub struct Contract;

#[contractimpl]
impl Contract {
    pub fn hello(env: Env, to: String) -> Vec<String> {
        vec![&env, String::from_str(&env, "Hello"), to]
    }
}

Three things stand out immediately:

#![no_std] — You’re compiling to WASM. No standard library, no filesystem, no threads. Every allocation goes through the Env.

The Env parameter — Every public function receives an Env as its first argument. It’s the gateway to storage, cryptographic operations, cross-contract calls, and authorization. You don’t have global state; you have env.

soroban_sdk typesString, Vec, Map are all SDK types, not Rust’s standard library types. They serialize efficiently to the WASM memory model. You’ll spend the first hour fighting this, then it clicks.

The token contract

The token contract implements SEP-41. Nothing exotic — standard fungible token with mint and admin controls. The interesting parts are in how Soroban handles ownership and storage.

The constructor mints the full supply to the deploying admin:

pub fn __constructor(
    e: Env,
    admin: Address,
    decimal: u32,
    name: String,
    symbol: String,
    total_supply: i128,
) {
    if decimal > 18 {
        panic!("Decimal must not be greater than 18");
    }
    write_administrator(&e, &admin);
    write_metadata(&e, TokenMetadata { decimal, name, symbol });

    if total_supply > 0 {
        receive_balance(&e, admin, total_supply);
    }
}

__constructor is Soroban’s convention — called atomically at deployment, never again. It’s cleaner than OpenZeppelin’s initialize() pattern on EVM chains, where forgetting to call it is a real attack vector.

require_auth() instead of msg.sender

The authorization model is the most notable departure from Solidity. Instead of require(msg.sender == admin), you call require_auth() on an address:

pub fn mint(e: Env, to: Address, amount: i128) {
    let admin = read_administrator(&e);
    admin.require_auth(); // transaction must be signed by admin
    receive_balance(&e, to, amount);
}

This is declarative. The runtime verifies that the transaction includes a valid signature from admin. It works the same way for multi-sig accounts and smart wallets — the protocol handles the verification, not your contract logic.

Storage and TTL — the key Soroban difference

On Ethereum, storage is permanent. On Soroban, storage expires. Every value you store has a time-to-live (TTL) measured in ledger closings (~5 seconds each). If you don’t bump the TTL, the data is eventually evicted.

There are two storage tiers:

  • Instance storage — shared TTL with the contract itself, for config/metadata
  • Persistent storage — per-key TTL, for per-investor data
pub(crate) const DAY_IN_LEDGERS: u32 = 17280; // ~5s per ledger × 17280 = 1 day
pub(crate) const INSTANCE_BUMP_AMOUNT: u32 = 30 * DAY_IN_LEDGERS;

pub fn get_investment(e: &Env, investor: &Address) -> i128 {
    let key = DataKey::Investment(investor.clone());
    if let Some(amount) = e.storage().persistent().get::<_, i128>(&key) {
        // bump TTL on every read
        e.storage().persistent().extend_ttl(
            &key,
            PERSISTENT_LIFETIME_THRESHOLD,
            PERSISTENT_BUMP_AMOUNT,
        );
        amount
    } else {
        0
    }
}

This is easy to forget — and it has real production implications. An investor who claims their tokens a year after a deal closes will need the data to still be alive. You either design proactively (bump on every read/write) or build a separate keep-alive service.

The escrow contract

This is where the actual business logic lives. The design: the backend records off-chain investment confirmations onto the contract. When a deal closes, the escrow distributes tokens proportionally.

The state machine is simple:

#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub enum DealStatus {
    Active,
    Success,
    Failed,
}

Active → Success or Active → Failed. No transitions backwards.

Recording investments

The backend is the admin. When a payment clears off-chain, it calls record_investment:

pub fn record_investment(e: Env, investor: Address, amount: i128) {
    let admin = get_admin(&e);
    admin.require_auth();
    extend_instance_ttl(&e);

    let status = crate::storage::get_status(&e);
    if status != DealStatus::Active {
        panic!("deal is not active");
    }

    let current = get_investment(&e, &investor);
    set_investment(&e, &investor, current + amount);

    let total = get_total_raised(&e);
    set_total_raised(&e, total + amount);
}

Amounts accumulate — the same investor can invest multiple times. The contract doesn’t care about the payment method; that’s the backend’s concern.

Claiming tokens

Once the admin closes the deal as successful, investors claim their proportional share:

pub fn claim_tokens(e: Env, investor: Address) {
    investor.require_auth(); // investor signs the transaction
    extend_instance_ttl(&e);

    let status = crate::storage::get_status(&e);
    if status != DealStatus::Success {
        panic!("deal is not successful");
    }

    if has_claimed(&e, &investor) {
        panic!("already claimed");
    }

    let investment = get_investment(&e, &investor);
    if investment == 0 {
        panic!("no investment found");
    }

    let total_raised = get_total_raised(&e);
    let total_supply = get_total_supply(&e);

    // Multiply first to preserve precision before dividing
    let allocation = (investment * total_supply) / total_raised;

    let token_address = get_token(&e);
    let token_client = token::TokenClient::new(&e, &token_address);
    token_client.transfer(&e.current_contract_address(), &investor, &allocation);

    set_claimed(&e, &investor);
}

The allocation formula is (investment × total_supply) / total_raised. An investor who put in 30% of the total raised receives 30% of the token supply. The multiplication happens before the division — deliberate, to preserve as much precision as possible in integer arithmetic.

The Claimed flag in persistent storage prevents double-claims. Once set, there’s no way to unset it.

Testing

The test suite runs without a node. Soroban’s test environment is deterministic and isolated:

fn setup_test() -> (Env, Address, TokenClient<'static>, EscrowContractClient<'static>, Address) {
    let e = Env::default();
    e.mock_all_auths(); // bypass auth checks in tests

    let admin = Address::generate(&e);

    let token_id = e.register(
        Token,
        (&admin, &DECIMALS, String::from_str(&e, "Deal Token"), String::from_str(&e, "DEAL"), &TOTAL_SUPPLY),
    );
    let token = TokenClient::new(&e, &token_id);

    let escrow_id = e.register(
        EscrowContract,
        (&admin, &token_id, &DEADLINE, &SOFT_CAP, &TOTAL_SUPPLY),
    );
    let escrow = EscrowContractClient::new(&e, &escrow_id);

    token.transfer(&admin, &escrow_id, &TOTAL_SUPPLY);

    (e, admin, token, escrow, escrow_id)
}

mock_all_auths() tells the environment to approve every require_auth() call. In production, real wallet signatures are verified. In tests, you skip that and focus on business logic.

The full lifecycle test reads like a spec:

#[test]
fn test_full_lifecycle_success() {
    let (e, _admin, token, escrow, escrow_id) = setup_test();

    let investor_a = Address::generate(&e);
    let investor_b = Address::generate(&e);

    escrow.record_investment(&investor_a, &units(300_000));
    escrow.record_investment(&investor_b, &units(700_000));

    escrow.close_deal_success();

    escrow.claim_tokens(&investor_a);
    escrow.claim_tokens(&investor_b);

    assert_eq!(token.balance(&investor_a), units(300_000)); // 30% of 1M
    assert_eq!(token.balance(&investor_b), units(700_000)); // 70% of 1M
    assert_eq!(token.balance(&escrow_id), 0);
}

This runs in milliseconds, catches every edge case (double-claim, wrong state, soft cap), and requires no testnet or local node. Coming from frontend testing, this is the part that surprised me most about Rust — the test ergonomics are excellent.

What I’d do differently

TTL management deserves more deliberate design. Currently TTL is bumped on reads and writes, which is fine for active deals. For dormant data — a deal that closed two years ago — you need a strategy. Either a keep-alive service, or accepting that historical data gets evicted and rebuilding it from events.

Use events for off-chain indexing. Soroban has an event system (e.events().publish(...)) that I didn’t use here. For a production platform, emitting events on record_investment and claim_tokens makes it trivial to build an indexer without querying contract state directly.

Decimal precision is a footgun. Soroban uses 7 decimals by convention (1 token = 10_000_000 base units). Write a helper early and use it everywhere:

fn units(n: i128) -> i128 {
    n * 10i128.pow(7)
}

Missing this in one place is an off-by-ten-million error.

What’s next

The contracts are deployed to Stellar testnet. The next step is connecting a NestJS backend: signing transactions with a stored keypair, handling the investor claim flow, and wiring the escrow lifecycle into an existing codebase.

The Soroban SDK is genuinely well-designed for what it is. The authorization model is cleaner than EVM, the test tooling is first-class, and the storage model forces you to think about data lifecycle in a way that Ethereum never did. Worth the learning curve.


Frequently Asked Questions

How do Stellar transaction fees compare to Ethereum for token distribution?

Significantly cheaper. A Stellar transaction costs a fraction of a cent — typically 0.00001 XLM, which is under $0.01 at most price points. On Ethereum, a token transfer can cost $5–50 depending on network congestion. For platforms with many small investors, this difference is the entire argument for Stellar.

What happens if an investor never claims their tokens before the TTL expires?

Their on-chain record gets evicted. The contract’s persistent storage is gone, and the investor can no longer call claim_tokens. That’s why you need a keep-alive mechanism — either a background job that bumps TTL for unclaimed records, or an off-chain rebuild path using indexed events. Design for this from the start.

Can you run Soroban contract tests without deploying to testnet?

Yes — that’s one of Soroban’s strengths. The soroban_sdk::testutils environment is fully in-process and deterministic. Env::default() plus mock_all_auths() gives you a complete execution environment in milliseconds. No testnet, no local node, no funded accounts needed.

How does SEP-41 compare to ERC-20?

The interface is nearly identical: transfer, approve, allowance, balance, burn. The main differences are in how authorization works (require_auth() vs msg.sender) and how storage is managed (TTL-based vs permanent). If you’ve built ERC-20 tokens before, SEP-41 feels familiar within a day.

Is this pattern suitable for production, or just a proof of concept?

The contracts and architecture are production-capable, but there are a few gaps to close first: a robust TTL keep-alive service, event emission for indexing, and the refund path for failed deals. The NestJS backend integration covers how these pieces connect to an existing system.