diff --git a/Cargo.lock b/Cargo.lock index 636ee89..7f0a362 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1039,6 +1039,15 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "archery" version = "1.2.2" @@ -7764,6 +7773,7 @@ dependencies = [ name = "rdk-clob" version = "0.1.0" dependencies = [ + "arc-swap", "proptest", "serde", ] diff --git a/Cargo.toml b/Cargo.toml index 87ae995..1063812 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,9 @@ rdk-liquidation = { path = "crates/liquidation" } rdk-vault = { path = "crates/vault" } rdk-clearing = { path = "crates/clearing" } +# --- concurrency / benchmarking (dev) --- +arc-swap = "1.7" + # --- OpenHL app crates --- openhl-evm = { path = "openhl/crates/evm" } openhl-consensus = { path = "openhl/crates/consensus" } diff --git a/README.md b/README.md index 23a85b3..60e93df 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,46 @@ -# rdk — Reth DeFi Kit - -Ready-made DeFi L1 stack: **Reth/REVM (execution) + Malachite (BFT consensus) + DeFi primitives**. - -A monorepo hosting: - -- **`crates/`** — the kit itself: reusable DeFi L1 primitives (CLOB, funding, vault, liquidation, clearing, oracle, types, codec) consumable by any Reth+Malachite-based L1. -- **`openhl/`** — EVM Perp Sandbox engine. Reth+Malachite L1 that makes perp DEX behavior explorable. The engine behind Fabrknt's [EVM Perp Sandbox](https://fabrknt.com/evm-perp.html). See `openhl/README.md`. -- **`princeps/`** — EVM Prime Broker Sandbox engine. Reth+Malachite L1 unifying lending + perps + (future) options under one shared risk engine. The engine behind Fabrknt's [EVM Prime Broker Sandbox](https://fabrknt.com/evm-prime-broker.html). See `princeps/README.md`. - -## Why a kit - -Both OpenHL (perp DEX) and Princeps (prime broker) need the same L1 substrate (Reth EVM + Malachite consensus + custom precompile patterns) and the same trading primitives (orderbook matching, funding computation, collateral liquidation, settlement). Forking openhl into princeps and developing in parallel produced massive duplication. The kit factors the shared layer once so each app focuses on its own integration (precompile set, consensus hooks, node wiring) and product-specific crates (e.g., princeps-lending, princeps-portfolio). - -This mirrors how Reth itself ships: a single repo with reusable `reth-*` crates and the `reth` binary on top. +# rdk — build a DeFi L1 on Reth + +**Complete, tested reference implementations of DeFi L1s on Reth + Malachite** — a perp DEX +(OpenHL) and a prime broker (Princeps) — plus the shared kit of primitives behind them. + +Want to see how a real perpetuals exchange or prime broker works as its *own* Reth-based L1 — +consensus, EVM precompiles, orderbook, funding, liquidation, settlement, wired end-to-end? This is a +working, **590-test** reference you can read and run. Built the way Reth ships: reusable `rdk-*` +crates + node binaries on top. + +## The reference implementations + +- **`openhl/` — a Hyperliquid-shape perp DEX, as its own L1.** Reth (EVM execution) + Malachite (BFT + consensus) + on-chain CLOB, funding, and liquidation via custom precompiles. A complete, explorable + perp DEX you can run locally. → [`openhl/README.md`](openhl/README.md) · powers Fabrknt's + [EVM Perp Sandbox](https://fabrknt.com/evm-perp.html) +- **`princeps/` — a prime broker, as its own L1.** The same substrate, unifying lending + perps + + (future) options under one shared risk engine. → [`princeps/README.md`](princeps/README.md) · + powers Fabrknt's [EVM Prime Broker Sandbox](https://fabrknt.com/evm-prime-broker.html) + +## The kit behind them — `crates/` + +Both apps need the same substrate and the same trading primitives, so the shared layer is factored +once: `rdk-*` crates any Reth+Malachite L1 can consume. + +| crate | what it does | +|---|---| +| `clob` | central-limit orderbook matching engine | +| `funding` | funding-rate computation | +| `vault` | share-based collateral pooling | +| `liquidation` | margin math + insurance fund + scanner + ADL | +| `clearing` | settlement | +| `oracle` | signed-observation aggregation + circuit breaker | +| `types` · `codec` | shared data structures + wire codec | + +### Why one repo (not three) + +OpenHL (perp DEX) and Princeps (prime broker) need the same substrate (Reth EVM + Malachite consensus ++ custom precompile patterns) and the same trading primitives (matching, funding, liquidation, +settlement). Forking one into the other produced massive duplication; the kit factors the shared +layer once so each app focuses on its own integration (precompile set, consensus hooks, node wiring) +and product-specific crates (e.g. `princeps-lending`, `princeps-portfolio`). **This mirrors how Reth +itself ships: one repo, reusable `reth-*` crates, and binaries on top.** ## Structure diff --git a/crates/clob/Cargo.toml b/crates/clob/Cargo.toml index 7912093..ad82716 100644 --- a/crates/clob/Cargo.toml +++ b/crates/clob/Cargo.toml @@ -12,6 +12,11 @@ serde = { workspace = true } [dev-dependencies] proptest = { workspace = true } +arc-swap = { workspace = true } [lints] workspace = true + +[[bench]] +name = "read_contention" +harness = false diff --git a/crates/clob/benches/read_contention.rs b/crates/clob/benches/read_contention.rs new file mode 100644 index 0000000..21a763b --- /dev/null +++ b/crates/clob/benches/read_contention.rs @@ -0,0 +1,147 @@ +//! CLOB read-path latency under a concurrent writer: `Arc>` vs `ArcSwap`. +//! +//! This reproduces the openhl read path — the `read_best_bid` precompile locks +//! the shared book (`Arc>` in `live_node.rs`) that the block-builder +//! also locks to apply orders — and measures what a reader actually feels while a +//! writer is busy. +//! +//! - `Arc>`: the reader blocks whenever the writer holds the lock. +//! - `ArcSwap`: the reader never blocks (lock-free load); the writer +//! instead pays a full `Book` clone per published version. +//! +//! It measures reader **tail latency** (p50/p99/p99.9/max), because the interesting +//! cost is not the mean — it's the spikes a trader feels when a read lands during a +//! writer's lock hold. Numbers are for THIS machine; run it yourself: +//! +//! ```text +//! cargo bench -p rdk-clob --bench read_contention +//! ``` + +use std::hint::black_box; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Instant; + +use arc_swap::ArcSwap; +use rdk_clob::{AccountId, Book, Order, OrderId, OrderType, Price, Qty, Side}; + +const READS: usize = 200_000; +const DEPTH: u64 = 500; + +/// Seed both sides of the book so `best_bid` is real and crossing submits do work. +fn seed_book() -> Book { + let mut b = Book::new(); + for i in 0..DEPTH { + b.submit(Order { + id: OrderId(i), + account: AccountId(1), + side: Side::Buy, + qty: Qty(10), + order_type: OrderType::Limit { price: Price(1000 - i) }, + }); + b.submit(Order { + id: OrderId(1_000_000 + i), + account: AccountId(2), + side: Side::Sell, + qty: Qty(10), + order_type: OrderType::Limit { price: Price(1001 + i) }, + }); + } + b +} + +/// A small crossing order: matches a little top-of-book depth, so the writer's +/// lock hold is non-trivial (walks the book) — as a real submit would be. +fn writer_order(n: u64) -> Order { + let (side, price) = if n % 2 == 0 { + (Side::Buy, Price(1001)) // lifts the best ask + } else { + (Side::Sell, Price(1000)) // hits the best bid + }; + Order { id: OrderId(2_000_000 + n), account: AccountId(3), side, qty: Qty(5), order_type: OrderType::Limit { price } } +} + +fn percentile(sorted: &[u128], p: f64) -> u128 { + let idx = (((sorted.len() - 1) as f64) * p).round() as usize; + sorted[idx] +} + +fn report(name: &str, mut lat: Vec) { + let mean = lat.iter().sum::() / lat.len() as u128; + lat.sort_unstable(); + println!( + "{name:<18} mean={mean:>5}ns p50={:>5}ns p99={:>6}ns p99.9={:>7}ns max={:>9}ns", + percentile(&lat, 0.50), + percentile(&lat, 0.99), + percentile(&lat, 0.999), + lat.last().unwrap(), + ); +} + +fn bench_mutex() -> Vec { + let book = Arc::new(Mutex::new(seed_book())); + let stop = Arc::new(AtomicBool::new(false)); + let (wb, ws) = (book.clone(), stop.clone()); + let writer = thread::spawn(move || { + let mut n = 0u64; + while !ws.load(Ordering::Relaxed) { + { + let mut b = wb.lock().unwrap(); + b.submit(writer_order(n)); + } + n = n.wrapping_add(1); + } + }); + + let mut lat = Vec::with_capacity(READS); + for _ in 0..READS { + let t = Instant::now(); + { + let b = book.lock().unwrap(); + black_box(b.best_bid()); + } + lat.push(t.elapsed().as_nanos()); + } + stop.store(true, Ordering::Relaxed); + writer.join().unwrap(); + lat +} + +fn bench_arcswap() -> Vec { + let book = Arc::new(ArcSwap::from_pointee(seed_book())); + let stop = Arc::new(AtomicBool::new(false)); + let (wb, ws) = (book.clone(), stop.clone()); + let writer = thread::spawn(move || { + let mut n = 0u64; + while !ws.load(Ordering::Relaxed) { + let mut next = (*wb.load_full()).clone(); // writer pays the snapshot clone + next.submit(writer_order(n)); + wb.store(Arc::new(next)); + n = n.wrapping_add(1); + } + }); + + let mut lat = Vec::with_capacity(READS); + for _ in 0..READS { + let t = Instant::now(); + { + let g = book.load(); // lock-free + black_box(g.best_bid()); + } + lat.push(t.elapsed().as_nanos()); + } + stop.store(true, Ordering::Relaxed); + writer.join().unwrap(); + lat +} + +fn main() { + println!("CLOB read-path latency under a concurrent writer — {READS} reads, book depth {DEPTH}/side\n"); + let _ = bench_mutex(); // warm up (page-in, CPU ramp) + report("Arc>", bench_mutex()); + let _ = bench_arcswap(); + report("ArcSwap", bench_arcswap()); + println!("\nTradeoff: ArcSwap flattens the reader tail (no lock wait) but the writer clones"); + println!("the whole Book per published version — cost + allocation move to the write side."); +}