Milestone-based supply chain escrow on Stellar Soroban
ChainSettle is a Soroban smart contract that locks buyer payment in escrow and automatically releases funds to the supplier as each delivery milestone is confirmed on-chain. No middlemen, no delayed wire transfers, no trust required.
This is Repo 1 of 3 in the ChainSettle project:
| Repo | Description |
|---|---|
chainsetttle-contract ← you are here |
Soroban smart contract (Rust) |
chainsetttle-frontend |
React + Freighter wallet UI |
chainsetttle-backend |
Node.js API, notifications, off-chain metadata |
- How It Works
- Architecture
- Data Structures
- Contract Functions
- Events
- Error Codes
- Project Structure
- Prerequisites
- Setup & Installation
- Running Tests
- Building
- Deploying to Testnet
- Deploying to Mainnet
- Security Considerations
- Roadmap
Buyer creates shipment → USDC locked in contract escrow
↓
Supplier dispatches goods → submits IPFS proof hash (Milestone 1)
↓
Buyer confirms → 25% of USDC released to supplier automatically
↓
Logistics confirms transit → submits proof (Milestone 2)
↓
Buyer confirms → 50% released
↓
Goods delivered → supplier submits proof (Milestone 3)
↓
Buyer confirms → final 25% released → Shipment Completed ✓
If buyer disputes any proof → milestone frozen → Arbiter resolves
The contract is deployed once. Multiple independent shipments can be created by different buyers using the same contract.
┌──────────────────────────────────────────────────────────────┐
│ ChainSettle Contract (Soroban) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Shipment │ │ Milestone │ │ USDC Escrow │ │
│ │ Registry │ │ State │ │ (SAC Transfer) │ │
│ │ (Persistent │ │ Machine │ │ │ │
│ │ Storage) │ │ │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
└──────────────────────────────────────────────────────────────┘
↑ ↑ ↑
Buyer / Supplier Buyer confirms Token SAC contract
call contract fns or disputes (USDC on Stellar)
| Role | Address | Permissions |
|---|---|---|
| Buyer | Locks USDC, confirms milestones, raises disputes, cancels shipment | Most actions |
| Supplier | Submits proof for dispatch and delivery milestones | submit_proof only |
| Logistics | Submits proof for in-transit milestones | submit_proof only |
| Arbiter | Resolves disputes — approves or rejects supplier proof | resolve_dispute only |
| Admin | Contract deployer, set at init |
Future: upgrade, pause |
pub struct Milestone {
pub name: String, // e.g. "Goods Dispatched"
pub payment_percent: u32, // 0-100, all milestones must sum to 100
pub proof_hash: String, // IPFS CID set by supplier/logistics
pub status: MilestoneStatus, // Pending | ProofSubmitted | Confirmed | Disputed | Resolved
}pub struct Shipment {
pub id: String, // unique buyer-defined ID e.g. "SHIP-2026-001"
pub buyer: Address,
pub supplier: Address,
pub logistics: Address,
pub arbiter: Address,
pub token: Address, // Stellar Asset Contract for USDC
pub total_amount: i128, // locked in escrow (smallest unit)
pub released_amount: i128, // how much has been paid out so far
pub milestones: Vec<Milestone>,
pub status: ShipmentStatus, // Active | Completed | Cancelled
pub created_at: u32, // ledger sequence number
}Pending
└─ submit_proof() ──→ ProofSubmitted
├─ confirm_milestone() ──→ Confirmed (payment released)
└─ raise_dispute() ──→ Disputed
├─ resolve_dispute(approve=true) ──→ Resolved (payment released)
└─ resolve_dispute(approve=false) ──→ Pending (supplier resubmits)
All functions require the relevant party to sign the transaction (Soroban auth).
Initialises the contract. Called once by the deployer right after deployment.
Creates a new shipment, validates milestone percentages sum to 100,
and transfers total_amount USDC from the buyer into escrow.
Parameters:
shipment_id String — unique ID for this shipment
buyer Address — funds source + milestone approver
supplier Address — payment recipient
logistics Address — in-transit proof submitter
arbiter Address — dispute resolver
token Address — USDC Stellar Asset Contract address
total_amount i128 — total USDC to lock (in stroops)
milestones Vec<Milestone> — ordered list, percentages must sum to 100
Returns: shipment_id (same as input, for confirmation)
Supplier or logistics submits an IPFS hash as proof for a milestone.
Milestone must be in Pending status. Moves status to ProofSubmitted.
Buyer confirms a ProofSubmitted milestone. Automatically calculates
and transfers the milestone's payment percentage to the supplier.
If all milestones are confirmed, shipment status becomes Completed.
Buyer disputes a ProofSubmitted milestone. Freezes the milestone in
Disputed state — no payment can be released until arbiter resolves.
Arbiter resolves a Disputed milestone.
approve = true→ releases payment, status →Resolvedapprove = false→ resets status →Pending(supplier must resubmit)
Cancels the shipment if no milestones have been confirmed yet. Returns all locked funds to the buyer.
Returns the full shipment record.
Returns a single milestone.
Returns the amount of USDC still locked in escrow.
The contract emits the following events (subscribe via Horizon or RPC):
| Event name | Payload | When |
|---|---|---|
shipment_created |
shipment_id |
New shipment created |
proof_submitted |
(shipment_id, milestone_index) |
Proof submitted for a milestone |
milestone_confirmed |
(shipment_id, milestone_index, payment_amount) |
Milestone confirmed, payment released |
dispute_raised |
(shipment_id, milestone_index) |
Buyer disputes a milestone |
dispute_resolved |
(shipment_id, milestone_index, approved) |
Arbiter resolves dispute |
shipment_cancelled |
(shipment_id, refund_amount) |
Shipment cancelled |
The backend service (chainsetttle-backend) listens for these events and
sends push notifications to the relevant parties.
| Code | Meaning |
|---|---|
| 1 | ShipmentAlreadyExists — shipment ID already in use |
| 2 | ShipmentNotFound — shipment ID not found |
| 3 | Unauthorized — caller does not have permission |
| 4 | InvalidMilestoneIndex — index out of range |
| 5 | InvalidMilestoneStatus — wrong state for this action |
| 6 | ShipmentNotActive — shipment is completed or cancelled |
| 7 | InvalidPercentages — milestone percentages don't sum to 100 |
| 8 | InvalidAmount — amount must be > 0 |
| 9 | DisputeAlreadyOpen — dispute already exists for this milestone |
chainsetttle-contract/
├── Cargo.toml ← Rust workspace config
├── Cargo.lock
├── .gitignore
├── README.md ← this file
└── contracts/
└── chainsetttle/
├── Cargo.toml ← contract package config
├── Makefile ← build / deploy shortcuts
└── src/
├── lib.rs ← main contract logic
└── test.rs ← unit tests
Install the following before you begin:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup target add wasm32v1-noneRequires Rust v1.84.0 or higher.
# macOS
brew install stellar-cli
# Linux / WSL
cargo install --locked stellar-cli --features optVerify installation:
stellar --versionstellar keys generate --global my-account --network testnet
stellar keys fund my-account --network testnet# Clone the repo
git clone https://github.com/your-org/chainsetttle-contract.git
cd chainsetttle-contract
# Check all dependencies compile
cargo check# Run all unit tests
cargo test
# Run tests with output (useful for debugging)
cargo test -- --nocapture
# Run a specific test
cargo test test_full_shipment_lifecycle
# Run with logs enabled
cargo test --features testutilsExpected output:
running 7 tests
test test::test_cancel_shipment ... ok
test test::test_create_shipment_success ... ok
test test::test_full_shipment_lifecycle ... ok
test test::test_raise_and_resolve_dispute_approve ... ok
test test::test_raise_and_resolve_dispute_reject ... ok
test test::test_unauthorized_confirm_milestone ... ok
test test::test_create_shipment_invalid_percentages ... ok
# Build contract to .wasm
make build
# → target/wasm32v1-none/release/chainsetttle.wasm
# Optimize .wasm for production (smaller size = lower fees)
make optimize
# → target/wasm32v1-none/release/chainsetttle.optimized.wasmSoroban contracts have a 64KB max size. The optimize step uses
stellar contract optimize to strip unused symbols and shrink the binary.
# Set your account name (created in Prerequisites step)
export STELLAR_ACCOUNT=my-account
# Deploy (uses optimized .wasm)
make deploy-testnetYou'll get back a contract ID like:
CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Save this — you'll need it to initialize the contract and in your frontend/backend configs.
After deploying, call init once to set the admin:
stellar contract invoke \
--id <CONTRACT_ID> \
--source my-account \
--network testnet \
-- init \
--admin <YOUR_ADDRESS>stellar contract invoke \
--id <CONTRACT_ID> \
--source my-account \
--network testnet \
-- create_shipment \
--shipment_id "SHIP-001" \
--buyer <BUYER_ADDRESS> \
--supplier <SUPPLIER_ADDRESS> \
--logistics <LOGISTICS_ADDRESS> \
--arbiter <ARBITER_ADDRESS> \
--token <USDC_SAC_ADDRESS> \
--total_amount 1000000000 \
--milestones '[{"name":"Dispatch","payment_percent":25,"proof_hash":"","status":"Pending"},{"name":"Transit","payment_percent":50,"proof_hash":"","status":"Pending"},{"name":"Delivered","payment_percent":25,"proof_hash":"","status":"Pending"}]'
⚠️ Only deploy to Mainnet after thorough testing and ideally a security audit.
# Fund a mainnet account first (you need XLM for fees)
stellar contract deploy \
--wasm target/wasm32v1-none/release/chainsetttle.optimized.wasm \
--source my-account \
--network mainnetUSDC SAC address on Mainnet:
CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7EJKEF
- Authorization: Every state-changing function calls
require_auth()on the relevant party. No one can act on behalf of another address without their signature. - Escrow isolation: Funds are held by the contract address itself, not a separate wallet. The contract can only release funds via the explicit
transfercalls inconfirm_milestoneandresolve_dispute. - Milestone ordering: Milestones can be confirmed in any order. For sequential enforcement (e.g. must confirm dispatch before transit), you would add a check in
submit_proofthat the previous milestone isConfirmed— this is left as an optional extension. - Percentage validation: The contract validates that all milestone percentages sum exactly to 100 at shipment creation. Rounding is integer-based — for amounts where
total * percent / 100doesn't divide evenly, the final milestone may receive a slightly different amount. Consider adjusting percentages accordingly. - TTL / State Archival: Persistent storage entries are given an extended TTL (~1 year) at creation. Long-lived shipments should call
extend_ttlvia the backend before entries archive. - No upgradability (MVP): This scaffold has no upgrade mechanism. For production, consider implementing Soroban's
upgradepattern.
- Core escrow + milestone logic
- Dispute resolution via arbiter
- USDC token transfers via Stellar Asset Contract
- Full unit test suite
- Sequential milestone enforcement (optional)
- Multi-token support (XLM, EURC)
- Partial cancellation (after some milestones confirmed)
- Contract upgrade mechanism
- Mainnet deployment + verification
- Integration with
chainsetttle-backendevent listener - Integration with
chainsetttle-frontendFreighter wallet
Pull requests welcome. Please run cargo fmt and cargo test before submitting.
MIT