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.
This 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 types — String, 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);
}
}
The __constructor naming is Soroban’s convention — called atomically at deployment, never again. This is 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 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 mechanism.
The escrow contract
This is where the actual business logic lives. The design: the backend (NestJS) 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. For an investor who put in 30% of the total raised, they receive 30% of the token supply. The multiplication happens before the division — this is deliberate, to preserve as much precision as possible in integer arithmetic.
The Claimed(Address) 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 the 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 rebuild 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 the frontend: connecting a Freighter wallet, building the XDR transaction for claim_tokens, and submitting it via the Stellar Horizon RPC. That part is a different article.
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.