Trustless escrow for social commerce on Stellar: funds move only when the contract can prove the requested lifecycle event has happened.
This repository contains the TrustLink escrow smart contract implemented for Stellar’s Soroban runtime, plus a small set of developer tooling and language bindings to interact with the contract.
At a high level, TrustLink replaces “trust me” payments with a lifecycle that is enforced in code:
- A seller creates an escrow agreement.
- A buyer funds the escrow by transferring tokens into the contract.
- The seller marks the order as shipped.
- The system either:
- lets the buyer confirm delivery (ending the deal), or
- allows the buyer to raise a dispute before a deadline, after which an authorized resolver/oracle decides the outcome, or
- allows auto-release after time windows elapse if no dispute remains unresolved.
The core goal is to ensure that each outcome—delivery completion, dispute release, dispute refund, or cancellation—happens via contract-enforced rules with clear authorization boundaries.
- 1. What is this project?
- 2. Who are the actors?
- 3. Trust model & oracles
- 4. Escrow lifecycle (state machine)
- 5. Contract architecture
- 6. Fee model
- 7. Operational controls
- 8. Public API reference
- 9. Error codes
- 10. Security considerations
- 11. Testing strategy
- 12. Repository layout
- 13. TypeScript bindings & client usage
- 14. Contributing
- 15. License
TrustLink is a trustless escrow protocol designed for peer-to-peer social commerce. In typical social commerce scenarios—payments initiated through DMs, chats, or lightweight marketplace workflows—buyer and seller rarely share a traditional, enforceable contract. Disputes are commonly handled manually and inconsistently.
This smart contract enforces payment outcomes on-chain.
The contract is the escrow vault and arbitration enforcement layer. It:
- accepts token deposits from a buyer into contract-held escrow state,
- releases funds to a seller only after delivery-related lifecycle transitions,
- supports dispute raising by the buyer within a time window,
- finalizes disputed escrows by requiring a resolver oracle to sign
resolve_dispute, - supports auto-release after time windows elapse (permissionless triggering),
- supports a global pause flag for operational safety.
“Trustless” does not mean “no trust anywhere.” Instead, it means that the protocol eliminates the need to trust a custodian to move funds correctly. With the exception of the dispute resolver/oracle’s judgment, outcomes are determined by:
- deterministic state machine transitions,
- deterministic token transfer logic, and
- deterministic time checks using ledger timestamps.
Because all transfers are initiated by contract code, there is no discretionary third party movement of funds.
TrustLink defines several distinct roles:
-
Seller
- Creates the escrow agreement.
- Marks the escrow as shipped.
- Receives the payout on success.
-
Buyer
- Funds the escrow.
- Confirms delivery (ending the escrow).
- Raises disputes (within the deadline).
-
Resolver (oracle for dispute finality)
- Only role that can finalize an escrow once it enters
Disputed. - Resolver address is stored per escrow at creation time.
- Only role that can finalize an escrow once it enters
-
Admin (operational control)
- Pauses/unpauses the contract.
- Rotates admin address.
- Configures default fee parameters and arbitration fee.
-
Fee Collector
- Receives protocol fee withdrawals.
-
Any caller
- Can trigger
auto_releaseonce the escrow satisfies time conditions.
- Can trigger
The repository includes ORACLE_TRUST_MODEL.md, which documents the central trust assumptions. Summarizing that document in code terms:
When the buyer raises a dispute, the contract stores an evidence hash and metadata, but it cannot verify the underlying evidence (shipments, courier records, legal documents). Real-world verification is impossible for on-chain code without trusted inputs.
Therefore, the contract embeds a resolver oracle address per escrow and requires the resolver to authenticate the dispute outcome using Soroban’s require_auth().
If the resolver key is compromised, the attacker can finalize disputes in any direction by signing resolve_dispute.
Mitigations recommended by the repository documentation:
- use multisig or hardened accounts for the resolver,
- ensure strong key management and liveness monitoring,
- treat evidence hashes as commitments to off-chain evidence rather than verifiable on-chain content.
Admin can pause the contract and update fee parameters. Admin compromises primarily affect liveness and economics, not the ability for arbitrary accounts to move escrow funds.
The escrow lifecycle is a finite state machine. The escrow states are defined in contracts/escrow/src/types.rs:
PendingFundedShippedCompletedDisputedRefundedCanceled
-
Creation
create_escrowcreates a new escrow record and sets state toPending.
-
Funding
fund_escrowrequires buyer auth.- State must be
Pending. - Tokens are transferred from buyer to the contract.
- State becomes
Funded. funded_atanddispute_deadlineare recorded.
-
Shipping
mark_shippedrequires seller auth.- State must be
Funded. tracking_idis saved (bounded length).- State becomes
Shipped.
-
Delivery confirmation
confirm_deliveryrequires buyer auth.- Allowed from
FundedandShipped. - Requires
ledger.timestamp() >= dispute_deadline. - Transfers payout to the seller.
- State becomes
Completed.
-
Dispute raising
raise_disputerequires buyer auth.- Allowed from
FundedandShipped. - Requires
ledger.timestamp() < dispute_deadline. - Stores dispute metadata including
evidence_hash. - State becomes
Disputed.
-
Dispute resolution
resolve_disputerequires resolver auth.- Allowed only from
Disputed. - Applies configured arbitration fee.
- Transfers net payout based on resolution type.
- Updates both escrow state and dispute status.
-
Auto-release
auto_releaseis permissionless.- Allowed from
FundedorShipped. - Requires:
- ledger time past dispute deadline,
- ledger time past
funded_at + shipping_window.
- Transfers payout to the seller.
- State becomes
Completed.
-
Cancellation
cancel_escrowrequires seller auth.- Allowed only in
Pending. - Sets state to
Canceled.
The contract also includes an auditing helper transition_state in lib.rs to express allowed transitions in one place.
The contract implementation is in:
contracts/escrow/src/lib.rs
This file defines the Soroban contract (#[contract] pub struct Escrow;) and a set of #[contractimpl] methods.
The entrypoints split into:
- Initialization and admin actions
- Escrow creation and lifecycle actions
- Dispute actions
- Resolution actions
- Read-only query functions
Each state-mutating method generally follows a pattern:
- ensure contract is not paused (except some admin/oracle methods depending on call path),
- load escrow data from persistent storage,
- check the escrow state and time conditions,
- verify caller authorization using
require_auth()on the expected address, - perform token transfers via SEP-41 token client,
- persist updated state and emit events.
The contract stores global configuration and counters in instance storage and escrow/dispute records in persistent storage.
Keys are defined in contracts/escrow/src/types.rs:
DataKey::AdminDataKey::Escrow(u64)DataKey::EscrowCounterDataKey::Dispute(u64)DataKey::PausedDataKey::FeeCollectorDataKey::ArbitrationFeeDataKey::DefaultFeeBps- totals such as
DataKey::TotalCompleted,DataKey::TotalDisputed, etc.
TTL extension is configurable (instance key DataKey::TtlExtensionLedgers) and is applied when saving/loading escrow and dispute records.
Events are defined in contracts/escrow/src/events.rs.
Each meaningful lifecycle step emits an event (examples):
EscrowCreatedEscrowFundedEscrowShippedEscrowCompletedEscrowCancelledDisputeRaisedDisputeResolvedAutoReleased
The tests include numerous snapshot JSON files under contracts/escrow/test_snapshots/… that strongly suggests events are checked for stability and correctness.
For backend oracle/indexer designs, the recommended workflow is:
- subscribe to events,
- build a local state index keyed by
escrow_id, - present reconciliation views for dispute, deadlines, and payout status.
The escrow contract is token-agnostic, using SEP-41 token interface clients. All token operations are mediated via:
soroban_sdk::token::Client
Token transfers occur in:
fund_escrow: buyer → contractconfirm_delivery: contract → sellerauto_release: contract → sellerresolve_dispute: contract → seller or buyerwithdraw_fees: contract → fee collector recipient
The payout logic is governed by deduct_and_transfer, which calculates:
- fee = amount * fee_bps / 10_000 (basis points)
- net = amount - fee
The arbitration fee is handled as a separate deduction in resolve_dispute.
The contract enforces a fee cap with MAX_FEE_BPS = 300, i.e. 3%.
Escrow creation accepts a fee_bps parameter, and create_escrow rejects any value above the cap.
Additionally, the contract can update a default fee via admin (set_fee) stored in DataKey::DefaultFeeBps. (Per-escrow fee is passed at creation time.)
The deduct_and_transfer helper rejects negative amounts and uses checked arithmetic to avoid silent overflows.
Dispute resolution uses an arbitration fee configured on-chain as ArbitrationFee.
resolve_dispute reads the arbitration fee, checks that the escrow’s amount covers it, subtracts it from escrow.amount, and tracks total arbitration fees per token.
This creates the effect that arbitration resolution payouts are reduced by arbitration fee before applying the protocol fee model.
withdraw_fees(token, to, amount) enables the admin to move accumulated protocol token balances from the contract to the target address.
Guards include:
- paused check,
- admin authorization,
- amount positive,
- sufficient balance in the contract token vault.
Pause is stored as DataKey::Paused.
When paused, state-mutating escrow operations refuse execution via ensure_not_paused.
The pause behavior is tested in test_pause.rs and corresponding snapshots.
Admin rotation is performed by set_admin(new_admin).
Only the current admin can rotate; rotation emits an AdminRotated event.
This is useful to recover from lost keys and to evolve operational security posture.
Soroban storage entries can expire if not extended.
The contract uses:
- a default TTL extension value
- a configurable override via
set_ttl_extension(ledgers)
The helper functions in lib.rs apply TTL extension after reading from persistent storage and when writing back.
This reduces the chance of long-lived escrow entries expiring unexpectedly.
This section provides an “operator’s view” of the contract methods as they appear in:
contracts/escrow/src/lib.rs- TypeScript bindings under
bindings/src
- Guard: only allowed when not initialized (checks existence of
DataKey::Admin). - Effects: sets:
DataKey::AdminDataKey::FeeCollectorDataKey::ArbitrationFeeDataKey::EscrowCounter = 1DataKey::Paused = false
A second call panics in current implementation.
- Auth:
seller.require_auth(). - Guards: amount > 0, fee_bps <= 300, not paused.
- Effects:
- creates new escrow record with unique id from
EscrowCounter, - state =
Pending, - buyer is unset (
None), - dispute deadline and funding fields set to zero defaults.
- creates new escrow record with unique id from
- Auth: seller require auth (escrow.seller).
- Guards: escrow must be in
Pending. - Effects: state =
Canceledand emitsEscrowCancelled.
- Auth: buyer require auth.
- Guards: escrow in
Pending. - Effects:
- sets escrow.buyer = Some(buyer)
- state =
Funded - records
funded_atanddispute_deadline - transfers escrow amount into contract
- emits
EscrowFunded.
- Auth: seller require auth.
- Guards: escrow in
Funded, tracking_id length <= 64. - Effects: state =
Shipped, store tracking id, emitEscrowShipped.
- Auth: admin require auth.
- Guards: escrow must be
Shipped. - Effects: writes
delivered_atand emitsDeliveryRecorded.
Whether clients use this function depends on the deployment; the contract also provides confirm_delivery that directly completes escrow based on dispute deadline.
- Auth: buyer require auth.
- Guards: escrow in
FundedorShipped, and the dispute window has closed (ledger.timestamp >= dispute_deadline). - Effects: transfers net amount to seller using protocol fee logic, sets
state = Completed, increments totals, emitsEscrowCompleted.
- Auth: buyer require auth.
- Guards: escrow in
FundedorShipped, andledger.timestamp < dispute_deadline. - Effects:
- sets
state = Disputed - persists
DisputeDatawithBytesN<32>evidence hash and metadata - emits
DisputeRaised.
- sets
- Auth: resolver require auth.
- Guards: escrow in
Disputed. - Effects:
- subtract arbitration fee from escrow amount
- transfers net remainder based on resolution direction:
Release→ sellerRefund→ buyer
- sets escrow state
CompletedorRefunded - updates dispute status to
Resolved - emits
DisputeResolved.
- Auth: none.
- Guards: escrow state in
FundedorShipped, and time checks for both:- dispute deadline closed,
- shipping window elapsed (
funded_at + shipping_window).
- Effects: transfers net amount to seller, sets
state = Completed, emitsAutoReleased.
get_escrow(escrow_id): returnsEscrowData.get_dispute(escrow_id): returnsOption<DisputeData>(or None if no dispute exists for the escrow ID).get_escrows_by_buyer(buyer): iterates from 1 toEscrowCounter-1, collects matching buyer escrows.- This is convenient for clients, but can be expensive as escrow count grows.
get_fee_config(): returns fee collector and max fee.get_contract_config(): returns admin, default fee bps, fee collector, and escrow count.get_stats(): returns counters for created/completed/disputed/refunded.
The contract uses Soroban typed errors defined in contracts/escrow/src/types.rs:
InvalidAmount = 1InsufficientBalance = 2EscrowNotFound = 3InvalidState = 4NotAuthorized = 5AlreadyInitialized = 6FeeExceedsMax = 7EscrowHasNoBuyer = 8ShippingWindowNotElapsed = 9InvalidEvidenceHash = 10DisputeNotFound = 11ArithmeticError = 12DisputeWindowClosed = 13ContractPaused = 14ArithmeticOverflow = 15InvalidStateTransition = 16InputTooLong = 17
Client applications should handle these errors by showing user-friendly messages or by retrying/correcting inputs depending on the code.
This contract’s security is primarily a combination of:
- correct authorization checks,
- strict state-machine guards,
- deterministic time windows,
- safe arithmetic,
- careful token transfer handling.
Additional security reasoning is documented in REENTRANCY_ANALYSIS.md.
Across entrypoints, the contract requires the expected signer:
- seller calls require seller auth,
- buyer calls require buyer auth,
- resolver calls require resolver auth,
- admin calls require admin auth.
This ensures that even if someone can guess or discover an escrow id, they cannot move escrow funds without the correct signature.
Classic EVM external reentrancy patterns rely on the callee executing attacker-controlled callbacks while the caller is mid-execution.
Soroban’s execution model prevents classic external reentrancy patterns. The included REENTRANCY_ANALYSIS.md explains why: nested invocation frames are host-managed and there is no ability to inject callbacks that can re-enter the caller mid-frame.
Even so, the contract still enforces good internal structure:
- precondition checks before transfers,
- state transitions that make repeated calls invalid,
- checked arithmetic.
The contract enables overflow-checking in the Rust release profile (overflow-checks = true).
Additionally, deduct_and_transfer uses checked operations and returns typed errors instead of panicking.
TrustLink has explicit operational trust points:
- The resolver is required for dispute finality.
- Admin keys can pause the contract and update fees.
The protocol is therefore not purely “no trust ever,” but it is structured so that trust is limited to clearly defined roles with explicit authentication.
The repository includes further guidance in ORACLE_TRUST_MODEL.md.
The escrow contract has an extensive test suite in:
contracts/escrow/src/test.rs- multiple
test_*.rsmodules and snapshots.
A non-exhaustive list of what is covered by the repository test files and snapshot folders:
- correct escrow id behavior and counter monotonicity,
- fee bounds and fee update behavior,
- string length validation for
tracking_idand dispute description, - dispute timing boundaries and error cases,
- arbitration fee deduction semantics,
- auto-release timing and state constraints,
- admin rotation and admin auth enforcement,
- pause/unpause behavior and blocked mutations,
- TTL behavior.
Snapshot JSONs suggest the tests verify event payloads and/or numeric outputs to prevent regressions.
Important files and directories:
Cargo.tomlandCargo.lock: workspace and dependencies.ARCHITECTURE.md: overall architectural description.ORACLE_TRUST_MODEL.md: resolver and oracle trust assumptions.REENTRANCY_ANALYSIS.md: reentrancy security rationale.CONTRIBUTING.md: contribution workflow.contracts/escrow/: Soroban escrow contract workspace member.bindings/: TypeScript bindings package.
Key contract source files:
contracts/escrow/src/lib.rs: contract logic and entrypoints.contracts/escrow/src/types.rs: states, storage keys, errors, data structures.contracts/escrow/src/events.rs: event types and emitters.
The repository provides ABI bindings in bindings/. These include:
- a typed
EscrowClientinbindings/src/client.ts - data types mirroring contract structs (see
bindings/src/types.ts) - a transport abstraction (
ContractTransport) that you can wire to RPC/invocation tooling.
The typical client flow:
- Connect to a Soroban RPC endpoint.
- Prepare and sign a transaction with the appropriate account.
- Invoke a contract method by name, passing ABI-encoded arguments.
- Parse return values and handle typed errors.
The EscrowClient class is intentionally thin: it forwards method names to the transport layer.
Contribution guidelines are in CONTRIBUTING.md.
Recommended workflow:
- format with
cargo fmt, - lint with
cargo clippy -- -D warnings, - run tests with
cargo test, - ensure CI passes before opening a PR.
The repository participates in the Stellar Wave Program, and the docs describe issue labels and contribution points.
MIT © TrustLink Contributors
DISPUTE_WINDOW = 172_800seconds (2 days)MAX_FEE_BPS = 300(3%)DEFAULT_TTL_EXTENSION = 120_960ledgersMAX_TRACKING_ID_LEN = 64charactersMAX_DESCRIPTION_LEN = 256characters
raise_dispute stores a BytesN<32> evidence hash.
The contract commits to the hash, but does not validate evidence content. The resolver is trusted to interpret the evidence off-chain (based on the repository’s trust model).
End of README.