Rust/Axum API for Zenith, a decentralized options protocol on Stellar Soroban. Black-Scholes pricing with a crypto vol smile, a paper-trading account/positions ledger backed by SQLite, sign-in-with-wallet auth, and a live spot-price WebSocket feed.
Market data (spot prices, vol surface) is in-memory and nudged by a
background simulator — the backend has no production price-source or
on-chain publishing integration yet. The repository includes an
experimental Soroban settlement-oracle contract in contracts/oracle,
but it is not connected to the backend, deployed, or ready for real funds.
Everything else (accounts, positions, watchlist, alerts) persists to a
SQLite file via sqlx. This is a paper-trading backend for the frontend to
build against, not a production trading system.
cp .env.example .env # DATABASE_URL=sqlite://zenith.db, or leave unset for the same default
cargo run
# listening on 0.0.0.0:8081cargo test # unit + integration tests, plus the property-based invariant harness
cargo clippy --all-targets -- -D warnings
cargo fmt --checkNo external services required — sqlx creates and migrates the SQLite file on first run, and every integration test spins up its own throwaway temp-file database.
em.
cp .env.example .env # DATABASE_URL=sqlite://zenith.db, or leave unset for the same default
cargo run
# listening on 0.0.0.0:8081cargo test # unit + integration tests, plus the property-based invariant harness
cargo clippy --all-targets -- -D warnings
cargo fmt --checkNo external services required — sqlx creates and migrates the SQLite file on first run, and every integration test spins up its own throwaway temp-file database.
Secrets are resolved through a pluggable SecretProvider (env vars by
default; SOPS files and HashiCorp Vault are also supported) and cached in a
SecretStore that fails fast at startup and fails soft on refresh. HMAC keys
rotate through a sign-with-current / verify-with-current-or-previous ring, and
Stellar signing can be delegated to Vault Transit or AWS KMS so the private
key never enters process memory. See docs/secrets-rotation.md and
docs/threat-model.md for the system security assumptions and production
readiness gaps.
tests/invariants_test.rs is a property-based state-machine harness: it
generates random sequences of ledger operations, steps a pure in-memory
reference model and the real router in lockstep, and re-checks the
global invariants (conservation, no negative balances, locked collateral
equals the open legs' collateral, valid status transitions, cross-wallet
isolation) after every step. It runs with a bounded case count in CI;
the nightly workflow raises that via PROPTEST_CASES. Failing inputs
shrink to a minimal sequence and are persisted under
proptest-regressions/.
The fund-critical math (collateral, payoff, fees) is additionally
model-checked with Kani over the
integer-scaled prototypes in math_fixed.rs — see docs/verification.md
for the property list, the cargo kani command, and the documented f64
gap. .github/workflows/kani.yml runs it on every PR and nightly.
The Soroban option contract source is in contracts/options. It escrows an
explicitly configured SEP-41 token, mints seven-decimal option balances,
accepts admin-authorized expiry fixings, and pays capped intrinsic value.
See contracts/options/README.md for collateral semantics and build commands.
The Soroban integration lives in src/chain/. A transaction is simulated
first — the ledger footprint and the declared resources come from the
simulation, never from a constant — and the resource fee is computed from
those resources. The inclusion fee is bid dynamically: the strategy takes a
percentile of the fees the network recently accepted, clamped between a floor
and a ceiling, bounded by a per-transaction cap and a rolling 24 hour budget.
What was paid, and why, is persisted with the transaction. See
docs/chain-fees.md.
The event indexer records the ledger ranges it has covered, so a hole left by
a restart or an RPC outage is detectable instead of silently corrupting the
off-chain state. The gaps are repaired by a historical backfill that replays
through the same projection step as the live indexer, and reports the ranges
the node's retention window makes unrecoverable. See
docs/indexer-backfill.md.
Backups are two independent layers: Litestream streams the SQLite WAL to
object storage continuously, and scripts/backup.sh takes checksummed
snapshots to a separate bucket. scripts/restore.sh restores from either
and validates the result. docs/runbooks/disaster-recovery.md lists every
piece of durable state (including indexer cursors and on-chain sequence
numbers) and the recovery procedure for each failure scenario.
Liquidation shortfalls are never netted away silently: src/insurance.rs
records each one as a deficit, covers it from an append-only insurance
reserve as far as the reserve allows, and keeps the rest open until it's
covered or explicitly written off. GET /api/v1/insurance/coverage
publishes the reserve's coverage ratio. See docs/insurance-reserve.md.
SETTLEMENT_MODE=shadow runs the on-chain settlement path next to the
off-chain engine by simulation only and records every margin, payoff and
expiry comparison; shadow::cutover_readiness turns that into a go/no-go
for live settlement, and live mode keeps comparing to catch contract
regressions. See docs/shadow-mode.md.
src/retention.rs keeps the hot database bounded: per-table policies move
expired indexer events, projections and other high-volume rows into
sha256-checksummed JSONL archives (recorded in archive_manifests) before
deleting them, while settlement records of truth are never purged. See
docs/data-retention.md.
All /api/v1/* endpoints marked auth require an
Authorization: Bearer <token> header from /api/v1/auth/verify.
| Endpoint | What it does |
|---|---|
GET /health |
Liveness + a DB ping |
GET /api/v1/spot |
Current spot prices + base vols for all underlyings |
GET /api/v1/price |
Black-Scholes premium/Greeks for one option |
GET /api/v1/iv |
Implied vol for a given market price (Newton-Raphson) |
GET /api/v1/chain |
Full optio |
All /api/v1/* endpoints marked auth require an
Authorization: Bearer <token> header from /api/v1/auth/verify.
| Endpoint | What it does |
|---|---|
GET /health |
Liveness + a DB ping |
GET /api/v1/spot |
Current spot prices + base vols for all underlyings |
GET /api/v1/price |
Black-Scholes premium/Greeks for one option |
GET /api/v1/iv |
Implied vol for a given market price (Newton-Raphson) |
GET /api/v1/chain |
Full option chain (calls+puts) across strikes for one expiry |
GET /api/v1/expiries/:underlying |
Available expiries for an underlying |
GET /api/v1/stats |
Protocol-wide stats (mocked, not derived from real trades) |
GET /api/v1/insurance/coverage |
Insurance reserve balance, open deficits and coverage ratio |
GET /api/v1/ws/spot |
WebSocket: snapshot on connect, then a live tick every ~2s |
GET /api/v1/market-maker/stream auth |
Authenticated, sequenced WebSocket for maker quotes, shared events, and idempotent order handoff |
POST /api/v1/portfolio/payoff |
Combined P&L curve for a set of caller-supplied legs (no auth — legs carry their own premium) |
Market-maker sessions use the existing bearer session token and require an
enabled wallet entry in market_maker_accounts (provisioned by an operator;
there is no public enrollment endpoint). Quotes are stored per maker and are
not broadcast to competing makers. Clients reconnect with
?after=<sequence>; resync_required means replay exceeded the bounded
buffer or the connection fell behind. Series/oracle/margin publishers can
persist and fan out updates through market_maker::publish_event; private
margin events must include their wallet audience. Order entry remains
fail-closed until an on-chain SettlementHandoff adapter and a positive
per-order notional limit are configured through
build_router_with_market_maker. The default router does not submit trades
to the simulated chain client.
| Endpoint | What it does |
|---|---|
POST /api/v1/auth/nonce |
Issue a single-use, 5-minute sign-in message for a wallet address |
POST /api/v1/auth/verify |
Verify the signed message, get a 24h bearer session token |
GET /api/v1/auth/me auth |
Confirm the current token's wallet address |
Every POST below (positions open/close/roll, watchlist/alerts create,
strategies/execute) is rate-limited per-IP (5/s, burst 20) on top of
requiring a session — see mutation_rate_limited_routes() in lib.rs.
| Endpoint | What it does |
|---|---|
GET /api/v1/account |
Balance + locked collateral |
GET /api/v1/positions |
List positions (?status=, ?strategy_id=, ?limit=, ?offset=) |
POST /api/v1/positions/open |
Price and open one position |
POST /api/v1/positions/:id/close |
Settle an open position at current spot/vol |
POST /api/v1/positions/:id/roll |
Close + reopen at a new strike/expiry, atomically |
GET /api/v1/history |
Closed/rolled positions + win/loss/pnl stats (?limit=, ?offset= — stats always cover the full history, not just the returned page) |
GET /api/v1/portfolio/greeks |
Aggregate Greeks across all open positions, repriced live |
POST /api/v1/strategies/execute |
Open 2+ legs atomically under one shared strategy_id |
GET /api/v1/strategies |
List multi-leg strategies with aggregate realized/unrealized P&L |
GET /api/v1/strategies/:id |
One strategy's legs + aggregate P&L |
POST /api/v1/strategies/:id/close |
Close every currently-open leg of a strategy atomically |
POST /api/v1/rfqs |
Create a private, wallet-scoped RFQ for invited counterparties |
GET /api/v1/rfqs/:id |
Read an RFQ and visible quotes (requester or invited maker) |
POST /api/v1/rfqs/:id/quotes |
Submit or update a quote as an invited maker |
POST /api/v1/rfqs/:id/accept |
Accept one quote as an off-chain agreement |
POST /api/v1/rfqs/:id/cancel |
Cancel an open RFQ |
| Endpoint | What it does |
|---|---|
GET / POST /api/v1/watchlist |
List / add a watched symbol |
DELETE /api/v1/watchlist/:underlying |
Remove a watched symbol |
GET / POST /api/v1/alerts |
List / create a price alert (above/below a target) |
DELETE /api/v1/alerts/:id |
Remove an alert |
Alerts are checked against spot every 10s by a background task; a triggered alert stays in the table (visible via GET) rather than being deleted.
RFQ acceptance currently records negotiation state only; it does not transfer assets or positions. SEP-7 URI construction and the Soroban authorization plan types are groundwork, not a live signing/submission or delivery-versus-payment pipeline. See docs/stellar-settlement.md.
Every response carries an x-request-id header — a fresh UUIDv4 if the
request didn't already have one, or the caller's own value echoed back
unchanged otherwise — for tracing a single request through logs.
src/
├── main.rs # Thin entrypoint: init_tracing -> init_state -> build_router -> serve
├── lib.rs # Pricing engine, AppState, request/response types, route wiring
├── db.rs # SQLite pool + migration runner
├── models.rs # Row structs (Account, Position, WatchlistItem, Alert)
├── error.rs # AppError: JSON {"error": "..."} instead of empty-body status codes
├── auth.rs # Sign-in-with-wallet: nonce, verify, AuthUser extractor, session cleanup
├── strkey.rs # Stellar G... address <-> raw ed25519 pubkey codec
├── collateral.rs # Collateral rules for writing options (100% calls, 110% puts)
├── payoff.rs # Combined multi-leg P&L math (ported from the frontend's lib/payoff.ts)
├── secrets/ # SecretProvider (env/SOPS/Vault), SecretStore, HMAC key rings, SigV4
├── signing.rs # Signer trait + local / Vault Transit / AWS KMS ed25519 signers
├── math_fixed.rs # Integer-scaled prototypes of collateral/payoff/fee math (Kani-verified)
├── kani_proofs.rs # Kani harnesses for math_fixed, compiled only under `cargo kani`
├── positions.rs # Account/position/roll/greeks handlers + the open/close tx helpers
├── strategies.rs # Multi-leg atomic execution, built on positions.rs's tx helpers
├── history.rs # Closed/rolled positions + stats
├── request_id.rs # UUIDv4 generator for the x-request-id middleware
├── watchlist.rs, alerts.rs, prices.rs # Per-domain CRUD + background loops
migrations/ # One file per schema change, embedded into the binary at compile time
tests/
├── common/mod.rs # TestApp: real router over a throwaway temp-file DB, via tower::oneshot
└── *_test.rs # One file per domain, black-box HTTP-level assertions
main.rs is intentionally thin — everything testable lives in the
zenith_backend library crate, which is what lets tests/*_test.rs
exercise the real router without a bin-only crate's usual restriction
(a tests/ directory can only see a library crate's public items).
smile_vol() is a bit-for-bit port of the frontend's smileVol() —
including its wing term being unconditionally (|moneyness-1|-0.15)^2
rather than clamped at zero near the money, which looks like a bug but
matches the frontend's shipped (if quirky) behavior on purpose, for
pricing parity between client and server.
Closing/rolling a position reprices it with the same time-to-expiry it was opened with, rather than tracking an absolute expiry timestamp and computing real elapsed time. Fine for a paper-trading demo; not a real theta-decay model.
- No real market data feed — spot prices are seeded constants nudged by a random-walk simulator, not sourced from anywhere real.
- No on-chain / Soroban integration — this is pure off-chain paper trading.
Dockerfileand the CI workflow are not build/run-tested against a real Docker daemon or GitHub Actions runner from this environment — reviewed for correctness, not executed end-to-end.- The mutation rate limiter's bearer-token fallback (for requests with no token at all) keys on peer IP only, without replicating SmartIpKeyExtractor's x-forwarded-for/x-real-ip/forwarded header chain — acceptable since that fallback path is only reached by requests that fail AuthUser's own check regardless, but it does mean that one specific path isn't proxy-aware the way the auth endpoints' limiter is.
Previously listed here and since addressed: the three background loops
(auth cleanup, alert checks, price simulator) now have direct unit
tests against their extracted per-tick logic; the rate limiter moved
off a single global quota to per-IP (SmartIpKeyExtractor) on the auth
endpoints and per-wallet (BearerOrIpKeyExtractor) on every mutating
one (positions, watchlist, alerts, strategies), so wallets sharing an
IP no longer share a quota; list_positions/get_history now report
total count and whether more pages exist (x-total-count/x-has-more
headers on positions, a has_more field on history) instead of
leaving a paging client to guess; malformed query params and JSON
bodies now return the same {"error": "..."} shape as every other
failure instead of axum's plain-text rejections (AppQuery/AppJson
in error.rs); every remaining unexpected-DB-failure path across the
whole app now names the specific operation that failed (db_error()
in error.rs) instead of a bare "Internal Server Error" with no other
detail; and cross-wallet ownership on close/roll (a stranger 404s
trying to close or roll another wallet's position, same as it already
did for deleting someone else's alert/watchlist entry) and
roll_position's replacement-leg validation are now actually tested
rather than just assumed from reading the SQL.
MIT © Zenith Protocol Contributors