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.
The router enforces compile-time bounds that callers must respect before
submitting a transaction. Discover them on-chain via the auth-free, read-only
get_limits entrypoint, which returns a RouterLimits struct mirroring the
constants below:
| Limit | Constant | Value | Enforced by |
|---|---|---|---|
| Max per-pair fee | MAX_FEE_BPS |
1_000 bps (10%) |
set_pair_fee_bps, set_pair_fees_bps |
| BPS denominator | BPS_DENOMINATOR |
10_000 |
fee arithmetic |
| Max batch size | MAX_BATCH_SIZE |
100 entries |
register_pairs, set_pair_fees_bps |
| Max cooldown | MAX_COOLDOWN_SECS |
2_592_000 s (30 days) |
set_pair_cooldown |
The RouterLimits field order is a stable part of the on-chain ABI — do not
reorder or insert fields. See docs/abi.md for the authoritative
reference.
- Storage model & DataKey reference — authoritative reference for every on-chain storage slot: key shape, value type, tier, default-when-absent, reader/writer entrypoints, and TTL classification.
- ABI reference — generated client-facing interface.
- Upgrade & Migration Runbook — operational guide for safely upgrading the contract, running schema migrations, verifying schema versions, and handling rollback scenarios.
- Deployment Guide — covers constructor deployment and the legacy
inittrap.
Contract state lives in two Soroban storage tiers:
- Instance storage --
DataKey::Admin,DataKey::PendingAdmin, andDataKey::Paused. These are the hot globals: every admin-gated entrypoint readsAdmin, and every state-changing entrypoint readsPausedbefore doing anything else. Bundling them with the contract instance avoids a separate persistent-storage read (and its own TTL check) on every call. Every write to one of these three keys also extends the instance's TTL viaenv.storage().instance().extend_ttl(...), so the instance -- and these singletons with it -- never archives as long as the contract keeps seeing admin/pause/transfer traffic. - Persistent storage -- every other key: per-pair config and metrics
(
Pair,PairFeeBps,PairMinAmount,PairMaxAmount,PairLiquidity,PairCooldown,PairRouteCount,PairVolume,PairLastRouteAt), and less-hot singletons (FeeRecipient,TotalRoutesAllTime,Timelock,PendingAdminEta,SchemaVersion,ReentrancyLock,MaxFeeAbsolute,Oracle).
PendingAdminEta stays in persistent storage even though PendingAdmin
moved to instance: it's only read during an already-queued handover, not
on every call, so it doesn't carry its weight as a hot global.
See docs/storage.md for the full key-by-key reference (value type, default-when-absent, reader/writer entrypoints, and TTL classification).
PairRouteCount (u64) and PairVolume (i128) accumulate on every
successful compute_route_fee call via saturating_add, so a corridor's
lifetime route count and cumulative routed volume can never panic or wrap
even near i128::MAX. Read them with get_pair_route_count(source, destination) and get_pair_volume(source, destination) -- both default
to 0 for a pair that has never been routed, and each pair's counters
are fully independent of every other pair's. purge_pair_metrics is the
only entrypoint that resets them.
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 wasm32v1-none cargo install cargo-llvm-cov
- Build and test:
cargo build cargo clippy --all-targets -- -D warnings cargo test cargo build --target wasm32v1-none --release bash scripts/check_wasm_size.sh 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 wasm32v1-none --release |
Build the deployable Soroban WASM artifact |
bash scripts/check_wasm_size.sh |
Enforce the WASM artifact size budget (see CONTRIBUTING.md) |
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, force_admin_transfer |
No handover is pending; nothing to accept/force. |
| 8 | NotPendingAdmin |
accept_admin_transfer, force_admin_transfer |
Caller is not the proposed pending admin, or force_admin_transfer was called with the wrong new_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 exceeds MAX_BATCH_SIZE (100) entries. Split into smaller batches. |
| 19 | EmptyBatch |
register_pairs, set_pair_fees_bps |
Batch must contain at least one entry. |
| 20 | CooldownTooLarge |
set_pair_cooldown |
Cooldown exceeds MAX_COOLDOWN_SECS (30 days). Lower the value. |
| 21 | ZeroFeeCap |
set_max_fee_absolute |
Fee cap of zero was rejected. Use clear_max_fee_absolute to remove the cap. |
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 also clears the pair's live config slots (PairFeeBps,
PairMinAmount, PairMaxAmount, PairLiquidity) before emitting a
cfg_clr companion event. Re-registering the same pair therefore starts from
the documented defaults instead of reviving stale fee, bounds, or liquidity
values.
Note on unregister_pair blocking: one could imagine requiring
unregister_pair to fail while a pair still has live config (fee/bounds/
liquidity) set, forcing an explicit config-clear step first. This was
considered for issue #144 and deliberately left out of scope: since
unregister_pair already clears all four config slots itself (see
above), there is no window where stale config could survive an
unregister to justify blocking the call. A future issue could revisit
this if the cleanup behavior ever changes.
The router separates governance from the high-frequency liquidity feed:
- Admin (
DataKey::Admin) — the single governance role. Required by every state-changing entrypoint, includingset_oracleandremove_oracle. - Oracle (
DataKey::Oracle) — an optional, scoped role. The oracle may callset_pair_liquidityand nothing else — it cannot set fees, pause, rotate admin, or upgrade. This lets a frequently rotated off-chain key keep the liquidity feed fresh without holding governance power.
See docs/roles.md for the complete capability matrix, role boundaries, key rotation procedures, and compromised-key recovery guidance.
| Action | Entrypoint | Auth | Event |
|---|---|---|---|
| Grant / rotate | set_oracle(oracle) |
admin | orac_set |
| Revoke | remove_oracle() |
admin | orac_rm |
| Inspect | get_oracle() |
none (read) | — |
remove_oracle clears DataKey::Oracle entirely. This is the recovery
path for a compromised oracle key: rotation via set_oracle always
leaves some oracle authorized, whereas removal returns the contract to
an admin-only liquidity feed.
- After removal,
set_pair_liquidityaccepts only the admin again. No special-case code is needed: the dual-auth check (caller != admin && Some(caller) != oracle) naturally degrades to admin-only when the slot is absent, becauseSome(caller)can never equalNone. A revoked oracle is rejected withNotAuthorized(#15), the same code any other unauthorized caller receives. - Removal is idempotent — calling
remove_oraclewhen no oracle is configured is a clean no-op. - Every call emits an
orac_rmevent carrying the previously configured oracle (Noneon a no-op) so indexers can audit revocations. - The missing-admin path reuses
NotInitialized(#2), like every other admin-gated entrypoint; no new error code was added. - The admin can later grant the role to a fresh key with
set_oracleonce the incident is resolved.
On every push/PR to main, GitHub Actions runs:
cargo fmt --all -- --checkcargo buildcargo clippy --all-targets -- -D warningscargo testcargo build --target wasm32v1-none --releasebash scripts/check_wasm_size.sh— fails when the release WASM exceeds the byte budget in.github/wasm-size-budget; on PRs it also prints the size delta versus the base branch (see "WASM size budget" in CONTRIBUTING.md)cargo 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, size budget, 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.
All admin-gated entrypoints have negative-authorization tests in test_i19_authorization (asserting non-admins are rejected) and positive controls (asserting admins can invoke them). When adding a new admin-gated entrypoint, add a corresponding test_*_requires_admin case to this module.
get_pair_info on a never-touched pair returns the documented sentinel
defaults exactly: registered: false, fee_bps: 0, min_amount: 0, max_amount: i128::MAX, liquidity: 0, last_route_at: 0
(test_pair_info_defaults_for_unconfigured_pair). A bare-registered pair
flips only registered to true, leaving every other field at its
default (test_pair_info_reflects_bare_registration_only).
quote_route's net (amount - fee) is asserted exactly at zero fee and
at the MAX_FEE_BPS cap
(test_quote_route_net_equals_amount_minus_fee_at_zero_fee,
test_quote_route_net_equals_amount_minus_fee_at_max_fee_bps), plus a
quote-vs-compute parity sweep across zero/typical/max fee tiers on one
pair (test_quote_and_compute_agree_across_fee_tiers), all in
test_i16_fee_arithmetic. This complements the existing property tests
prop_fee_within_amount and prop_quote_matches_compute, which already
prove 0 <= fee <= amount and fee + net == amount generally across the
full fee_bps and amount ranges — the tests above pin the same
invariants at fixed, human-readable boundary values named in issue #146.
test_i230_paused_sweep is the exhaustive pause sweep: it enumerates every state-changing entrypoint and asserts its expected behaviour while the router is paused. The module documents three categories:
| Category | Entrypoints | Expected when paused |
|---|---|---|
| Route accounting | compute_route_fee |
Rejected — ContractPaused (#9) |
| Pair registration | register_pair, register_pairs |
Rejected — ContractPaused (#9) |
| Fee setters | set_pair_fee_bps, set_pair_fees_bps |
Rejected — ContractPaused (#9) |
| Config setters | set_pair_min_amount, set_pair_max_amount, set_pair_liquidity, set_pair_cooldown, set_fee_recipient, set_max_fee_absolute, clear_max_fee_absolute, set_oracle, remove_oracle |
Succeeds — governance/config ops are not blocked |
| Pair lifecycle | unregister_pair, purge_pair_metrics |
Succeeds — admin cleanup must remain available |
| Migration | migrate_v1_to_v2 |
Succeeds — schema ops are not blocked |
| Governance | pause (idempotent), unpause, set_timelock, propose_admin_transfer, cancel_admin_transfer, force_admin_transfer, accept_admin_transfer |
Succeeds — governance must work to recover |
| Upgrade | upgrade |
Succeeds — documented trade-off; patch deployment must survive an emergency pause |
The fail-loudly invariant: if a new state-changing entrypoint is added without being added to this module, the coverage drop is caught by cargo llvm-cov --fail-under-lines 95. When adding a new entrypoint, add a corresponding case to test_i230_paused_sweep that documents its pause policy explicitly.
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 |
set_pair_fee_bps |
fee_set |
(source, destination, fee_bps) |
test_pair_lifecycle_events_have_exact_payloads_and_counts |
set_pair_liquidity |
liq_set |
(source, destination, liquidity) |
test_pair_lifecycle_events_have_exact_payloads_and_counts |
set_pair_cooldown |
cd_set |
(source, destination, cooldown_secs) |
|
unregister_pair |
unreg |
(source, destination) |
test_pair_lifecycle_events_have_exact_payloads_and_counts |
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.
MIT