Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Stellar Escrow

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's stellar-tokens.
  • The escrow contract — factory-style, many offers keyed by u64 id, custody-based settlement.

Layout

.
├── 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)

Prerequisites

  • Rust toolchain (stable).
  • stellar CLI ≥ 26.0. Install: cargo install --locked stellar-cli (or brew 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 is mainnet; there's also futurenet for protocol previews.


1. One-time setup

1a. Point the CLI at testnet

stellar network use testnet

This 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.

1b. Generate three identities

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 deployer

Keys are stored under ~/.config/stellar/identity/. If you ever need to rerun the demo from scratch, stellar keys rm <name> deletes them.


2. Build the token contracts

From the repo root:

stellar contract build --package token-a
stellar contract build --package token-b

Each 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).


3. Deploy Token A

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 1000000000

What'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 alias token-a (stored in .stellar/contract-ids/). Later commands can use --id token-a instead of pasting the raw C… 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's Address. This identity will later be the only one allowed to call mint.
  • --decimals 7 — Stellar convention (native XLM is 7-decimal). EVM-style 18 also works but breaks tooling assumptions.
  • --initial-supply 1000000000raw units, not whole tokens. With 7 decimals, this is 1_000_000_000 / 10⁷ = 100.0 Token A, all minted to deployer.

On success the CLI prints the contract ID (a C… address). Save it if you skipped --alias.

4. Deploy Token B

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 1000000000

5. Verify the deployments

Read-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 deployer

Repeat with --id token-b for Token B.


6. Mint to Alice and Bob (for the escrow demo)

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 500000000

Verify:

stellar contract invoke --id token-a --source deployer -- balance --account alice
stellar contract invoke --id token-b --source deployer -- balance --account bob

Why 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.


7. The escrow contract

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.

7a. Public methods

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.

7b. Lifecycle of an offer

                  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.

7c. Storage at a glance

Key Scope Purpose
DataKey::NextOfferId instance Monotonic counter, refreshed on every contract call.
DataKey::Offer(id) persistent Per-offer state. TTL extended on make.

7d. Events

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 }

8. Build and deploy the escrow

stellar contract build --package escrow

stellar contract deploy \
  --source-account deployer \
  --alias escrow \
  --wasm target/wasm32v1-none/release/escrow.wasm

Notes:

  • 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 deployer here just pays for the deploy. The escrow has no admin, no owner, no privileged keys — anyone can call make for themselves once it's deployed.
  • --alias escrow lets later commands write --id escrow instead of pasting the C… address.

9. Demo flow A — make → take (atomic swap)

This is the headline use case. Alice offers 10 Token A in exchange for 10 Token B; Bob accepts.

9a. Alice creates the offer

stellar contract invoke --id escrow --source alice \
  -- make \
  --maker alice \
  --token-offered token-a \
  --amount-offered 100000000 \
  --token-wanted token-b \
  --amount-wanted 100000000

What's happening:

  • --source alice makes Alice the transaction signer. The CLI auto-builds the auth tree covering both the outer make call and the inner token_a.transfer(alice, escrow, …) that the contract triggers internally. (maker.require_auth() succeeds because she's signing; the cross-contract transfer's own from.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 underlying C… contract IDs.
  • --amount-offered 100000000 — raw units (= 10.0 Token A at 7 decimals).
  • The contract returns the new offer_id (a u64). 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 0

The get_offer call should print the full struct: { maker, token_offered, amount_offered, token_wanted, amount_wanted }.

9b. Bob takes the offer

stellar contract invoke --id escrow --source bob \
  -- take \
  --taker bob \
  --offer-id 0

Inside this single transaction, two cross-contract calls execute atomically:

  1. token_b.transfer(bob, alice, 100_000_000) — Bob pays Alice.
  2. 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     # void

get_offer on a deleted offer returns Option::None, which the CLI prints as the empty/void value.


10. Demo flow B — make → refund (cancellation)

Same make as before, then Alice cancels instead of waiting for Bob.

10a. Alice creates a second offer

stellar contract invoke --id escrow --source alice \
  -- make \
  --maker alice \
  --token-offered token-a \
  --amount-offered 50000000 \
  --token-wanted token-b \
  --amount-wanted 75000000

This 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.

10b. Alice refunds

stellar contract invoke --id escrow --source alice \
  -- refund \
  --offer-id 1

Internally:

  • The contract loads Offer(1) from storage.
  • It calls offer.maker.require_auth() — passes because --source alice is 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     # void

10c. (Optional) Try to refund Bob's way

If 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 failed

Why: 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."

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages