Smart contracts powering the MarketX decentralized marketplace.
This repository contains Soroban smart contracts written in Rust for handling escrow, payments, and core on-chain marketplace logic on the Stellar network.
MarketX leverages Stellar's Soroban smart contract platform to provide:
- Secure escrow between buyers and sellers
- Controlled fund release and refunds
- Authorization-based state transitions
- On-chain validation of marketplace operations
- Event emission for off-chain indexing and monitoring
The contract layer is designed to be secure, deterministic, and minimal.
- Rust (stable toolchain)
- Soroban Smart Contracts (soroban-sdk v25)
- stellar-cli v25
- Stellar Testnet (initial deployment target)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup update# Legacy target (used for cargo test / dev builds)
rustup target add wasm32-unknown-unknown
# New Soroban target (used by stellar contract build)
rustup target add wasm32v1-nonecargo install stellar-cliVerify installation:
stellar --versionThis repository is a Cargo workspace — every directory under contracts/ is automatically included as a workspace member. Adding a new contract requires no changes to the root Cargo.toml.
.
├── Cargo.toml # Workspace manifest & shared dependencies
├── Cargo.lock # Locked dependency versions (committed)
├── Makefile # Workspace-wide shortcuts (build, test, fmt, check)
└── contracts/
└── marketx/ # Escrow contract for marketplace settlement
├── Cargo.toml # Inherits versions from workspace
├── Makefile # Per-contract shortcuts
└── src/
├── lib.rs # Contract entrypoints & module-level docs
├── errors.rs # ContractError variants
├── types.rs # Escrow, EscrowStatus, DataKey
└── test.rs # Unit & snapshot tests
stellar contract init . --name <contract-name>This scaffolds contracts/<contract-name>/ and automatically adds it to the workspace.
Shared dependency versions (e.g. soroban-sdk) are inherited from [workspace.dependencies] in the root Cargo.toml.
Build all contracts as optimized WASM artifacts:
make build
# or directly:
stellar contract buildFor production-ready WASM artifacts using the repository's optimized release profile:
make build-prod
# or directly:
./scripts/build_wasm.shArtifacts land at:
target/wasm32v1-none/release/<contract-name>.wasm
make test
# or directly:
cargo testAll contract logic must be covered by unit tests.
Generate a keypair and fund it via Friendbot:
stellar keys generate --global deployer --network testnet
stellar keys fund deployer --network testnetVerify the account address:
stellar keys address deployerstellar contract deploy \
--wasm target/wasm32v1-none/release/marketx.wasm \
--source deployer \
--network testnetOn success, the CLI outputs a contract ID:
CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Export it for use in subsequent commands:
export CONTRACT_ID=CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXstellar contract invoke \
--id $CONTRACT_ID \
--source deployer \
--network testnet \
-- \
create_escrow \
--buyer GBUYERADDRESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX \
--seller GSELLERADDRESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX \
--amount 1000000 \
--token CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSCNote: Amounts are in stroops (1 XLM = 10,000,000 stroops).
stellar contract info \
--id $CONTRACT_ID \
--network testnetThis section still contains some legacy naming from earlier contract iterations. For the current event model, off-chain indexing guidance, and TTL behavior, use the Event Schemas, Off-Chain Indexing Spec, TTL Maintenance, and Current Implementation Notes sections below as the source of truth.
All state is stored in persistent ledger entries (minimum TTL: 4,096 ledgers on testnet, ~5.7 hours at 5 s/ledger). There are three key types:
| Key | Type | Description |
|---|---|---|
Escrow(u64) |
Escrow |
One record per escrow, keyed by caller-assigned ID |
EscrowCount |
u64 |
Monotonic counter reserved for future auto-ID generation |
InitialValue |
u32 |
Arbitrary value set at initialization; defaults to 0 |
The Escrow struct has five fields: buyer: Address, seller: Address, token: Address, amount: i128 (in the token's base unit, e.g. stroops for XLM), and status: EscrowStatus.
An escrow moves through a strict state machine. Released and Refunded are terminal — no further transitions are permitted once either is reached.
Pending ──► Released buyer confirms delivery
Pending ──► Disputed dispute raised
Pending ──► Refunded direct cancellation
Disputed ──► Released resolved in seller's favour
Disputed ──► Refunded resolved in buyer's favour
All transitions except Disputed → Released require buyer authorization (require_auth).
Stores an initial u32 value in persistent storage. Can be called multiple times; subsequent calls overwrite the previous value.
Returns the value set by initialize, or 0 if initialize has not been called.
Writes an Escrow record to persistent storage under escrow_id. Silently overwrites any existing record — callers are responsible for ID uniqueness.
Returns the escrow record for escrow_id. Traps (panics) if the ID does not exist. Use try_get_escrow when the ID may be absent.
Safe variant of get_escrow. Returns ContractError::EscrowNotFound instead of trapping on a missing ID.
The primary state-mutation entrypoint. Loads the escrow, enforces buyer authorization for buyer-initiated moves, validates the transition against the state graph, and persists the updated record.
| Error | Condition |
|---|---|
EscrowNotFound |
No record exists for escrow_id |
InvalidTransition |
Move not permitted from the current state |
Convenience wrapper that releases funds to the seller. Validates that the escrow is in Pending state before delegating to transition_status, surfacing EscrowNotFunded as a clearer error than the generic InvalidTransition.
| Error | Condition |
|---|---|
EscrowNotFound |
No record exists for escrow_id |
EscrowNotFunded |
Escrow is not in Pending state |
InvalidTransition |
Transition rejected by state graph (propagated from transition_status) |
| Variant | Value | Meaning |
|---|---|---|
EscrowNotFound |
1 |
No escrow stored for the given ID |
InvalidTransition |
2 |
State move not in the valid transition graph |
EscrowNotFunded |
3 |
Escrow is not in Pending state |
Error discriminant values are part of the on-chain ABI — they must not be renumbered.
- Use explicit authorization checks (
require_auth) - Validate all inputs
- Avoid unnecessary storage writes
- Keep state transitions clear and deterministic
- Format and check before opening a PR:
make fmt
make check- Ensure no warnings before opening a PR
- Initial deployment target: Stellar Testnet
- Mainnet deployment will follow thorough testing and review.
The contract now emits Soroban #[contractevent] events using compact vec payloads instead of map-style payloads. This avoids per-field name overhead and keeps escrow events free of large string or metadata blobs.
| Event | Topics | Data | Emitted when |
|---|---|---|---|
EscrowCreatedEvent |
("escrow_created", escrow_id) |
[buyer, seller, token, amount, status, arbiter] |
create_escrow |
FundsReleasedEvent |
("funds_released", escrow_id) |
[amount] |
release_escrow |
FeeCollectedEvent |
("fee_collected", escrow_id) |
[fee_collector, fee] |
release_escrow or verify_delivery |
FeesWithdrawnEvent |
("fees_withdrawn", collector, token) |
[amount] |
withdraw_fees |
StatusChangeEvent |
("status_change", escrow_id) |
[from_status, to_status, actor] |
Every implemented escrow status transition |
FeeChangedEvent |
("fee_changed") |
[old_fee_bps, new_fee_bps, actor] |
set_fee_percentage |
FeeCollectorRotatedEvent |
("fee_collector_rotated") |
[old_collector, new_collector, actor] |
set_fee_collector |
FeeCapsChangedEvent |
("fee_caps_changed") |
[old_min_fee, new_min_fee, old_max_fee, new_max_fee, actor] |
set_fee_caps |
FeeExemptionEvent |
("fee_exemption") |
[address, exempted, actor] |
add_fee_whitelist / remove_fee_whitelist |
CancellationProposedEvent |
("cancellation_proposed", escrow_id) |
[actor] |
propose_cancellation |
StatusChangeEvent is the canonical lifecycle stream. Every implemented escrow status mutation now emits it, including dispute resolution.
There is intentionally no on-chain EscrowsByBuyer or EscrowsBySeller index. Frontends should derive those views from events.
Recommended indexer flow:
- Subscribe to all events for the MarketX contract ID.
- On
EscrowCreatedEvent, readescrow_idfrom the second topic and decode the data vector as[buyer, seller, token, amount, status, arbiter]. - Upsert a canonical escrow record keyed by
escrow_id. - Append
escrow_idto off-chain lookup tables keyed bybuyerandseller. - Optionally maintain an arbiter lookup table when
arbiteris present. - On
StatusChangeEvent, update the escrow status and move the escrow between active and terminal views. - On
FeeCollectorRotatedEvent, update treasury routing in the indexer cache so fee accounting stays aligned with the live collector.
This schema provides everything needed for user escrow lists without an on-chain reverse index:
EscrowCreatedEventsupplies the user addresses, escrow ID, token, amount, initial status, and arbiter.StatusChangeEventsupplies the full transition history needed to keep active/completed views current.FeeCollectedEventandFeesWithdrawnEventprovide fee accounting for treasury dashboards.FeeCollectorRotatedEventgives indexers a clean cutover signal when treasury addresses change.- If a detail page needs optional metadata, the indexer can fetch
get_escroworget_escrow_metadataonce and cache it off-chain instead of paying to include metadata in every event.
The contract exposes two read-only helpers for sizing and load-planning:
estimate_storage_rent(escrow_id)returns the approximate persistent footprint for an escrow, including companion milestone, time-lock, and group-buy entries when present.get_resource_profile()returns the contract's bounded size limits so off-chain load tests can target worst-case payloads without hard-coding constants.
These helpers are intentionally conservative. They report serialized footprint and contract ceilings, not the live network rent price. Integrators should combine the returned byte counts with current Soroban fee parameters when they need an XLM estimate.
Persistent entries on Soroban expire unless their TTL is extended. The contract exposes bump_escrow(escrow_id: u64) so anyone can refresh long-lived escrow storage before archival.
bump_escrowis permissionless.- It extends the escrow record itself.
- It also extends the duplicate-prevention hash entry associated with that escrow.
- Integrators can call it periodically for long-running escrows or disputes.
The current public flows are create_escrow, fund_escrow, release_escrow, resolve_dispute, pause/unpause, fee updates, fee-collector rotation, storage rent estimation, and bump_escrow.
release_partial, refund_escrow, and broader pending-state transitions are still placeholders and should not yet be treated as production-ready flows.
MIT
- Web3 Dashboard Mock Documentation:
docs/post-task211.md - Stellar Explorer Metadata Integration:
docs/post-task213.md - Explorer metadata template:
docs/stellar-expert-metadata.json