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
- See detailed security model: docs/SECURITY.md
- 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 proof for a milestone (proof_hash is the payload reference, e.g. an IPFS CID; proof_type is a short symbol naming the content scheme, e.g. ipfs, sha256, or url).
Milestone must be in Pending status. Moves status to ProofSubmitted.
The buyer can constrain which proof_type values are valid for each milestone before anyone submits proof. This is enforced inside submit_proof: if a non-empty whitelist is stored for that milestone, the supplied proof_type must appear in the list or the call panics (proof type not in whitelist). If no whitelist was configured, or the buyer cleared it with an empty list, any proof_type is accepted.
| Function | Who | When |
|---|---|---|
set_proof_whitelist(buyer, shipment_id, milestone_index, allowed_types) |
Buyer | Shipment Active, milestone still Pending (before first submission) |
get_proof_whitelist(shipment_id, milestone_index) → Vec<Symbol> |
Anyone (read-only) | Returns allowed types; empty vector means “any type” |
get_milestone_proof_type(shipment_id, milestone_index) → Option<Symbol> |
Anyone (read-only) | Type recorded at submission, or None if not submitted yet |
Example: the buyer allows only IPFS proofs on milestone 0:
set_proof_whitelist(buyer, "SHIP-001", 0, [ipfs])
submit_proof(supplier, "SHIP-001", 0, "<cid>", ipfs) // ok
submit_proof(supplier, "SHIP-001", 0, "<hash>", sha256) // fails — not whitelisted
Pass an empty allowed_types vector to set_proof_whitelist to remove the restriction for that milestone.
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.
Confirms multiple milestones in a single transaction. Functionally equivalent
to calling confirm_milestone once per index, but saves transaction fees and
reduces the number of on-chain round-trips when several milestones are ready
to approve at once.
Parameters:
buyer Address — must be the shipment's registered buyer
shipment_id String — shipment to operate on
milestone_indices Vec<u32> — ordered list of milestone indices to confirm
Behavior:
- The call is atomic — if any index is invalid or any milestone is not in
ProofSubmittedstatus, the entire transaction is reverted and no payments are released. - All indices are validated before any state is mutated, so partial application never occurs.
- Each confirmed milestone triggers its own
milestone_confirmedevent and releases that milestone's proportional payment to the supplier (same fee and advance-deduction logic asconfirm_milestone). - Passing an empty
milestone_indiceslist is a no-op — the call returns without error or side-effects. - If all milestones end up confirmed after the batch, shipment status
automatically transitions to
Completed(same asconfirm_milestone).
vs. confirm_milestone:
confirm_milestone |
batch_confirm_milestones |
|
|---|---|---|
| Milestones per call | 1 | Many |
| Atomicity | Single milestone | All-or-nothing across the batch |
| Partial failure | N/A | Reverts entire batch |
| Gas / fee cost | Per milestone | One transaction for all |
Example — confirm all three milestones at once after all proofs are in:
stellar contract invoke \
--id <CONTRACT_ID> \
--source buyer-account \
--network testnet \
-- batch_confirm_milestones \
--buyer <BUYER_ADDRESS> \
--shipment_id "SHIP-001" \
--milestone_indices '[0, 1, 2]'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.
Admin-only kill switch that halts state-changing calls across every shipment without touching any stored data. Locked funds stay in escrow untouched while paused — this only blocks new actions, it never moves or seizes funds itself.
pause(admin)— sets the paused flag and emitscontract_paused.unpause(admin)— clears the paused flag and emitscontract_unpaused.is_paused() → bool(read-only) — returns the current state.
While paused, every function that guards on assert_not_paused panics
with "contract is paused". That covers the full shipment lifecycle:
create_shipment, submit_proof, confirm_milestone,
raise_dispute/raise_partial_dispute, resolve_dispute,
cancel_shipment/supplier_cancel, escrow top-ups, advances,
extensions, amendments, transfers, and claims. Read-only getters,
admin configuration setters (fees, thresholds, whitelists, etc.), and
admin succession (nominate_admin/accept_admin) are not gated by
pause and keep working normally.
Only the current admin can call pause/unpause — same
assert_admin check used everywhere else. To resume normal operation,
the admin simply calls unpause; no other recovery steps are needed.
Admin-only. Sets the maximum percentage of a milestone's payment that a
supplier may request as an advance via request_advance. percent must
not exceed 100, or the call panics with "max advance percent must not exceed 100".
Returns the current advance percentage cap, defaulting to 30 if the
admin has never called set_max_advance_percent. request_advance
reads this value and panics with AdvanceExceedsMax if the requested
advance_percent is greater than the cap.
Admin-only. Sets how many milestones on a single shipment can be under
dispute at the same time. Defaults to 1 if never called. raise_dispute
and raise_partial_dispute check the shipment's open dispute count
against this cap and panic with "DisputeAlreadyOpen" once it's reached.
This is what stops a buyer from disputing several milestones of the same shipment all at once. Without a cap, one shipment could rack up an unbounded number of simultaneous disputes, tying up several payment releases at once and dumping all of that resolution work on one arbiter at the same time.
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 |
nft_hook_config_updated |
(admin, enabled, ledger_sequence) |
Admin toggled the NFT mint hook via set_nft_hook_enabled |
nft_mint_hook |
(shipment_id) topic, (buyer, supplier, total_amount, ledger_sequence, metadata_hash) data |
Final milestone completed while the NFT mint hook is enabled |
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 |
| 18 | MaxShipmentValueExceeded — total_amount exceeds the admin-configured cap |
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 ← test module orchestrator
├── test_common.rs ← shared test setup, fixtures, helpers
├── test_shipment.rs ← shipment lifecycle tests (create, confirm, cancel)
├── test_dispute.rs ← dispute workflow tests (raise, resolve, cooldown)
├── test_admin.rs ← admin control tests (pause, blacklist, settings)
├── test_query.rs ← read-only query tests (completion %)
├── constants.rs ← contract constants
├── storage.rs ← storage layer
├── admin.rs ← admin functions
├── benchmarks.rs ← performance benchmarks
└── [other test files] ← edge cases, stress tests, advanced features
Tests are split by domain for clarity and to reduce merge conflicts:
| File | Purpose | Example Tests |
|---|---|---|
test_common.rs |
Shared setup & utilities | setup(), build_milestones(), create_standard_shipment() |
test_shipment.rs |
Shipment lifecycle | test_create_shipment_success, test_full_shipment_lifecycle, test_cancel_shipment |
test_dispute.rs |
Dispute resolution | test_raise_dispute, test_resolve_dispute, test_dispute_cooldown_enforced |
test_admin.rs |
Admin controls | test_pause_blocks_create_shipment, test_blacklist_removal_restores_participation |
test_query.rs |
Read-only queries | test_get_completion_percentage_* |
All shared fixtures and helper functions are centralized in test_common.rs to avoid duplication.
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.
For a detailed threat analysis and security model, see docs/SECURITY.md.
- 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