A Soroban smart contract on Stellar that enables trustless milestone-based escrow between a client, freelancer, and arbiter.
This contract allows a client to fund a job broken into milestones. Funds are locked in the contract and released per milestone only when the client approves delivery. Disputes can be raised by either party and resolved by a designated arbiter.
The escrow's protection depends on four distinct roles assigned at initialize time:
- Admin — the platform operator. Holds admin-only endpoints (whitelist management, pause/resume, admin overrides, upgrades). The admin is expected to be a neutral platform address, not the client or freelancer.
- Client — funds the job and approves milestone releases.
- Freelancer — delivers milestones and receives payouts.
- Arbiter — a neutral third party that resolves disputes via
resolve_disputeandapply_dispute_arbitration_split.
initialize enforces party distinctness: it rejects any collision among admin, client, freelancer, and arbiter with Error::InvalidParties. This prevents the client from resolving their own disputes (arbiter == client), the freelancer from ruling in their own favour (arbiter == freelancer), or the client from holding every admin override (admin == client). The frontend must pass four genuinely distinct addresses — it must not reuse the connected wallet as both admin and client.
| Function | Caller | Description |
|---|---|---|
initialize |
Anyone | Set up job with milestone amounts and spec hashes, a 30-day dispute timeout and Upfront funding |
initialize_with_dispute_timeout |
Anyone | Set up job with a custom, non-zero dispute timeout |
initialize_with_funding_mode |
Anyone | Set up job with Upfront or PerMilestone funding |
fund |
Client | Deposit total amount into contract (Upfront mode) |
fund_milestone |
Client | Deposit one milestone (PerMilestone mode) |
mark_delivered |
Freelancer | Mark a milestone as delivered |
request_revision |
Client | Return a delivered milestone to Pending (max 3) |
approve_milestone |
Client | Release funds for a delivered milestone |
raise_dispute |
Client or Freelancer | Freeze a milestone for arbitration |
escalate_dispute |
Client or Freelancer | After the timeout, restore a disputed milestone to its previous status |
resolve_dispute |
Arbiter | Release to freelancer or refund to client |
get_job |
Anyone | View current job state |
sweep_excess |
Client or admin | Recover surplus tokens not backing obligations |
emergency_unpause_parties |
Client and freelancer | Jointly lift a party-initiated pause |
Pending → Delivered → Released
↓
Disputed → Released (arbiter favors freelancer)
→ Refunded (arbiter favors client)
The full state machine, including every entry point, its required signers and the escrow-level locks, is specified in contracts/milestone-escrow/SPEC.md.
Roles, trust assumptions, admin powers and known risks are described in THREAT_MODEL.md.
- https://rustup.rs/ (the toolchain is pinned to 1.96.0 by
rust-toolchain.toml) - https://developers.stellar.org/docs/smart-contracts/getting-started/setup (stellar-cli 28.0.0)
- wasm32 target:
rustup target add wasm32v1-none - Docker, for reproducible builds
For development, from the repository root:
stellar contract build --manifest-path contracts/milestone-escrow/Cargo.tomlAnything that will be deployed must come from the reproducible build instead:
scripts/build-reproducible.shIt compiles a clean export of HEAD (or of the commit given as the first
argument) inside a pinned container (build/reproducible.Dockerfile: Rust
1.96.0 by image digest, stellar-cli 28.0.0 by release checksum, linux/amd64)
and writes dist/milestone_escrow.wasm, its SHA-256 and dist/build-info.json.
The same commit gives the same hash on any machine. CI runs the same script on
every commit and publishes the hash in the job summary and the wasm as an
artifact.
From the repository root:
cargo test -p milestone-escrow- Mainnet release gates are tracked in the mainnet readiness checklist
- The admin authorization direction and migration decision are recorded in ADMIN_MULTISIG_DECISION.md
- Contract implementation completed in
contracts/milestone-escrow - Includes milestone delivery, approval, dispute, and arbitration flows
- Initialize now rejects empty milestone lists, zero/negative amounts, zero addresses, zero auto-release windows, and party collisions (
admin == client,admin == freelancer,arbiter == client,arbiter == freelancer,client == freelancer) with explicit contract errors raise_disputenow performs zero-address check,require_auth, and identity validation before writing the re-entrancy lock (issue #566), matching the authorization pattern used byfund,approve_milestone, andresolve_dispute; includes the five precondition guard assertions present in all other mutating functions- Disputes may only be raised on delivered or partially released milestones. The default dispute timeout is 30 days;
initialize_with_dispute_timeoutallows a custom non-zero timeout. If neither party gets an arbitration decision by that deadline, either party may callescalate_disputeto return the milestone to its pre-dispute status; a restored delivered milestone gets a fresh auto-release window lock_escrow_interest_yieldnow rejects unauthorized callers withUnauthorizedand an illegal source state withInvalidStatusbefore it reads or writes the ledger entry (issue #464); authorization runs first viarequire_admin_from_instance, so a non-admin cannot probe whether an escrow is locked, and an already-locked configuration is refused rather than silently re-locked- Contract tests and snapshots are provided under
contracts/milestone-escrow/test_snapshots
scripts/build-reproducible.sh
stellar contract deploy \
--wasm dist/milestone_escrow.wasm \
--network testnet \
--source <your-account>Then record the contract ID, source_commit and wasm_sha256 from
dist/build-info.json in deployments/<network>.json, and the hash in
CHANGELOG.md.
After upgrading an existing deployment to this version, the admin should call
migrate_cold_config once to move the whitelist, multisig, yield and fee
configuration out of instance storage (issue #772). The contract keeps working
before that call; the migration only makes every later call cheaper.
Anyone can check which source a deployed contract was built from:
-
Read the installed wasm hash:
stellar contract fetch --id <contract-id> --network testnet --out-file deployed.wasm sha256sum deployed.wasm
(
stellar contract infoand the explorer show the same hash.) -
Look up
source_commitfor that contract indeployments/<network>.json. -
Rebuild that commit and compare:
scripts/build-reproducible.sh <source_commit> cat dist/milestone_escrow.wasm.sha256
The two hashes must be identical. If they differ, the deployed code is not the reviewed source at that commit.
MIT
| Network | Contract ID |
|---|---|
| Testnet | CDD5WKK3WT3QVKXMXTJNDIXE4T73FK6GGXDSD6UTJAH6YYZU52SQ4MUH |
Deployment records, including the installed wasm hash, are in
deployments/. The current testnet deployment predates
reproducible builds, so its source commit is unknown.
Explorer: https://stellar.expert/explorer/testnet/contract/CDD5WKK3WT3QVKXMXTJNDIXE4T73FK6GGXDSD6UTJAH6YYZU52SQ4MUH