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 |
Both register_pairs and set_pair_fees_bps enforce the following batch
size rules:
| Batch length | Outcome | Error |
|---|---|---|
0 (empty) |
Panics | EmptyBatch (#19) |
1 ..= 100 |
Succeeds | — |
101+ |
Panics | BatchTooLarge (#18) |
When a batch panics, Soroban's transaction atomicity guarantees that no
state is written — no pairs are registered and no fees are set for any
entry in the batch. This holds for both EmptyBatch and BatchTooLarge
rejections.
The boundary at exactly MAX_BATCH_SIZE (100) is the largest accepted
batch. Callers should split larger workloads into multiple transactions
of at most 100 entries each.
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, top_up_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, top_up_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_liquiditytop_up_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, top_up_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(which overwrites whatever remains, resetting the consumption window) or increment it viatop_up_pair_liquidity(which adds onto existing liquidity usingsaturating_addand preserves thei128::MAXsentinel value).
| 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, top_up_pair_liquidity |
Oracle/admin set or incremented 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.
Each DataKey variant that is both read and written within
compute_route_fee — PairLiquidity, PairLastRouteAt,
PairRouteCount, and PairVolume — is constructed once into a
local variable and then referenced by & for every subsequent storage
operation. This halves the number of Symbol clones for those hot
paths. The fee calculation is hoisted before the effects section so
that the final use of source and destination — the route event
emission — moves (consumes) them instead of cloning. This is an
internal optimisation with no change to external behaviour.
top_up_pair_liquidity emits the same liq_set event with the new accumulated liquidity. 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 |
top_up_pair_liquidity |
liq_set |
(source, destination, liquidity) |
|
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