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.
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.
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.
|
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. |
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
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.
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.
| # | 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.
|
Signed writes
|
Reads
|
|
Feeds maker fanout · per RFQ taker |
Guarantees nonce replay protection · exactly one terminal event |
Settlement is the happy path. These are the others.
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 The accept window elapses. Every reservation unwinds, nothing moves. |
A live quote, mid window Both sides quoted on a two way RFQ, countdown running. |
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.
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.
⏱ 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
cargo build --release --workspaceStart the venue
Terminal 1 — server
cargo run --release -p server -- --seed seed.toml --port 4402Terminals 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 --offlineLast 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 balancesRun 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 2Run the tests
cargo test -p server
cargo test -p server --release
cargo test -p server --release --test e2e -- --test-threads=1 --nocaptureThe 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 🦀



