Soroban smart contracts for StableRoute — Stellar liquidity routing protocol.
- StableRouteRouter — Soroban contract placeholder for routing metadata and route integrity (version, route tags). Production logic will integrate with path payments and liquidity data.
See SECURITY.md for the router's trust model (single
admin, two-step transfer, pause), known limitations, and the responsible
-disclosure process. Report vulnerabilities privately via the StableRoute
Discord — https://discord.gg/37aCpusvx — not as public issues.
- Rust (stable, with
rustfmt) - Optional: Soroban CLI for deployment
- Clone the repo and enter the directory:
git clone <repo-url> && cd stableroute-contracts
- Install Rust (if needed):
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh rustup component add rustfmt clippy rustup target add wasm32-unknown-unknown cargo install cargo-llvm-cov
- Build and test:
cargo build cargo clippy --all-targets -- -D warnings cargo test cargo build --target wasm32-unknown-unknown --release cargo llvm-cov --all-targets --fail-under-lines 95 - Check formatting:
cargo fmt --all -- --check
| Command | Description |
|---|---|
cargo build |
Build the contracts |
cargo test |
Run unit tests |
cargo clippy --all-targets -- -D warnings |
Treat Rust lints and warnings as CI failures |
cargo build --target wasm32-unknown-unknown --release |
Build the deployable Soroban WASM artifact |
cargo llvm-cov --all-targets --fail-under-lines 95 |
Report coverage and fail below 95 percent line coverage |
cargo fmt --all |
Format code |
cargo fmt --all -- --check |
CI: verify formatting |
Every contract panic surfaces to clients as Error(Contract, #N). The
table below is the authoritative map from code to meaning. Codes are
append-only: a variant is never reused or renumbered once shipped, so
integrations can hard-code these numbers safely and only ever need to
learn about new (higher) codes. Source of truth: the RouterError enum
in src/lib.rs.
| Code | Variant | Raised by | Meaning / remedy |
|---|---|---|---|
| 1 | AlreadyInitialized |
init |
Admin already set; the contract is initialized. No action. |
| 2 | NotInitialized |
every admin-gated entrypoint (pause, set_*, …) |
Admin not set yet — call init first. |
| 3 | SourceEqualsDestination |
register_pair |
A route's source and destination must differ. |
| 4 | FeeBpsTooHigh |
set_pair_fee_bps |
Fee exceeds MAX_FEE_BPS (1000 bps = 10%). Lower the fee. |
| 5 | PairNotRegistered |
compute_route_fee, quote_route |
Register the pair before routing/quoting. |
| 6 | AmountMustBePositive |
compute_route_fee, quote_route, set_pair_liquidity, set_pair_min_amount, set_pair_max_amount |
Amount/value must be positive (or non-negative where noted). |
| 7 | NoPendingAdminTransfer |
accept_admin_transfer |
No handover is pending; nothing to accept. |
| 8 | NotPendingAdmin |
accept_admin_transfer |
Caller is not the proposed pending admin. |
| 9 | ContractPaused |
state-mutating entrypoints (register_pair, set_pair_fee_bps, …) |
Router is paused; retry after unpause. |
| 10 | AmountBelowMin |
compute_route_fee |
Amount is below the pair's configured minimum. |
| 11 | AmountAboveMax |
compute_route_fee |
Amount is above the pair's configured maximum. |
| 12 | InsufficientLiquidity |
compute_route_fee |
Reported pair liquidity is below the requested amount. |
| 13 | MigrationVersionMismatch |
migrate_v1_to_v2 |
Schema is not at v1; migration already applied. |
| 14 | TimelockNotElapsed |
accept_admin_transfer |
Governance timelock has not elapsed yet; retry after the queued ETA. |
| 15 | ReentrantCall |
compute_route_fee |
Route accounting was re-entered while locked; retry only after the first call completes. |
| 16 | NotAuthorized |
set_pair_liquidity |
Caller is neither the admin nor the configured oracle. |
| 17 | RouteCooldownActive |
compute_route_fee |
Pair cooldown has not elapsed since the previous routed amount. |
| 18 | BatchTooLarge |
register_pairs, set_pair_fees_bps |
Batch length exceeds MAX_BATCH_SIZE (100). Split into smaller batches. |
Maintainers: when you append a new
RouterErrorvariant, add a row here with the next sequential code. Never edit an existing code/row.
register_pair must be called for (source, destination) before any of
its per-pair config setters:
set_pair_fee_bpsset_pair_min_amountset_pair_max_amountset_pair_liquidity
Each setter checks DataKey::Pair(source, destination) after its own
admin/sign validation and rejects an unregistered (or since-unregistered)
pair with PairNotRegistered (#5) — the same error compute_route_fee
and quote_route already raise. This prevents an admin from writing
fee/bounds/liquidity config for a corridor that was never enabled, which
would otherwise waste storage rent and pollute future pair enumeration.
unregister_pair does not clear the config slots it leaves behind
(PairFeeBps, PairMinAmount, PairMaxAmount, PairLiquidity); a later
register_pair for the same pair silently revives the old values. Whether
unregister_pair should also clear those slots, or refuse to run while
they're non-default, is a follow-up cleanup question and is out of scope
for the registration guard above.
On every push/PR to main, GitHub Actions runs:
cargo fmt --all -- --checkcargo buildcargo clippy --all-targets -- -D warningscargo testcargo build --target wasm32-unknown-unknown --releasecargo llvm-cov --all-targets --fail-under-lines 95
Ensure these pass locally before pushing.
See CONTRIBUTING.md for the contract conventions (error numbering, event-topic limits, admin-auth and pause patterns, storage/TTL tiers) and the PR checklist.
- Fork the repo and create a branch from
main. - Make changes; keep formatting, linting, tests, WASM build, and coverage passing.
- Open a PR; CI must be green.
- Follow the project’s code style (enforced by
rustfmt).
require_admin — every admin-gated entrypoint in StableRouteRouter calls the private fn require_admin(env: &Env) -> Address helper instead of repeating the load-unwrap-require_auth block inline. When adding a new admin-gated entrypoint, start the body with Self::require_admin(&env);. Do not duplicate the pattern manually.
compute_route_fee debits the routed amount from the pair's stored
PairLiquidity on every successful route. This ensures the on-chain
liquidity figure reflects consumption between oracle updates, preventing
repeated routes from exceeding real available liquidity.
- Set liquidity: When an oracle or admin has called
set_pair_liquidity, the stored value is decreased byamountvia saturating subtraction and persisted. Aliq_usedevent with(source, destination, remaining_liquidity)is emitted. The slot TTL is extended on each write. - Unset liquidity (unbounded sentinel): When
PairLiquidityhas never been written it reads asi128::MAXinsidecompute_route_fee. The decrement is skipped entirely — no storage write and noliq_usedevent — preserving the "no oracle configured" behaviour. The public getterget_pair_liquiditystill returns0for absent slots. - InsufficientLiquidity: The existing guard (
RouterError::InsufficientLiquidity, code #12) fires whenamount > stored_liquidity. - Oracle top-up: The oracle (or admin) can replenish liquidity at any
time via
set_pair_liquidity. The new value overwrites whatever remains, resetting the consumption window.
| Topic | Data | Emitted by | Meaning |
|---|---|---|---|
liq_used |
(source, destination, remaining_liquidity) |
compute_route_fee |
Liquidity decremented by routed amount |
liq_set |
(source, destination, liquidity) |
set_pair_liquidity |
Oracle/admin set/replenished liquidity |
compute_route_fee is the only mutating read path. On success it performs three
side effects, each covered by a dedicated test in src/lib.rs:
| Side effect | Storage / event | Test |
|---|---|---|
| Lifetime counter | DataKey::TotalRoutesAllTime (saturating, protocol-wide) |
test_compute_route_fee_counter_is_global_across_pairs |
| Last-route timestamp | DataKey::PairLastRouteAt ← env.ledger().timestamp() |
test_compute_route_fee_stamps_pair_last_route_at |
| Liquidity debit | DataKey::PairLiquidity ← max(0, liquidity - amount) |
test_liquidity_decremented_by_amount_after_route |
| Emitted event | topic route, data (source, destination, amount) |
test_compute_route_fee_emits_route_event_with_payload |
| Emitted event | topic liq_used, data (source, destination, remaining) |
test_liq_used_event_emitted_with_remaining |
quote_route is the read-only twin and must perform none of these. The
parity guard test_quote_route_does_not_mutate_counter_or_emit_route_event
asserts the counter is unchanged and no new route event is emitted after a
quote.
The route_event_payloads test helper scans the current host event buffer and
returns only the decoded payloads of events whose single topic is route.
Pair lifecycle tests assert the exact one-event payload emitted by each lifecycle entrypoint before any later contract call refreshes the host event buffer:
| Entrypoint | Topic | Data payload | Test |
|---|---|---|---|
| constructor | init |
admin |
test_pair_lifecycle_events_have_exact_payloads_and_counts |
register_pair |
pair_reg |
(source, destination) |
test_pair_lifecycle_events_have_exact_payloads_and_counts |
register_pairs |
pair_reg (per entry) |
(source, destination) (per entry) |
test_register_pairs_happy_path |
set_pair_fee_bps |
fee_set |
(source, destination, fee_bps) |
test_pair_lifecycle_events_have_exact_payloads_and_counts |
set_pair_fees_bps |
fee_set (per entry) |
(source, destination, fee_bps) (per entry) |
test_set_pair_fees_bps_empty |
set_pair_liquidity |
liq_set |
(source, destination, liquidity) |
test_pair_lifecycle_events_have_exact_payloads_and_counts |
unregister_pair |
unreg |
(source, destination) |
test_pair_lifecycle_events_have_exact_payloads_and_counts |
compute_route_fee |
liq_used |
(source, destination, remaining_liquidity) |
test_liq_used_event_emitted_with_remaining |
Two edge-case tests guard idempotency and storage boundaries: unregistering a
never-registered pair stays a clean no-op while still emitting the lifecycle
event, and re-registering after unregister restores the pair without clearing
the stored PairFeeBps value.
The router supports in-place WASM upgrades via the admin-gated upgrade entrypoint,
so bug fixes can be deployed without losing pair state, admin configuration, or
route history.
Flow:
-
Build the new WASM artifact:
cargo build --target wasm32-unknown-unknown --release
-
Install the WASM on-chain and obtain its hash:
soroban lab build \ --copy-to target/wasm32-unknown-unknown/release/stableroute_contracts.wasm soroban contract install \ --source <admin-key> \ --network <network> \ --wasm target/wasm32-unknown-unknown/release/stableroute_contracts.wasm
The command prints a
BytesN<32>WASM hash (e.g.cafebabe...). -
Call the
upgradeentrypoint as the admin:soroban contract invoke \ --source <admin-key> \ --network <network> \ --id <contract-id> \ -- \ upgrade \ --new_wasm_hash cafebabe...
Security notes:
- Only the admin (
DataKey::Admin) can callupgrade; the entrypoint usesrequire_adminand will panic withNotInitialized(#2) if the contract has not been initialised. - The call emits an
upgradedevent carrying the new WASM hash, providing a censorable audit trail for indexers and off-chain watchers. - Storage (
DataKeyslots) is preserved across the upgrade — the admin, all registered pairs, fees, liquidity reports, route counters, and configuration survive the WASM replacement. upgradeis intentionally not paused-gated: the admin should be able to fix a bug even while the contract is emergency-stopped. The admin can always unpause, so there is no escalation path through this exception.
MIT