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-frontendReact + Freighter wallet UIchainsetttle-backendNode.js API, notifications, off-chain metadata
Table of Contents How It Works Architecture Data Structures Contract Functions Allowed token list Invoice hash rebalance_milestones Partial Disputes & Escalation Checks Advance Payment Lifecycle Admin Succession & Emergency Recovery Contract Upgrades & Migration Events Error Codes Project Structure Prerequisites Setup & Installation Running Tests Milestone Amendment History Tracking Building Deploying to Testnet Deploying to Mainnet Security Considerations See detailed security model: docs/SECURITY.md Roadmap
How It Works
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.
Architecture
┌──────────────────────────────────────────────────────────────┐
│ 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)
Roles
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
Data Structures
Milestone
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
}Shipment
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
}ReputationScore
get_reputation(supplier) returns cumulative supplier outcome counters. It is
not a single weighted score; applications can use these counters to calculate
their own supplier rating:
pub struct ReputationScore {
pub completed: u32, // shipments completed successfully
pub disputed: u32, // disputes raised against the supplier
pub cancelled: u32, // shipments cancelled or expired
}The counters are updated automatically:
completed increases once when all milestones in a shipment reach a
completed state, including successful dispute resolution.
disputed increases when a buyer raises a full or partial dispute. Resolving
the dispute does not remove the historical count.
cancelled increases when a shipment is cancelled by the buyer or supplier,
or when the buyer claims a deadline refund.
All counters start at 0 for a supplier with no reputation record and are
monotonically increasing (with saturating u32 arithmetic).
Interpreting supplier reputation
get_reputation intentionally exposes raw on-chain facts instead of imposing
one platform-wide rating formula. A frontend, marketplace, or backend can
choose how much weight to give each outcome, while different applications can
use the same contract data for different purposes.
Important details for scoring integrations:
completed is incremented once per shipment, not once per milestone. A
shipment completed after an approved dispute still counts as completed.
disputed is incremented once whenever a dispute is opened against one of
the supplier's milestones. A shipment with disputes on several milestones
can therefore contribute more than one disputed count.
cancelled is incremented once when the shipment becomes cancelled or
expired. This includes buyer cancellation, eligible supplier cancellation,
and a successful deadline refund claim.
A dispute that is later rejected or automatically resolved does not erase
the historical disputed counter. Applications should treat it as an
activity or risk signal rather than proof that the supplier lost the
dispute.
The counters do not include payment volume, delivery speed, milestone
size, dispute outcome, or the identity of the party that raised the dispute.
Fetch shipment records separately when those dimensions are required.
For example, an application may calculate a simple historical completion rate
from the returned counters:
outcomes = completed + disputed + cancelled
completion_rate = completed / outcomes // when outcomes > 0
This is only an application-level metric, not a value returned or enforced by
the contract. Because disputed counts milestone dispute events while the
other fields count shipment outcomes, the ratio should be labelled clearly
and should not be presented as an official ChainSettle score. A safer UI can
show the three counters separately and display Not enough history until a
minimum number of completed shipments has been reached.
Example read-only response:
{
"completed": 18,
"disputed": 2,
"cancelled": 1
}The example represents 18 completed shipments, two disputed milestones, and
one cancelled or expired shipment. It does not mean that 18 of 21 shipments
were completed, because the disputed value is not a shipment-level count.
The getter requires no supplier signature and does not create a reputation
record. A supplier with no stored record returns the default value with all
fields set to zero. Reputation storage uses a persistent entry with a
renewable Soroban TTL, so indexers should periodically refresh their cached
value and should not assume that an absent record proves the supplier has
never used the contract.
MilestoneStatus state machine
Pending
└─ submit_proof() ──→ ProofSubmitted
├─ confirm_milestone() ──→ Confirmed (payment released)
├─ raise_dispute() ──→ Disputed (full 100%)
│ ├─ resolve_dispute(approve=true) ──→ Resolved (payment released)
│ └─ resolve_dispute(approve=false) ──→ Pending (supplier resubmits)
└─ raise_partial_dispute() ──→ Disputed (uncontested released)
├─ resolve_dispute(approve=true) ──→ Resolved (contested paid to supplier)
└─ resolve_dispute(approve=false) ──→ Resolved (contested refunded to buyer)
Contract Functions
All functions require the relevant party to sign the transaction (Soroban auth).
init(admin: Address)
Initialises the contract. Called once by the deployer right after deployment.
create_shipment(...) → String
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 — Stellar Asset Contract address used for escrow
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)
Allowed token list
The token parameter on create_shipment is checked against an admin-managed allowlist (DataKey::AllowedTokens). By default the list is empty, which means open mode: any Stellar Asset Contract (SAC) address is accepted. Once the admin adds at least one token, create_shipment only accepts tokens on that list — a non-listed token panics with "token is not in the approved whitelist".
Function Who Effect
add_allowed_token(token) Admin only Approves a SAC address for use as create_shipment's token parameter
remove_allowed_token(token) Admin only Revokes approval; existing shipments that already use the token are unaffected
get_allowed_tokens() → Vec<Address> Anyone (read-only) Returns the current allowlist (empty = open mode)
The allowlist gates shipment creation only. After a shipment is created, all payouts (confirm_milestone, dispute resolution, cancellation refunds, etc.) always use the token address stored on that shipment — they never re-check the allowlist.
submit_proof(caller, shipment_id, milestone_index, proof_hash, proof_type)
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.
Invoice hash
An invoice hash is an optional, immutable BytesN<32> commitment (e.g. a SHA-256 digest of an off-chain invoice PDF) that the supplier can attach to a milestone for audit and reconciliation. It does not move funds; it only records a cryptographic reference next to the milestone after proof is on-chain.
Function Who When
attach_invoice_hash(caller, shipment_id, milestone_index, invoice_hash) Supplier only Shipment Active, milestone past Pending (proof already submitted), and no hash set yet
get_invoice_hash(shipment_id, milestone_index) → Option<BytesN<32>> Anyone (read-only) Returns the stored hash, or None if none was attached
Rules:
Only the shipment's supplier may call attach_invoice_hash (buyer, logistics, arbiter, and strangers are rejected).
Proof must already be submitted for that milestone — attaching while still Pending panics with "proof must be submitted before attaching invoice hash".
The hash is immutable: a second attach for the same milestone panics with "invoice hash already set and is immutable".
Emits invoice_hash_attached with (milestone_index, invoice_hash, caller).
Proof content-type whitelist
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.
confirm_milestone(buyer, shipment_id, milestone_index)
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.
batch_confirm_milestones(buyer, shipment_id, milestone_indices)
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
ProofSubmitted status, 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_confirmed event and
releases that milestone's proportional payment to the supplier (same fee and
advance-deduction logic as confirm_milestone).
Passing an empty milestone_indices list 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 as confirm_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]'raise_dispute(buyer, shipment_id, milestone_index)
Buyer disputes 100% of a ProofSubmitted or ConfirmedHeld milestone's payment. Freezes the milestone in Disputed state — no payment can be released until arbiter resolves.
raise_partial_dispute(buyer, shipment_id, milestone_index, contested_percent: u32)
Buyer contests only a specified percentage (contested_percent from 1 to 99) of a milestone's value. The uncontested portion (100 - contested_percent)% is immediately calculated and released to the supplier, while the contested portion is held in escrow in Disputed state pending arbiter resolution. Panics if an approved advance exists for the milestone.
resolve_dispute(arbiter, shipment_id, milestone_index, approve: bool)
Arbiter resolves a Disputed milestone (full or partial).
For full disputes:
approve = true → releases payment to supplier (minus arbiter fee), status → Resolved
approve = false → refunds buyer / rejects proof, status → Pending (supplier must resubmit)
For partial disputes:
approve = true → releases contested portion to supplier (minus arbiter fee), status → Resolved
approve = false → refunds contested portion to buyer (minus arbiter fee), status → Resolved
check_escalation(shipment_id, milestone_index)
Checks whether an open dispute on a milestone has exceeded the admin-configured escalation threshold without arbiter resolution. Emits dispute_escalated event if the threshold is met or exceeded. Callable by anyone.
set_escalation_threshold(admin, threshold_ledgers: u32)
Admin-only. Sets the dispute escalation threshold in ledgers (0 = disabled). get_escalation_threshold() → u32 returns the current threshold.
cancel_shipment(buyer, shipment_id)
Cancels the shipment if no milestones have been confirmed yet.
Returns all locked funds to the buyer.
top_up_escrow(buyer, shipment_id, additional_amount)
Buyer adds more funds to an already active shipment's escrow.
Use this when the shipment scope or expected payout increases after
creation, for example:
the order was expanded and the buyer wants to cover the extra cost
milestone payments need to be increased before the shipment finishes
the buyer wants to add a buffer so later confirmations do not run short
The call is buyer-only and only works while the shipment is Active.
It transfers the additional USDC into the contract, increases the
shipment's total_amount, and leaves the milestone percentages unchanged.
That means every milestone keeps the same percentage split, but each
milestone's absolute payout becomes larger because the escrow base grew.
Partial Disputes & Escalation Checks
Partial Disputes Mechanics
In real-world supply chain transactions, delivered goods may be partially damaged, incomplete, or slightly off-specification without justifying a total transaction freeze. ChainSettle supports partial disputes via raise_partial_dispute():
1–99% Contested Split: The buyer specifies contested_percent (must be between 1 and 99). The contract calculates:
Uncontested share: (100 - contested_percent)% of milestone value.
Contested share: contested_percent% of milestone value.
Immediate Uncontested Payout: The uncontested portion is immediately released to the supplier at dispute-raising time (net of standard platform fees), updating the shipment's released_amount and reducing total escrow balance (TotalEscrowed).
Advance Request Constraint: If an approved advance request already exists on the milestone, raise_partial_dispute() panics with "partial dispute not allowed when an approved advance exists for this milestone". Buyers must use full raise_dispute() when an advance has been approved.
Resolution Path (resolve_dispute):
Arbiter Approves (approve = true): Supplier receives the contested portion minus the arbiter fee. Dispute bond is returned to the buyer. Milestone status transitions to Resolved.
Arbiter Rejects (approve = false): Buyer receives a refund of the contested portion minus the arbiter fee. Milestone status transitions to Resolved (unlike full disputes where rejection resets status to Pending, partial disputes finalize at Resolved because the uncontested portion was already paid out).
Dispute Escalation & Escalation Threshold
To ensure disputes do not stall indefinitely without arbiter action, ChainSettle provides a dispute escalation check mechanism:
Configuring the Threshold: The contract admin configures the global escalation threshold using set_escalation_threshold(admin, threshold_ledgers). Setting threshold_ledgers = 0 disables escalation checks (default).
Checking Escalation (check_escalation): Anyone (buyer, supplier, arbiter, or automated backend services) can invoke check_escalation(shipment_id, milestone_index).
Ledger Sequence Comparison: check_escalation() inspects the target milestone:
Verifies the milestone is in MilestoneStatus::Disputed.
Compares the current ledger sequence (env.ledger().sequence()) against opened_ledger + threshold (where opened_ledger is recorded in dispute_opened_ledger when the dispute was raised).
If current_ledger >= opened_ledger + threshold, the contract emits a dispute_escalated event containing (milestone_index, opened_ledger, current_ledger).
Off-Chain Handling: The backend service (chainsetttle-backend) monitors dispute_escalated events to alert platform administrators, notify the assigned arbiter, or trigger automated resolution escalation workflows.
get_shipment(shipment_id) → Shipment (read-only)
Returns the full shipment record.
get_milestone(shipment_id, milestone_index) → Milestone (read-only)
Returns a single milestone.
get_reputation(supplier) → ReputationScore (read-only)
Returns the supplier's cumulative completed, disputed, and cancelled
shipment counters. The call requires no authorization and returns zeroes when
the supplier has no recorded activity; it does not calculate or persist a
separate weighted rating.
get_escrow_balance(shipment_id) → i128 (read-only)
Returns the amount of USDC still locked in escrow.
Milestone Deadline Extensions
Suppliers can ask for more ledger time on an active shipment milestone,
and buyers decide whether to accept that new deadline. The flow is
single-request: each milestone can have only one pending extension
request at a time.
request_extension(caller, shipment_id, milestone_index, extra_ledgers)
— supplier-only. Creates a pending request to add extra_ledgers to
the milestone deadline and emits extension_requested. The shipment
must be active, the milestone index must exist, and the call fails if
that milestone already has a pending extension request.
approve_extension(buyer, shipment_id, milestone_index) — buyer-only.
Approves the pending request, clears it, and writes the effective
milestone deadline. If a deadline already exists, the contract adds
the requested extra ledgers to it; otherwise, it starts from the
current ledger sequence and adds the requested extra ledgers. The call
emits extension_approved.
deny_extension(buyer, shipment_id, milestone_index) — buyer-only.
Denies the pending request, clears it, leaves the current deadline
unchanged, and emits extension_denied.
get_milestone_deadline(shipment_id, milestone_index) → u32
(read-only) — returns the stored effective deadline ledger for the
milestone, or 0 when no deadline has been set.
Extension request, approval, and denial calls are paused by the emergency
circuit breaker; get_milestone_deadline remains available while paused.
Emergency pause (circuit breaker)
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 emits contract_paused.
unpause(admin) — clears the paused flag and emits contract_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.
Rate-limiting circuit breaker
A sliding-window rate limiter that caps total token outflow over a rolling
period of ledger sequences. It is separate from the emergency pause and
protects against abnormal drain scenarios (e.g. exploit, compromised key)
without halting the entire contract.
set_circuit_breaker(admin, limit: i128, window_ledgers: u32)
Admin-only. Configures the circuit breaker parameters and immediately
resets the current window counter to zero.
Parameter Description
limit Maximum cumulative outflow (in token smallest units) allowed within one window. Pass 0 to disable the circuit breaker entirely.
window_ledgers Duration of the tracking window in ledger sequences. After this many ledgers have elapsed since the window start, the counter resets automatically.
When the breaker is active, every payment that moves tokens out of escrow
(confirm_milestone, resolve_dispute, partial dispute uncontested
release, advance payouts, claim payouts, and amendments that release
funds) is checked against the window:
If the current ledger has moved past window_start + window_ledgers,
the window resets — outflow counter returns to zero and the window
start updates to the current ledger.
The pending payment is added to the running window_outflow counter.
If the new total would exceed limit, the call panics with
"circuit breaker triggered: outflow limit exceeded" and no state
is mutated.
Because the check runs before the payment executes, a triggered
breaker leaves all funds exactly where they were — no partial transfers
occur.
get_circuit_breaker() → (i128, u32, u32, i128) (read-only)
Returns the current circuit breaker configuration and window state:
(limit, window_ledgers, window_start, window_outflow). When limit
is 0, the circuit breaker is disabled and the other fields are
irrelevant.
Example — set a 100,000 USDC per 1,000-ledger cap:
stellar contract invoke \
--id <CONTRACT_ID> \
--source admin-account \
--network testnet \
-- set_circuit_breaker \
--admin <ADMIN_ADDRESS> \
--limit 10000000000000 \
--window_ledgers 1000Referral fee program
The referral fee mechanism lets an admin reward a referrer when a shipment
completes successfully. The fee is paid out of the protocol fee that would
otherwise go to the platform treasury, so the referrer receives a share of
that fee instead of the full amount being retained by the contract.
set_referral_fee_bps(admin, bps: u32) — admin-only. Sets the referral
fee as a basis-point value between 0 and 10_000.
get_referral_fee_bps() → u32 (read-only) — returns the current
referral fee setting, defaulting to 500 bps (5%) if no value has
been configured yet.
A basis point (bps) is one-hundredth of a percent: 1 bps = 0.01%, and
10_000 bps = 100%. The contract computes the referral payout as a
fraction of the total protocol fee using the configured bps value, so a
setting of 500 bps means the referrer receives 5% of that fee on
shipment completion.
Volume-based fee tiers
Buyers can receive a reduced platform fee once their lifetime shipment volume
crosses admin-configured thresholds. Each FeeTier entry is a pair:
| Field | Type | Meaning |
|---|---|---|
min_lifetime_volume |
i128 |
Minimum cumulative volume (token base units) the address must have accrued |
fee_bps |
u32 |
Fee in basis points applied when that threshold is met |
set_fee_tiers(admin, tiers: Vec<FeeTier>) — admin-only. Stores up to 5 tier
entries on instance storage. Prefer listing higher min_lifetime_volume values
first; at lookup time the contract still picks the lowest fee_bps among
every tier whose threshold the address meets.
get_fee_tier(address) → u32 (read-only) — returns the effective fee bps for
address:
- Load the address's
LifetimeVolume. - Among configured tiers where
volume >= min_lifetime_volume, take the smallestfee_bps. - If no tier matches (or no tiers are configured), fall back to
FeeConfig.fee_bps, or0if fee config was never set.
Per-address application: when a shipment is created, the primary buyer's
resolved tier bps is locked onto that shipment (ShipmentFeeBps) so later
volume changes do not alter fees mid-flight. Subsequent payouts for that
shipment use the locked value; get_fee_tier always reflects the address's
current volume against the live tier table (useful for dashboards and for
previewing the rate the next shipment would lock).
set_max_concurrent_disputes(admin, limit: u32)
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.
Advance Payment Lifecycle
The advance payment flow lets a supplier request early access to a
portion of a milestone's payment before submitting proof. This is useful
when the supplier needs working capital (e.g. to purchase materials or
cover logistics costs) before the milestone can be fulfilled.
set_max_advance_percent(admin, percent: u32) — admin-only. Sets the
maximum percentage of a milestone's payment that a supplier may request
as an advance. percent must not exceed 100. Defaults to 30 if the
admin has never called this function.
get_max_advance_percent() → u32 (read-only) — returns the current
advance percentage cap, defaulting to 30.
request_advance(caller, shipment_id, milestone_index, advance_percent)
— supplier-only. Requests an advance draw of up to advance_percent of
the milestone's payment value. The milestone must be in Pending status.
advance_percent is capped by the admin-configured max_advance_percent;
if it exceeds the cap, the call panics with "AdvanceExceedsMax".
A milestone may have only one pending advance request at a time. If an
earlier request was already approved, a second request panics with
"AdvanceAlreadyApproved". The buyer can later deny a pending request
simply by not approving it — there is no explicit deny_advance function;
a new request_advance overwrites the previous unapproved request.
Emits advance_requested on success.
approve_advance(buyer, shipment_id, milestone_index) — buyer-only.
Approves a pending advance request for the given milestone. The contract
immediately transfers the advance amount (milestone_payment * advance_percent / 100)
from escrow to the supplier. The shipment's total_advanced_amount is
incremented, and the total escrowed balance is decremented accordingly.
After approval, the advance record is marked approved so the same
request cannot be approved twice. The advance amount is deducted from
the milestone's payout when the milestone is later confirmed (see
confirm_milestone / batch_confirm_milestones), so the supplier
receives only the remaining balance at that point. Emits
advance_approved on success.
Deduction on confirmation:
When the buyer confirms a milestone that has an approved advance, the
confirmation flow calls consume_advance_for_milestone which reads the
stored AdvanceRequest, returns the already-advanced amount, removes
the request from storage, and adjusts the shipment's
total_advanced_amount. The net payment to the supplier during
confirmation is therefore milestone_payment - advance_amount (minus
any applicable protocol fees). If the milestone has no approved advance,
the full milestone payment is released as normal.
Constraints:
- Buyer raises a partial dispute: If an approved advance exists for a
milestone,
raise_partial_disputepanics with"partial dispute not allowed when an approved advance exists for this milestone". The buyer must use the fullraise_disputeflow instead. - Milestone status: The milestone must be
Pendingwhen the advance is requested. Once proof has been submitted (ProofSubmitted), the advance window is closed and the supplier must wait for confirmation. - Escrow coverage: The advance is paid from escrowed funds, so the
shipment must have sufficient unlocked balance. The total advanced
amount across all milestones is tracked in
shipment.total_advanced_amountand is excluded from the recoverable escrow balance used byemergency_recover,supplier_cancel, and theget_escrow_balanceview. Example lifecycle:
# 1. Supplier requests a 20% advance on milestone 0
stellar contract invoke \
--id <CONTRACT_ID> \
--source supplier-account \
--network testnet \
-- request_advance \
--caller <SUPPLIER_ADDRESS> \
--shipment_id "SHIP-001" \
--milestone_index 0 \
--advance_percent 20
# 2. Buyer approves the advance — funds are transferred immediately
stellar contract invoke \
--id <CONTRACT_ID> \
--source buyer-account \
--network testnet \
-- approve_advance \
--buyer <BUYER_ADDRESS> \
--shipment_id "SHIP-001" \
--milestone_index 0
# 3. Later, buyer confirms the milestone — the advance is deducted
stellar contract invoke \
--id <CONTRACT_ID> \
--source buyer-account \
--network testnet \
-- confirm_milestone \
--caller <BUYER_ADDRESS> \
--shipment_id "SHIP-001" \
--milestone_index 0The supplier receives the advance amount at step 2 and only the remaining
balance at step 3. If the milestone payment is 100 USDC with a 20 %
advance, the supplier gets 20 USDC at step 2 and roughly 80 USDC (minus
fees) at step 3.
Supplier Address Whitelisting
The supplier-address whitelist lets an admin operate a closed or curated
supplier network. It is enforced only when a new shipment is created:
when the list is non-empty, the shipment's supplier must be on it or
create_shipment panics with "unauthorized". It does not grant an
address any additional permissions and does not restrict buyers, logistics
providers, or arbiters. Existing shipments are unaffected if their supplier
is later added to or removed from the list.
The whitelist is open by default. An empty list means every supplier is
allowed; adding the first address switches the contract to restricted mode.
Removing the last address switches it back to open mode.
Function Who Behaviour
add_to_whitelist(admin, address) Admin only Adds address as an approved supplier. Adding an address already on the list is a no-op.
remove_from_whitelist(admin, address) Admin only Removes address from the approved suppliers. It does not blacklist the address.
is_whitelisted(address) → bool Anyone (read-only) Returns true when the address is listed, or when the list is empty (open mode).
Interaction with address blacklisting
Whitelisting is an allowlist for the supplier role; blacklisting is a
denylist for every role in a new shipment (buyer, supplier, logistics, or
arbiter). Blacklisting takes precedence: a supplier that is both whitelisted
and blacklisted cannot be used to create a shipment. Conversely, removing an
address from the blacklist only removes that denial; the supplier must still
be on a non-empty whitelist to be eligible. Neither control changes an
already-created shipment.
This supplier whitelist is separate from the approved-token whitelist and the
per-milestone proof content-type whitelist.
Arbiter Pool & Assignment
ChainSettle supports an active pool of trusted arbiters, allowing for automatic assignment to distribute the dispute resolution workload.
add_arbiter_to_pool(admin, arbiter: Address): Admin-only. Adds an arbiter address to the active pool.
remove_arbiter_from_pool(admin, arbiter: Address): Admin-only. Removes an arbiter from the active pool.
get_arbiter_pool() → Vec<Address> (read-only): Returns the current list of active arbiters in the pool.
When creating a shipment, a buyer must specify an arbiter. By querying get_arbiter_pool(), a frontend or backend service can automatically select an arbiter using a round-robin assignment strategy. This ensures that disputes are evenly distributed among all trusted arbiters in the pool rather than overloading a single resolver.
Admin Action Audit Trail
The contract maintains an immutable audit log of all administrative actions
for transparency and governance accountability. Every admin function call
(pause, fee changes, blacklist operations, etc.) is automatically recorded
with context about who performed the action and when.
get_admin_log() → Vec<AuditEntry> (read-only)
Returns the complete history of admin actions performed on the contract.
Each AuditEntry contains:
pub struct AuditEntry {
pub action: Symbol, // Admin function called (e.g. "pause", "set_fee_config")
pub caller: Address, // Admin address who performed the action
pub ledger: u32, // Ledger sequence number (timestamp proxy)
pub detail: Symbol, // Context detail (e.g. "contract_paused", "fee_config_updated")
}Logged actions include:
Action Triggered by Detail
pause pause() contract_paused
unpause unpause() contract_unpaused
set_fee_config set_fee_config() fee_config_updated
set_max_concurrent_disputes set_max_concurrent_disputes() limit_updated
set_min_milestone_percent set_min_milestone_percent() percent_updated
set_max_advance_percent set_max_advance_percent() percent_updated
set_nft_hook_enabled set_nft_hook_enabled() nft_hook_updated
blacklist_address blacklist_address() address_blacklisted
remove_from_blacklist remove_from_blacklist() address_whitelisted
add_allowed_token add_allowed_token() allowed_token_added
remove_allowed_token remove_allowed_token() allowed_token_removed
add_arbiter_to_pool add_arbiter_to_pool() arbiter_added
remove_arbiter_from_pool remove_arbiter_from_pool() arbiter_removed
Usage example:
# Query the admin audit log via CLI
stellar contract invoke \
--id <CONTRACT_ID> \
--network testnet \
-- get_admin_logWhy this matters: Transparency: Anyone can verify when contract parameters were changed and by whom Governance: Off-chain tools can monitor the log and alert stakeholders to admin actions Forensics: If a dispute arises about contract configuration, the audit trail provides a tamper-proof record Accountability: Admin actions are permanently recorded on-chain, making unilateral changes visible to all participants The audit log uses persistent storage and is retained for the lifetime of the contract. Unlike events (which may be pruned by Horizon), these entries remain queryable indefinitely.
Admin Succession & Emergency Recovery
Two-step admin handover
Admin control transfers to a new address through a nominate/accept flow
rather than a single-step reassignment, so a typo'd or unreachable address
can never accidentally lock out contract administration:
nominate_admin(current_admin, nominee: Address) — Admin-only. Records
nominee as the pending admin and emits admin_nominated. This does
not change who the admin is; the current admin keeps full authority
until the nominee actively accepts.
accept_admin(nominee: Address) — Must be called by the nominee itself.
Fails with "no pending nomination" if none exists, or "unauthorized"
if called by any address other than the pending nominee. On success, it
sets nominee as the new admin, clears the pending nomination, and emits
admin_transferred with the old and new admin addresses.
revoke_nomination(current_admin) — Admin-only. Clears any pending
nomination before it's accepted (e.g. if the wrong address was nominated
or the handover is called off) and emits nomination_revoked.
Because the nominee must sign accept_admin themselves, admin rights can
never be transferred to an address that can't authorize transactions, and
the outgoing admin retains control for the entire window until acceptance.
emergency_recover(admin, shipment_id: String)
A last-resort, admin-only escape hatch for shipments whose escrowed funds
would otherwise be stuck indefinitely — for example, a shipment where the
buyer or supplier has gone permanently unresponsive and normal completion,
cancellation, or dispute paths can't be reached. Calling it:
Is blocked while the contract is paused (subject to assert_not_paused,
same as the rest of the shipment lifecycle) and requires the admin's
authorization.
Only works on a shipment that is currently Active; it panics with
"shipment is not active" otherwise.
Only becomes callable once RECOVERY_THRESHOLD_LEDGERS have elapsed since
the shipment's created_at ledger — panicking with
"recovery threshold not reached" before then. This threshold is a long,
fixed cooldown (currently 12,614,400 ledgers), so it's meant for
genuinely abandoned shipments, not routine intervention.
Transfers whatever remains locked in escrow (total_amount - released_amount) from the contract to the admin address, then marks the
shipment Cancelled and updates the token's total-escrowed accounting
accordingly.
Emits an emergency_recovery event containing the recovered amount and
the admin address, keyed to the shipment_id.
Because recovered funds go to the admin rather than back to the buyer,
this function is meant purely as a break-glass measure for abandoned
escrow, not as a way to resolve normal disputes — those should go through
raise_dispute / raise_partial_dispute and resolve_dispute instead.
Contract Upgrades & Migration
ChainSettle supports in-place WebAssembly (WASM) contract upgrades on Soroban using the contract deployer's update_current_contract_wasm interface. In-place upgrades preserve the existing contract ID, token escrow balances, state indices, and historical shipment records.
upgrade(admin: Address, new_wasm_hash: BytesN<32>)
Replaces the contract's executing WASM code in-place with the compiled WebAssembly module corresponding to new_wasm_hash.
- Who can call: Single contract Admin only (
admin.require_auth()+admin == stored_admin). - Behavior: Updates the WASM bytecode in-place and emits a
contract_upgradedevent with(new_wasm_hash, ledger_sequence). - Multisig Protection: If multi-admin governance (
DataKey::MultiAdminConfig) has been initialized viainitialize_multisig_admin, single-keyupgrade()is automatically disabled and panics with"upgrade multisig is configured; use propose_upgrade/approve_upgrade instead".
Multisig Upgrade Governance When multi-admin governance is enabled, upgrades must be proposed and approved by a threshold of registered admin keys:
propose_upgrade(admin: Address, new_wasm_hash: BytesN<32>) → u64— Any registered multisig admin key can propose an upgrade. The proposer's approval is recorded immediately. Returns a uniqueproposal_id.approve_upgrade(admin: Address, proposal_id: u64)— Registered admin keys approve a pending proposal. Once the approval count reaches the configured threshold (config.threshold), the contract automatically executesupdate_current_contract_wasmand emitsupgrade_executed.cancel_upgrade(admin: Address, proposal_id: u64)— Registered admin key cancels a pending proposal. Emitsupgrade_cancelled.get_upgrade_proposal(proposal_id: u64) → Option<UpgradeProposal>(read-only) — Queries details and collected approvals for a pending upgrade proposal.
migrate(env: Env)
Post-upgrade state migration entrypoint.
- Who can call: Public / post-upgrade execution hook.
- What it does post-upgrade: Called once immediately after a WASM bytecode upgrade to execute state schema transformations, re-key storage entries (e.g. migrating
V1_*storage keys toV2_*schema), or set new storage defaults. - Idempotency: Designed to be safe to invoke post-upgrade without side-effects when no data model changes are required (currently operates as an idempotent stub for the active contract version).
Events
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
partial_uncontested_released (shipment_id) topic, (milestone_index, uncontested_amount, fee_amount) data Uncontested portion released immediately at partial dispute time
dispute_resolved (shipment_id, milestone_index, approved) Arbiter resolves dispute
dispute_escalated (shipment_id) topic, (milestone_index, opened_ledger, current_ledger) data Dispute open duration surpassed escalation threshold
escalation_threshold_set threshold_ledgers Admin configured escalation threshold
shipment_cancelled (shipment_id, refund_amount) Shipment cancelled
milestones_rebalanced (buyer, new_percents) Buyer rebalanced milestone percentages
nft_hook_config_updated (admin, enabled, ledger_sequence) Admin toggled the NFT mint hook via set_nft_hook_enabled
circuit_breaker_set (limit, window_ledgers) Admin configured rate-limiting circuit breaker
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
contract_upgraded (new_wasm_hash, ledger_sequence) Single-key WASM upgrade executed
upgrade_proposed (new_wasm_hash, admin, 1) Multisig WASM upgrade proposed
upgrade_approved (admin, approvals_count) Multisig WASM upgrade approved by an admin
upgrade_cancelled admin Multisig WASM upgrade proposal cancelled
upgrade_executed new_wasm_hash Multisig WASM upgrade executed after threshold reached
The backend service (chainsetttle-backend) listens for these events and
sends push notifications to the relevant parties.
Error Codes
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
19 CircuitBreakerTripped — payment would exceed the rate-limiting outflow cap
Project Structure
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
Test File Organization
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.
Prerequisites Install the following before you begin:
- Rust + wasm32 target
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup target add wasm32v1-noneRequires Rust v1.84.0 or higher. 2. Stellar CLI
# macOS
brew install stellar-cli
# Linux / WSL
cargo install --locked stellar-cli --features optVerify installation:
stellar --version- A Stellar testnet account (for deployment)
stellar keys generate --global my-account --network testnet
stellar keys fund my-account --network testnetSetup & Installation
# Clone the repo
git clone https://github.com/your-org/chainsetttle-contract.git
cd chainsetttle-contract
# Check all dependencies compile
cargo checkRunning Tests
# 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
Building
# 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.
Deploying to Testnet
# 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.
Initialize after deployment
After deploying, call init once to set the admin:
stellar contract invoke \
--id <CONTRACT_ID> \
--source my-account \
--network testnet \
-- init \
--admin <YOUR_ADDRESS>Create a test shipment via CLI
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"}]'Deploying to Mainnet
⚠️ 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
Security Considerations
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 transfer calls in confirm_milestone and resolve_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_proof that the previous milestone is Confirmed — 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 / 100 doesn'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_ttl via the backend before entries archive.
Contract Upgrades: The contract supports in-place WASM code upgrades via Soroban's deployer mechanism (upgrade for single-admin mode and propose_upgrade/approve_upgrade for multi-admin governance). Post-upgrade data migration is handled via the migrate entrypoint.
For a detailed threat analysis and security model, see docs/SECURITY.md.
Roadmap
[x] Core escrow + milestone logic
[x] Dispute resolution via arbiter
[x] USDC token transfers via Stellar Asset Contract
[x] Full unit test suite
[ ] Sequential milestone enforcement (optional)
[ ] Multi-token support (XLM, EURC)
[ ] Partial cancellation (after some milestones confirmed)
[x] Contract upgrade mechanism
[ ] Mainnet deployment + verification
[ ] Integration with chainsetttle-backend event listener
[ ] Integration with chainsetttle-frontend Freighter wallet
Contributing
Pull requests welcome. Please run cargo fmt and cargo test before submitting.
License
MIT