This guide explains how to work on the escrow_contract Soroban smart contract, including how to prepare it for safe upgrades.
- Rust >= 1.74
wasm32-unknown-unknowntarget:rustup target add wasm32-unknown-unknown- Soroban CLI / Stellar CLI with contract support
contracts/escrow_contract/
|-- Cargo.toml
`-- src/
|-- lib.rs
|-- types.rs
|-- errors.rs
`-- events.rs
cd contracts/escrow_contract
# Native build for tests
cargo build
# Optimized WASM build for deployment (uses release profile: opt-level=z, LTO, strip)
cargo build --release --target wasm32-unknown-unknown
# Or via the test script (also runs tests first)
bash scripts/test-contract.sh --buildThe release profile is configured in Cargo.toml with:
opt-level = "z"— optimize for smallest binary sizelto = true— link-time optimization across cratescodegen-units = 1— single codegen unit for maximum optimizationstrip = "symbols"— remove debug symbols from the WASM outputpanic = "abort"— removes panic unwinding machinery
cd contracts/escrow_contract
cargo test
cargo test -- --nocapture
cargo test test_create_escrow_happy_pathEvery mutating function must call require_auth() on the correct address before touching storage.
caller.require_auth();
let state = load_escrow(&env, escrow_id)?;Load storage once, mutate in memory, then save once.
let mut escrow = load_escrow(&env, id)?;
escrow.status = EscrowStatus::Disputed;
env.storage().instance().set(&DataKey::Escrow(id), &escrow);Always commit state changes before token transfers.
milestone.status = MilestoneStatus::Approved;
env.storage().instance().set(&DataKey::Escrow(id), &escrow);
token_client.transfer(...);Public functions should return Result<T, EscrowError> and avoid panics.
let escrow = env
.storage()
.instance()
.get(&DataKey::Escrow(id))
.ok_or(EscrowError::EscrowNotFound)?;Use soroban_sdk::token::Client for token movement.
use soroban_sdk::token;
let token_client = token::Client::new(&env, &escrow.token);
token_client.transfer(&caller, &env.current_contract_address(), &amount);
token_client.transfer(&env.current_contract_address(), &recipient, &amount);In tests, StellarAssetClient can mint mock tokens.
let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_addr);
sac.mint(&client_addr, &10_000_000_000i128);The current contract is not upgrade-ready yet.
It already stores an admin under DataKey::Admin, which is the right authority model for upgrades, but it does not yet expose:
- an admin-only
upgrade(new_wasm_hash)entrypoint - a
version()entrypoint - a storage version key for migrations
Before the first production deployment that may need upgrades, add those pieces. The official Soroban upgrade pattern uses env.deployer().update_current_contract_wasm(new_wasm_hash) behind an admin authorization check.
Recommended additions:
#[contracttype]
pub enum DataKey {
EscrowCounter,
Escrow(u64),
Reputation(Address),
Admin,
ContractVersion,
}
pub fn version(env: Env) -> u32 {
env.storage().instance().get(&DataKey::ContractVersion).unwrap_or(1)
}
pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), EscrowError> {
let admin: Address = env.storage().instance().get(&DataKey::Admin).ok_or(EscrowError::NotInitialized)?;
admin.require_auth();
env.deployer().update_current_contract_wasm(new_wasm_hash);
Ok(())
}After deployment, initialize ContractVersion to 1 and bump it in each upgrade that changes behavior or storage expectations.
The contract currently persists these top-level keys in types.rs:
DataKey::EscrowCounterDataKey::Escrow(id)DataKey::Reputation(address)DataKey::Admin
Important compatibility rule:
Changing the binary layout of EscrowState, Milestone, ReputationRecord, or DataKey without a migration plan can make already-written ledger entries unreadable by new Wasm code.
Safe changes:
- adding new public functions that do not reinterpret old storage
- adding new event types
- adding new storage keys that do not conflict with existing ones
- adding optional migration-only functions
High-risk changes:
- renaming or reordering enum variants
- changing existing
contracttypefield ordering - changing field types, such as
u64toi128 - changing how
DataKey::Escrow(id)orDataKey::Reputation(address)are encoded
Use this runbook for any deployed contract upgrade.
Decide whether the release is:
- code-only: logic changes, bug fixes, event changes, no storage layout change
- additive storage: new keys or new migration-only data
- breaking storage: existing contract data will be interpreted differently
If the change is breaking storage, do not deploy until the migration design and rollback plan are written and tested.
- Record the current deployed contract ID.
- Record the currently active Wasm hash.
- Record the target release version.
- Build the new Wasm artifact with
stellar contract build. - Review any indexer or backend assumptions that depend on emitted events or enum values.
Before the upgrade window, confirm the deployed contract instance and Wasm TTL are healthy. If TTL is low, extend it before the upgrade so the live contract does not become archived during rollout.
- Deploy the old version.
- Seed representative escrow and reputation state.
- Upload the new Wasm.
- Invoke the upgrade against the test deployment.
- Run post-upgrade functional checks and migration verification.
cd contracts/escrow_contract
stellar contract build
stellar contract upload \
--source-account <admin_identity> \
--network <network> \
--wasm target/wasm32v1-none/release/<compiled_name>.wasmSave the returned Wasm hash. Do not continue without storing both the old and new hashes in the release notes.
Invoke the admin-only upgrade entrypoint using the new Wasm hash.
stellar contract invoke \
--id <contract_id> \
--source-account <admin_identity> \
--network <network> \
-- \
upgrade \
--new_wasm_hash <new_wasm_hash>- Call
version()and confirm the expected version. - Fetch representative escrows and reputation records.
- Validate milestone approval, dispute, and cancellation flows.
- Confirm backend indexer parsing still matches events and statuses.
If storage must change, use one of these patterns.
Keep old keys readable and write new data into separate keys or versioned structures.
Example approach:
- keep
DataKey::Escrow(id)readable for old records - add
DataKey::ContractVersion - add a migration function that upgrades one escrow at a time when first touched
This minimizes blast radius and keeps rollback feasible.
If every record must be rewritten, expose an admin-only migration entrypoint after the Wasm upgrade.
pub fn migrate_v2(env: Env, escrow_id: u64) -> Result<(), EscrowError> {
let admin: Address = env.storage().instance().get(&DataKey::Admin).ok_or(EscrowError::NotInitialized)?;
admin.require_auth();
let version: u32 = env.storage().instance().get(&DataKey::ContractVersion).unwrap_or(1);
if version >= 2 {
return Ok(());
}
let escrow: EscrowState = env
.storage()
.instance()
.get(&DataKey::Escrow(escrow_id))
.ok_or(EscrowError::EscrowNotFound)?;
let migrated = escrow;
env.storage().instance().set(&DataKey::Escrow(escrow_id), &migrated);
env.storage().instance().set(&DataKey::ContractVersion, &2u32);
Ok(())
}The example above is intentionally simple. In a real migration, transform old state into the new format and emit a migration event for auditability.
- never change
DataKeymeanings in place without a version gate - never reorder fields in on-ledger structs unless you also migrate old entries
- keep
Adminstable across upgrades so the same authority can operate rollback - coordinate contract event changes with
backend/services/escrowIndexer.js
Every upgrade plan must include rollback before deployment starts.
- the previous Wasm hash is recorded
- the previous release artifact is reproducible
- admin credentials for the contract are available
- migration effects are understood
If the new Wasm only changed logic and did not rewrite stored data:
- pause any backend jobs that may amplify the incident
- invoke
upgradeagain using the previous Wasm hash - verify
version()and critical read paths - replay post-upgrade checks
If the upgrade rewrote contract storage:
- do not assume Wasm rollback alone is safe
- either provide a reverse migration entrypoint or restore from a pre-upgrade ledger/data snapshot strategy
- document which migrations are reversible and which are one-way
For this repository, one-way migrations should be avoided until the contract is fully implemented and a dedicated migration test harness exists.
- keep the old Wasm hash in the deployment ticket
- keep testnet evidence of both forward and backward upgrade tests
- notify backend/frontend operators before rollback if event shapes changed
Every upgrade PR should include upgrade-specific tests, not just normal unit tests.
Minimum required coverage:
- old Wasm deploy -> new Wasm upgrade succeeds
- admin authorization is required for upgrade
version()changes as expected- pre-upgrade escrows remain readable after upgrade
- pre-upgrade reputation records remain readable after upgrade
- any migration entrypoint is idempotent
- rollback to previous Wasm works when migration is reversible
Recommended test structure:
mod old_contract {
soroban_sdk::contractimport!(file = "../old_contract/target/wasm32v1-none/release/old.wasm");
}
mod new_contract {
soroban_sdk::contractimport!(file = "../new_contract/target/wasm32v1-none/release/new.wasm");
}Then:
- register or deploy the old contract
- seed realistic escrow and reputation state
- upload new Wasm
- call
upgrade(new_wasm_hash) - verify state reads and post-upgrade writes
- if supported, upgrade back to old Wasm and verify rollback behavior
For breaking storage changes, add fixtures representing real production-like state:
- active escrows with multiple milestones
- completed escrows with zero remaining balance
- disputed escrows with arbiter assigned
- reputation records with non-zero counters and volume
Use semantic compatibility rules for contract releases.
- bug fix without storage changes
- event additions without removing old events
- new read-only methods
- new storage keys that old keys do not depend on
- new optional fields represented through new keys
- new admin-only migration functions
- event payload changes that require backend parser updates in the same release
- changes to existing serialized struct layout without migration
- renaming or removing storage keys used by live data
- changing auth expectations for existing methods without backend/frontend coordination
Release notes should always include:
- contract version number
- previous and new Wasm hashes
- storage migration required: yes or no
- rollback type: direct Wasm rollback or migration-aware rollback
- backend compatibility notes
-
upgrade()entrypoint exists and is admin-guarded -
version()exists and is updated - storage compatibility reviewed against
types.rs - migration path documented if any serialized type changed
- rollback plan written
- localnet or testnet upgrade test executed
- backend indexer compatibility reviewed
- Stellar Docs: Upgrading Wasm bytecode for a deployed contract https://developers.stellar.org/docs/build/guides/conventions/upgrading-contracts
- Stellar Docs: Extending a deployed contract's TTL https://developers.stellar.org/docs/build/guides/conventions/extending-wasm-ttl
This section documents every state an escrow (and its milestones) can be in, what triggers each transition, and how to check state from code.
| State | Description |
|---|---|
Active |
Escrow created, funds locked on-chain. Work can begin. |
Disputed |
A dispute has been raised. Funds are frozen pending arbiter resolution. |
CancellationPending |
A cancellation was requested. A dispute window is open before it executes. |
Completed |
All milestones approved and all funds released. Terminal state. |
Cancelled |
Escrow cancelled before completion. Remaining funds returned to client. Terminal state. |
| State | Description |
|---|---|
Pending |
Milestone defined but work not yet submitted. |
Submitted |
Freelancer submitted work. Awaiting client review. |
Approved |
Client approved. Funds for this milestone released to freelancer. |
Rejected |
Client rejected the submission. Freelancer should resubmit. |
Disputed |
A dispute was raised on this milestone. Funds frozen. |
┌─────────────────────────────────────────────────────┐
│ ESCROW STATES │
└─────────────────────────────────────────────────────┘
create_escrow()
│
▼
[ Active ] ──── raise_dispute() ──────────────────────► [ Disputed ]
│ │
│ resolve_dispute()
│ │
├──── request_cancellation() ▼
│ │ [ Completed ]
│ ▼ [ Cancelled ]
│ [ CancellationPending ]
│ │
│ dispute_cancellation() ──► [ Disputed ]
│ │
│ execute_cancellation()
│ │
│ ▼
│ [ Cancelled ]
│
└──── cancel_escrow() ────────────────────────────► [ Cancelled ]
│ (immediate, no pending milestones)
│
└──── (all milestones approved) ─────────────────► [ Completed ]
┌─────────────────────────────────────────────────────┐
│ MILESTONE STATES │
└─────────────────────────────────────────────────────┘
add_milestone()
│
▼
[ Pending ] ◄──────────────────────────────────────────────────────────────┐
│ │
submit_milestone() │
│ │
▼ │
[ Submitted ] ──── approve_milestone() ──► [ Approved ] │
│ │
├──── reject_milestone() ──────────────────────────────────────────────┘
│
└──── raise_dispute() ──────────────────────────► [ Disputed ]
| From | To | Triggered By | Who Can Call |
|---|---|---|---|
| — | Active |
create_escrow() |
Client |
Active |
Disputed |
raise_dispute() |
Client or Freelancer |
Active |
CancellationPending |
request_cancellation() |
Client or Freelancer |
Active |
Cancelled |
cancel_escrow() |
Client |
Active |
Completed |
Last approve_milestone() |
Client (auto) |
CancellationPending |
Disputed |
dispute_cancellation() |
Opposing party |
CancellationPending |
Cancelled |
execute_cancellation() |
Anyone (after window) |
Disputed |
Completed |
resolve_dispute() |
Arbiter or both parties |
Disputed |
Cancelled |
resolve_dispute() |
Arbiter or both parties |
| From | To | Triggered By | Who Can Call |
|---|---|---|---|
| — | Pending |
add_milestone() |
Client |
Pending |
Submitted |
submit_milestone() |
Freelancer |
Rejected |
Submitted |
submit_milestone() |
Freelancer |
Submitted |
Approved |
approve_milestone() |
Client |
Submitted |
Rejected |
reject_milestone() |
Client |
Submitted |
Disputed |
raise_dispute() |
Client or Freelancer |
Pending |
Disputed |
raise_dispute() |
Client or Freelancer |
let escrow = client.get_escrow(&escrow_id).unwrap();
match escrow.status {
EscrowStatus::Active => {
// safe to submit milestones, raise disputes, etc.
}
EscrowStatus::Disputed => {
// funds frozen — wait for resolve_dispute()
}
EscrowStatus::Completed | EscrowStatus::Cancelled => {
// terminal state — no further actions possible
}
EscrowStatus::CancellationPending => {
// dispute window open — call dispute_cancellation() to block it
}
}let milestone = client.get_milestone(&escrow_id, &milestone_id).unwrap();
if milestone.status == MilestoneStatus::Pending
|| milestone.status == MilestoneStatus::Rejected
{
client.submit_milestone(&freelancer, &escrow_id, &milestone_id, &proof_hash);
}The contract automatically transitions the escrow to Completed when the last milestone is approved. You can verify this:
let escrow = client.get_escrow(&escrow_id).unwrap();
assert_eq!(escrow.status, EscrowStatus::Completed);
assert_eq!(escrow.remaining_balance, 0);EscrowNotActive error when calling submit_milestone or approve_milestone
The escrow is not in Active state. Check escrow.status — it may be Disputed, Cancelled, or Completed. Resolve any open dispute first via resolve_dispute().
MilestoneNotSubmitted error when calling approve_milestone or reject_milestone
The milestone status is not Submitted. The freelancer must call submit_milestone() first.
AlreadyDisputed error when calling raise_dispute
The escrow is already in Disputed state. Only one active dispute is allowed per escrow at a time.
Escrow stuck in CancellationPending
Either wait for the dispute window to expire and call execute_cancellation(), or call dispute_cancellation() to escalate to a full dispute if you believe the cancellation is unjustified.
Escrow never transitions to Completed
Completed is set automatically when the last milestone is approved. Verify that all milestones have status == MilestoneStatus::Approved and that remaining_balance == 0. If any milestone is still Pending, Submitted, or Disputed, the escrow stays Active.
release_funds fails even though milestone is Approved
release_funds requires the milestone to be in Approved state and the escrow lock_time (if set) to have passed. Check escrow.lock_time against the current ledger timestamp.