Skip to content

Repository files navigation

⚡ rfq-coordinator

A lock free settlement engine for a request for quote trading venue

Rust Tests Tokio Ed25519

Takers request a price. Makers compete. The server settles.

Sole custodian of every balance, so no value is ever created or destroyed on any path.


🎬 It running

Four terminals: the server, two competing makers, and the taker CLI. A two way SOL RFQ opens, both makers quote blind, the tighter spread wins.

A two way SOL RFQ settling end to end

Read the bottom right pane. The taker goes down $156.156 and up 2 SOL. The winning maker goes up $156.156 and down 2 SOL. Uninvolved makers are untouched, and every venue wide total is unchanged.

That is the conservation invariant, visible in one frame.


🎯 The problem

What it does

A taker asks for a price on a size of an asset. Makers quote against it with signed orders. At window close the server picks a winner per side, and if the taker accepts, both legs move at once.

Why it's hard

The server holds everyone's money. Settlement is five steps with inconsistent state in between. Every race has to have exactly one defined answer, and the books have to balance after every single one.


🏗 Architecture

      HTTP edge                    THE ACTOR                    Feeds
   ┌──────────────┐            ┌────────────────┐          ┌──────────────┐
   │  verify sig  │            │                │          │ maker fanout │
   │  parse body  │──mpsc──▶   │  owns Ledger   │──events─▶│              │
   │  stamp time  │            │  owns World    │          │ taker per    │
   │              │◀─oneshot── │  BY VALUE      │          │ RFQ socket   │
   └──────────────┘            └────────────────┘          └──────────────┘
     many tasks                   one task                    many sockets
     in parallel                  serial                      broadcast

🔒 One actor owns everything

A single tokio task holds the ledger and world state by value. No Arc. No Mutex. No RwLock. Anywhere.

Settlement is a five step sequence with inconsistent intermediate states. A lock would have to be held across all five, including across await points, which serializes the work anyway while adding deadlock risk. Single ownership gets the same serialization for free, and the compiler enforces it.

The payoff: every race collapses into a dequeue ordering question with a small, enumerable set of outcomes. There is no interleaving to reason about.

📊 Holds net to a maximum, not a sum

A maker can quote several orders on one RFQ, but only one can win.

  orders:  100 lots ── 150 lots          hold = max(100, 150) = 150
                                                    NOT 250
  cancel the 100:
     ✗  subtract 100  →  hold 50   ← survivor needs 150. Underfunded.
     ✓  recompute max →  hold 150  ← nothing releases, because nothing should.

Because the hold is a max, it doesn't decompose into per order shares. Release has to recompute, never subtract.

⚖️ Conservation is asserted, not assumed

# Invariant Catches
1 Supply per asset is constant Money created or destroyed
2 available == total − reserved Internal inconsistency
3 reserved == Σ per-RFQ maxima The netting bug the other two miss

Assertion 3 is the one that earns its keep. Under a subtract instead of recompute bug, the first two still pass, because the books stay internally consistent while being wrong.


📡 Surface

Signed writes Ed25519

submit_rfq · place_order · cancel · accept · decline

Reads

/health · /v1/assets · /v1/balances · /v1/rfqs/{id}

Feeds WebSocket

maker fanout · per RFQ taker

Guarantees

nonce replay protection · exactly one terminal event


🔄 Every RFQ reaches exactly one terminal state

Settlement is the happy path. These are the others.

An RFQ expiring, a directed RFQ settling, and a maker cancelling and replacing its quote

Two RFQs here. The first expires with nobody accepting. The second is directed, so makers see which way the taker is trading and quote one side only. Watch the bottom left pane: the sniper maker cancels its throwaway quote and replaces it moments before close, then wins.

State transitions are driven by timers inside the actor, never by socket activity. An RFQ with no subscriber progresses identically to one with three.

Declined, then expired

A declined RFQ expiring

The accept window elapses. Every reservation unwinds, nothing moves.

A live quote, mid window

A live two way quote awaiting a decision

Both sides quoted on a two way RFQ, countdown running.


🧩 Two decisions worth calling out

The replay seam — why a duplicate beats a gap

A socket joining mid RFQ needs both a replay and a live subscription. Those are two steps, with the world moving in between.

Order Result
Replay, then subscribe Anything emitted in the gap is lost forever
Subscribe, then replay Nothing lost, but an event can arrive twice

I chose subscribe first and filter, on an asymmetry: a gap is undetectable and permanent, a duplicate is visible and droppable. Choose the failure you can fix.

Why the filter is safe: each event kind fires at most once per RFQ. Exactly one of quote or no_quote. Exactly one terminal event. So a suppressed duplicate is provably the same event arriving by two paths.

Where it breaks: add an event kind that can legitimately repeat and the filter silently eats real data, with no test catching it.

Slow consumers get disconnected — loud failure beats silent corruption

Both feeds carry mandatory terminal events. A client whose receiver lagged has lost events but has no idea which, since that is the nature of lag.

Keep streaming and it believes it saw everything, possibly including an RFQ ending it never learned about. Closing turns silent data loss into a loud reconnect signal.

The cost is real: a maker treating disconnection as fatal will exit rather than quote against a stale view. That is the right trade. A crashed maker is diagnosable in seconds. A maker quoting on stale state is a bug you find days later, in the money.


🧪 Testing

75 tests · 6 layers · green in debug and release

Layer Count What it covers
🧮 Ledger 13 Accounting in isolation, plus a property test over random operation sequences
🎭 Actor 6 Lifecycle through the command channel, no HTTP
🔏 Signing 24 Byte exact canonical strings against hand written literals
🌐 Routes 19 Real HTTP over a real actor, precedence for every adjacent pair
📡 Feeds 10 Live sockets, stream isolation, the replay seam
🔄 End to end 3 Real server, maker, and CLI processes over real sockets

⚠️ Conservation is checked over the wire at the end to end layer, because the internal assertion is compiled out in release, which is the profile that actually ships.

How the races were forced

  ⏱  virtual time              →  a 20 second TTL costs nothing
  📉  depth one event bus       →  deterministic lag, where real congestion is unreachable
  🎯  400ms early / 250ms late  →  brackets the deadline so the middle case means something

🚀 Running it

cargo build --release --workspace
Start the venue

Terminal 1 — server

cargo run --release -p server -- --seed seed.toml --port 4402

Terminals 2+ — makers, any subset

target/release/rfq-maker --key keys/maker-tight.json  --strategy tight  --offline
target/release/rfq-maker --key keys/maker-sniper.json --strategy sniper --offline

Last terminal — taker

target/release/rfq-cli balances
target/release/rfq-cli --key keys/taker.json submit --asset SOL --size 2 --ttl 5 --accept buy
target/release/rfq-cli balances

Run balances before and after. Taker's SOL up, USD down by the notional, the winning maker exactly the reverse, and every venue wide total unchanged.

Other things to try
# directed, so makers see the side and quote one way only
target/release/rfq-cli --key keys/taker.json submit --asset AAPL --size 1 --direction sell --ttl 5 --accept sell

# interactive, prompts before accepting
target/release/rfq-cli --key keys/taker.json submit --asset SOL --size 2 --ttl 5

# let it expire, just don't accept
target/release/rfq-cli --key keys/taker.json submit --asset DOGE --size 100 --ttl 2
Run the tests
cargo test -p server
cargo test -p server --release
cargo test -p server --release --test e2e -- --test-threads=1 --nocapture

The end to end suite spawns real processes, so it needs the full workspace built first and --test-threads=1. Expect about 65 seconds.

Gotchas
📦 Package and binary names differ. Package maker builds rfq-maker. Use cargo run -p maker --bin rfq-maker
🔌 Makers need --offline unless a live price feed is reachable
🔑 One key per process. The client seeds its nonce from wall clock time, so two processes sharing a keyfile permanently break whichever started first
🎨 RUST_LOG=rfq_maker=debug to see sniper's successful cancels. It logs those at DEBUG and only failures at INFO

Built in Rust 🦀

About

RFQ venue settlement engine in Rust. One actor owns the ledger by value so settlement is atomic without locks. Conservation invariant asserted on every path, 75 tests across six layers.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages