A Soroban escrow that swaps two fungible tokens atomically. Alice locks Token A in the escrow and declares how much Token B she wants; Bob can later accept the offer, paying Token B to Alice and receiving Token A from the escrow in one atomic transaction. Alice can cancel at any time before Bob accepts.
This repo ships:
- Two demo fungible tokens (
token-a,token-b) built on OpenZeppelin'sstellar-tokens. - The
escrowcontract — factory-style, many offers keyed byu64id, custody-based settlement.
.
├── Cargo.toml # workspace; pins soroban-sdk + OZ stellar-tokens
└── contracts/
├── token-a/ # SEP-41 fungible token (delegates to OZ Base)
├── token-b/ # identical contract, deployed with different metadata
└── escrow/ # the swap contract (see contracts/escrow/DESIGN.md)
- Rust toolchain (stable).
stellarCLI ≥ 26.0. Install:cargo install --locked stellar-cli(orbrew install stellar-cli).- Network connectivity to
https://soroban-testnet.stellar.org.
Terminology note for Solana folks: Stellar's public test network is called testnet (not "devnet"). It's the equivalent of Solana devnet — free XLM via a faucet (
friendbot), wiped occasionally, safe to break things on. Mainnet ismainnet; there's alsofuturenetfor protocol previews.
stellar network use testnetThis stores testnet as the default network so you can omit --network on every subsequent command. The CLI ships with the testnet RPC URL and passphrase preconfigured.
We'll use three roles, mirroring the escrow demo we'll build later:
| Identity | Role |
|---|---|
deployer |
Deploys both tokens. Receives initial supply. Has admin mint rights. |
alice |
Holds Token A. Will be one side of the escrow swap. |
bob |
Holds Token B. Will be the counterparty. |
stellar keys generate deployer --fund
stellar keys generate alice --fund
stellar keys generate bob --fund--fund calls friendbot immediately so each identity is created and topped up with testnet XLM in a single command (you need XLM to pay gas/storage).
Verify:
stellar keys ls
stellar keys public-key deployerKeys are stored under
~/.config/stellar/identity/. If you ever need to rerun the demo from scratch,stellar keys rm <name>deletes them.
From the repo root:
stellar contract build --package token-a
stellar contract build --package token-bEach build emits a .wasm at target/wasm32v1-none/release/token_a.wasm and token_b.wasm (note the underscore — Cargo converts crate name - to filename _).
You can also build both at once with plain stellar contract build (no --package flag).
stellar contract deploy \
--source-account deployer \
--alias token-a \
--wasm target/wasm32v1-none/release/token_a.wasm \
-- \
--owner deployer \
--name "Token A" \
--symbol TKA \
--decimals 7 \
--initial-supply 1000000000What's going on:
--source-account deployer— the account that pays fees and signs the deploy tx. Resolves to deployer's secret key.--alias token-a— saves the resulting contract ID under the aliastoken-a(stored in.stellar/contract-ids/). Later commands can use--id token-ainstead of pasting the rawC…address.- The
--separates CLI options from constructor arguments passed to__constructor. The CLI auto-converts kebab-case flags (--initial-supply) to the snake_case Rust param names (initial_supply). --owner deployer— identity name; the CLI resolves it to deployer'sAddress. This identity will later be the only one allowed to callmint.--decimals 7— Stellar convention (native XLM is 7-decimal). EVM-style 18 also works but breaks tooling assumptions.--initial-supply 1000000000— raw units, not whole tokens. With 7 decimals, this is1_000_000_000 / 10⁷ = 100.0Token A, all minted todeployer.
On success the CLI prints the contract ID (a C… address). Save it if you skipped --alias.
Same command, different metadata:
stellar contract deploy \
--source-account deployer \
--alias token-b \
--wasm target/wasm32v1-none/release/token_b.wasm \
-- \
--owner deployer \
--name "Token B" \
--symbol TKB \
--decimals 7 \
--initial-supply 1000000000Read-only contract calls go through stellar contract invoke. The --id accepts either the saved alias or the raw C… address.
# Metadata
stellar contract invoke --id token-a --source deployer -- name
stellar contract invoke --id token-a --source deployer -- symbol
stellar contract invoke --id token-a --source deployer -- decimals
stellar contract invoke --id token-a --source deployer -- total_supply
# Deployer's balance — should be 1_000_000_000
stellar contract invoke --id token-a --source deployer -- balance --account deployerRepeat with --id token-b for Token B.
Fund alice with Token A and bob with Token B so they have something to swap later:
# 50.0 Token A → alice (50 * 10⁷ = 500_000_000 raw)
stellar contract invoke --id token-a --source deployer \
-- mint --to alice --amount 500000000
# 50.0 Token B → bob
stellar contract invoke --id token-b --source deployer \
-- mint --to bob --amount 500000000Verify:
stellar contract invoke --id token-a --source deployer -- balance --account alice
stellar contract invoke --id token-b --source deployer -- balance --account bobWhy this works: mint reads the stored owner from contract storage and calls owner.require_auth(). Because --source-account deployer signs the tx and deployer was set as owner in the constructor, the auth check passes. If you swap --source-account for alice, the call will fail with an auth error — try it.
contracts/escrow is a factory-style atomic-swap escrow. One deployment, unbounded offers — each offer keyed by a monotonically-increasing u64. See contracts/escrow/DESIGN.md for the full design rationale.
| Method | Caller signs | What it does |
|---|---|---|
make |
maker | Locks amount_offered of token_offered into the escrow, records the desired amount_wanted of token_wanted. Returns a fresh offer_id. |
take |
taker | Atomic swap: taker pays amount_wanted to the maker; escrow releases amount_offered to the taker. Offer deleted. |
refund |
maker | Returns the locked amount_offered to the maker. Offer deleted. Only the original maker can call. |
get_offer |
(read-only) | Returns Some(Offer) if the offer is live, None if taken/refunded/never existed. |
make
(locks token_offered in escrow)
│
▼
┌────────┐
│ Offer │ ──────take──────► (atomic swap, offer deleted)
│ live │
└────────┘ ──────refund────► (maker reclaims funds, offer deleted)
Once an offer is taken or refunded, its storage entry is deleted — get_offer will return None. There's no "cancelled" state; absence is the state.
| Key | Scope | Purpose |
|---|---|---|
DataKey::NextOfferId |
instance |
Monotonic counter, refreshed on every contract call. |
DataKey::Offer(id) |
persistent |
Per-offer state. TTL extended on make. |
The escrow emits one event per state transition; indexers can subscribe to any topic.
| Topic | Payload |
|---|---|
offer_made |
{ offer_id, maker } |
offer_taken |
{ offer_id, taker, maker } |
offer_cancelled |
{ offer_id, maker } |
stellar contract build --package escrow
stellar contract deploy \
--source-account deployer \
--alias escrow \
--wasm target/wasm32v1-none/release/escrow.wasmNotes:
- No constructor arguments —
__constructor(e: &Env)only zeroes the counter; nothing to configure. The CLI knows there are no args because the contract spec is embedded in the Wasm. --source-account deployerhere just pays for the deploy. The escrow has no admin, no owner, no privileged keys — anyone can callmakefor themselves once it's deployed.--alias escrowlets later commands write--id escrowinstead of pasting theC…address.
This is the headline use case. Alice offers 10 Token A in exchange for 10 Token B; Bob accepts.
stellar contract invoke --id escrow --source alice \
-- make \
--maker alice \
--token-offered token-a \
--amount-offered 100000000 \
--token-wanted token-b \
--amount-wanted 100000000What's happening:
--source alicemakes Alice the transaction signer. The CLI auto-builds the auth tree covering both the outermakecall and the innertoken_a.transfer(alice, escrow, …)that the contract triggers internally. (maker.require_auth()succeeds because she's signing; the cross-contract transfer's ownfrom.require_auth()succeeds because that same auth tree authorizes the sub-call.)--token-offered token-a/--token-wanted token-b— the CLI resolves saved aliases to the underlyingC…contract IDs.--amount-offered 100000000— raw units (= 10.0 Token A at 7 decimals).- The contract returns the new
offer_id(au64). On a fresh escrow it prints"0"to stdout.
Verify post-state:
# Alice's Token A: 50 - 10 = 40
stellar contract invoke --id token-a --source deployer -- balance --account alice
# Escrow's Token A balance: 10
stellar contract invoke --id token-a --source deployer -- balance --account escrow
# Offer 0 is live
stellar contract invoke --id escrow --source deployer -- get_offer --offer-id 0The get_offer call should print the full struct: { maker, token_offered, amount_offered, token_wanted, amount_wanted }.
stellar contract invoke --id escrow --source bob \
-- take \
--taker bob \
--offer-id 0Inside this single transaction, two cross-contract calls execute atomically:
token_b.transfer(bob, alice, 100_000_000)— Bob pays Alice.token_a.transfer(escrow, bob, 100_000_000)— Escrow releases to Bob.
If either traps (e.g. Bob has insufficient Token B), the entire transaction rolls back — including step 1, even though it would have "succeeded" alone. There is no half-state.
Verify post-state:
# Alice: -10 A, +10 B
stellar contract invoke --id token-a --source deployer -- balance --account alice # 40
stellar contract invoke --id token-b --source deployer -- balance --account alice # 10
# Bob: +10 A, -10 B
stellar contract invoke --id token-a --source deployer -- balance --account bob # 10
stellar contract invoke --id token-b --source deployer -- balance --account bob # 40
# Escrow drained
stellar contract invoke --id token-a --source deployer -- balance --account escrow # 0
# Offer 0 is gone
stellar contract invoke --id escrow --source deployer -- get_offer --offer-id 0 # voidget_offer on a deleted offer returns Option::None, which the CLI prints as the empty/void value.
Same make as before, then Alice cancels instead of waiting for Bob.
stellar contract invoke --id escrow --source alice \
-- make \
--maker alice \
--token-offered token-a \
--amount-offered 50000000 \
--token-wanted token-b \
--amount-wanted 75000000This returns 1 (offer IDs are monotonic — the counter doesn't reset after deletes).
Verify Alice's Token A balance dropped from 40 to 35, and the escrow's balance went from 0 to 5.
stellar contract invoke --id escrow --source alice \
-- refund \
--offer-id 1Internally:
- The contract loads
Offer(1)from storage. - It calls
offer.maker.require_auth()— passes because--source aliceis the signer and Alice is the stored maker. token_a.transfer(escrow, alice, 50_000_000)returns the locked funds.Offer(1)is deleted.
Verify:
stellar contract invoke --id token-a --source deployer -- balance --account alice # 40 (restored)
stellar contract invoke --id token-a --source deployer -- balance --account escrow # 0
stellar contract invoke --id escrow --source deployer -- get_offer --offer-id 1 # voidIf you swap --source alice for --source bob on the refund call (against a live offer), the call traps:
# Make a new offer first, then:
stellar contract invoke --id escrow --source bob \
-- refund \
--offer-id 2
# → HostError: require_auth failedWhy: inside refund, the contract calls offer.maker.require_auth() — Alice's address from storage. Bob's signature on the outer tx doesn't satisfy that — Soroban auth is bound to the address being challenged, not to "whoever signed."