Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
58 changes: 43 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
5 changes: 5 additions & 0 deletions crates/clob/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
147 changes: 147 additions & 0 deletions crates/clob/benches/read_contention.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
//! CLOB read-path latency under a concurrent writer: `Arc<Mutex<Book>>` vs `ArcSwap<Book>`.
//!
//! This reproduces the openhl read path — the `read_best_bid` precompile locks
//! the shared book (`Arc<Mutex<Book>>` 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<Mutex<Book>>`: the reader blocks whenever the writer holds the lock.
//! - `ArcSwap<Book>`: 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<u128>) {
let mean = lat.iter().sum::<u128>() / 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<u128> {
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<u128> {
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<Mutex<Book>>", bench_mutex());
let _ = bench_arcswap();
report("ArcSwap<Book>", 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.");
}