Skip to content

StableRoute-Org/Stableroute-contracts

Repository files navigation

stableroute-contracts

Soroban smart contracts for StableRoute — Stellar liquidity routing protocol.

What this repo contains

  • StableRouteRouter — Soroban contract placeholder for routing metadata and route integrity (version, route tags). Production logic will integrate with path payments and liquidity data.

Protocol limits

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.

Documentation

  • 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 init trap.

Storage tiers

Contract state lives in two Soroban storage tiers:

  • Instance storage -- DataKey::Admin, DataKey::PendingAdmin, and DataKey::Paused. These are the hot globals: every admin-gated entrypoint reads Admin, and every state-changing entrypoint reads Paused before 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 via env.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).

Per-pair route metrics

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.

Security

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.

Prerequisites

Setup (contributors)

  1. Clone the repo and enter the directory:
    git clone <repo-url> && cd stableroute-contracts
  2. 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
  3. 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
  4. Check formatting:
    cargo fmt --all -- --check

Commands

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

Error reference (RouterError)

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 RouterError variant, add a row here with the next sequential code. Never edit an existing code/row.

Registration-first invariant

register_pair must be called for (source, destination) before any of its per-pair config setters:

  • set_pair_fee_bps
  • set_pair_min_amount
  • set_pair_max_amount
  • set_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.

Roles & least privilege

The router separates governance from the high-frequency liquidity feed:

  • Admin (DataKey::Admin) — the single governance role. Required by every state-changing entrypoint, including set_oracle and remove_oracle.
  • Oracle (DataKey::Oracle) — an optional, scoped role. The oracle may call set_pair_liquidity and 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.

Oracle lifecycle

Action Entrypoint Auth Event
Grant / rotate set_oracle(oracle) admin orac_set
Revoke remove_oracle() admin orac_rm
Inspect get_oracle() none (read)

Oracle revocation

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_liquidity accepts 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, because Some(caller) can never equal None. A revoked oracle is rejected with NotAuthorized (#15), the same code any other unauthorized caller receives.
  • Removal is idempotent — calling remove_oracle when no oracle is configured is a clean no-op.
  • Every call emits an orac_rm event carrying the previously configured oracle (None on 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_oracle once the incident is resolved.

CI/CD

On every push/PR to main, GitHub Actions runs:

  • cargo fmt --all -- --check
  • cargo build
  • cargo clippy --all-targets -- -D warnings
  • cargo test
  • cargo build --target wasm32v1-none --release
  • bash 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.

Contributing

See CONTRIBUTING.md for the contract conventions (error numbering, event-topic limits, admin-auth and pause patterns, storage/TTL tiers) and the PR checklist.

  1. Fork the repo and create a branch from main.
  2. Make changes; keep formatting, linting, tests, WASM build, size budget, and coverage passing.
  3. Open a PR; CI must be green.
  4. Follow the project’s code style (enforced by rustfmt).

Internal helper conventions

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.

Testing notes

Authorization testing

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.

Defaults and net-computation testing (issue #146)

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.

Pause coverage

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.

Liquidity consumption model

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.

Behaviour

  • Set liquidity: When an oracle or admin has called set_pair_liquidity, the stored value is decreased by amount via saturating subtraction and persisted. A liq_used event with (source, destination, remaining_liquidity) is emitted. The slot TTL is extended on each write.
  • Unset liquidity (unbounded sentinel): When PairLiquidity has never been written it reads as i128::MAX inside compute_route_fee. The decrement is skipped entirely — no storage write and no liq_used event — preserving the "no oracle configured" behaviour. The public getter get_pair_liquidity still returns 0 for absent slots.
  • InsufficientLiquidity: The existing guard (RouterError::InsufficientLiquidity, code #12) fires when amount > 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.

Event reference

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 side-effect matrix

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::PairLastRouteAtenv.ledger().timestamp() test_compute_route_fee_stamps_pair_last_route_at
Liquidity debit DataKey::PairLiquiditymax(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 event and idempotency matrix

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.

License

MIT

About

No description, website, or topics provided.

Resources

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages