
Crossbook: A Noncustodial Hybrid DEX in Rust and Solidity
Every decentralized exchange has to pick a side of an uncomfortable tradeoff. An on-chain order book is fully trustless but pays gas on every quote and cancel, so it never matches the latency of a real matching engine. A centralized off-chain book is fast but custodial: you hand your funds to a venue and hope. Crossbook is my attempt to refuse the tradeoff: match off-chain at memory speed, settle on-chain with the contract trusting nothing.
The model is borrowed, on purpose, from CoW Protocol. A maker signs an EIP-712 order off-chain. No gas, no transaction, just a signature over a struct. A pure Rust matching engine crosses signed orders in memory. Then a Solidity settlement contract takes the matched batch, independently re-verifies every signature, expiry, nonce, and limit price, pulls the tokens from each maker's wallet, and swaps them in a single atomic transaction. Funds never leave the trader's wallet until the moment of execution; a maker grants the settlement contract an ERC-20 allowance once, and the contract pulls only at settlement.
I built it as a portfolio centerpiece for Rust backend and DeFi work, so the part I cared about most is not the happy path. It is the threat model and the test suite that prove the happy path can't be subverted. This article is the tour I'd give a reviewer.
The one idea: the contract does not trust the engine
If you remember one thing about Crossbook, make it this. The matching engine is a convenience. It finds crossing orders and submits them as a batch because someone has to. But it has no special authority, and the settlement contract treats everything it submits as an unverified claim.
That single boundary is what makes the whole design safe:
- The engine says "these two orders cross at this price." The contract re-recovers both signatures and re-checks both limit prices itself.
- The engine says "fill order X for this amount." The contract bounds that against the order's own signed
sellAmountand the cumulative amount already filled. - The engine could be buggy, or outright malicious, and the worst it can do is produce a batch that reverts. It can never move funds against a maker's signed limits.
Because correctness is enforced on-chain, the engine's correctness is never a safety requirement, only a liveness one. That is a liberating place to write a matching engine from, and it's why I could make the core ruthlessly simple.
The matching core: pure, single-writer, deterministic
crossbook-core is the heart, and it is deliberately boring. It is a continuous price-then-time-priority limit-order matcher in the sell-amount / buy-amount model, and it does no I/O, holds no clock, and runs no async. Matching is a pure function: (book_state, ordered_inputs) -> (new_state, trades). Anything non-deterministic (arrival ordering, the EIP-712 domain, the current time) is supplied from outside as input and never read from inside the core.
Purity buys two things. First, golden-replay determinism: I can record a sequence of submits and assert it always produces byte-identical fills, which makes a whole class of bugs reproducible. Second, an LMAX-style hot path with nothing to lock and nothing to wait on.
Prices are ratios, and ratios overflow
A limit order is a ratio: buyAmount / sellAmount. To order the book and to check whether a taker clears a maker, you compare two ratios (b1/s1 vs b2/s2), which is the same as comparing b1*s2 vs b2*s1. Never divide; division loses precision and would collapse distinct prices.
The trap is that with 18-decimal tokens, those operands approach 2^256, so the cross-product reaches ~512 bits. A naive U256 multiply panics in debug and silently wraps in release. So every ratio comparison widens to U512 first:
pub fn cmp_limit(b1: U256, s1: U256, b2: U256, s2: U256) -> Ordering {
// b1/s1 vs b2/s2 is the same as b1*s2 vs b2*s1, widened so it cannot overflow.
let lhs: U512 = b1.widening_mul(s2);
let rhs: U512 = b2.widening_mul(s1);
lhs.cmp(&rhs)
}
/// ceil(a*b / d). Rounds a maker's received amount up, in the maker's favor.
pub(crate) fn mul_div_ceil(a: U256, b: U256, d: U256) -> U256 {
let num: U512 = a.widening_mul(b);
let dd = to_u512(d);
let q = num / dd;
let q = if (num % dd).is_zero() { q } else { q + to_u512(U256::from(1u64)) };
narrow(q)
}Notice the ceil. When a maker is partially filled, the amount it receives is rounded up, in the maker's favor, so a maker always gets at least its signed limit price. Getting the rounding direction wrong is not cosmetic; it leaks dust against the user.
The hot path allocates nothing, and I can prove it
It's easy to claim a match loop is allocation-free. It's better to make it a test that fails if you're wrong. submit is split into a read-only planning phase that walks the opposite side cheapest-first into a reused scratch buffer, then a commit phase that applies fills by order hash. The caller passes in the output Vec and it's reused across submits, so the steady state never touches the allocator.
To prove it, I install a counting global allocator and assert exactly zero allocations on a warm submit:
static ALLOCS: AtomicUsize = AtomicUsize::new(0);
struct Counting;
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
ALLOCS.fetch_add(1, Ordering::Relaxed);
System.alloc(l)
}
unsafe fn dealloc(&self, p: *mut u8, l: Layout) { System.dealloc(p, l) }
}
#[global_allocator]
static GLOBAL: Counting = Counting;let before = ALLOCS.load(Ordering::Relaxed);
let outcome = book.submit(taker, &mut out);
let after = ALLOCS.load(Ordering::Relaxed);
assert_eq!(out.len(), 16, "should have produced 16 fills");
assert_eq!(after - before, 0, "hot match path allocated {} times", after - before);
// touch outcome so it is not optimized away
assert!(matches!(outcome, SubmitOutcome::FullyFilled));That last line matters more than it looks. Without consuming outcome, the optimizer is free to delete the very work you're trying to measure. A measurement test that doesn't observe its result measures nothing.
On an Apple M4 (rustc 1.94.1, thin LTO), the crossing path runs about 165 nanoseconds per fill including the 512-bit arithmetic, roughly 6 million fills per second, and a resting insert lands around 356 ns. These are core microbenchmarks, not end-to-end numbers, and I label them that way in the repo. I'd rather under-claim than have a reviewer catch me conflating the two.
Two languages, one digest: the EIP-712 parity gate
Here is the seam where hybrid designs quietly break. The order is hashed in Rust (to compute its id and to verify the maker's signature at intake) and hashed again in Solidity (to recover the signer on-chain). If those two hashes ever disagree by a single byte, the system doesn't error. It just silently rejects every order, because the on-chain ecrecover returns a different address than the one the engine admitted.
So I made schema parity a hard gate. The canonical Order struct is eight fields, declared identically on both sides:
struct Order {
address maker; // signer; funds pulled from here
address sellToken;
address buyToken;
uint256 sellAmount;
uint256 buyAmount; // limit price = buyAmount / sellAmount
uint256 validTo; // unix timestamp expiry
uint256 nonce; // disambiguates orders; part of the order id
bool partiallyFillable; // false = fill-or-kill
}The Rust side declares the same struct via Alloy's sol! macro and hashes it with eip712_signing_hash, so the digest is identical to Solidity's OpenZeppelin EIP712 + OrderLib "for free." But "for free" is exactly the kind of claim that's wrong until tested, so the gate pins exact 32-byte digests for four order vectors and asserts both languages reproduce them:
fn expected(name: &str) -> B256 {
match name {
"basic" => b256!("44dc657587daad14d16ff62051e3b82762be4ff115a8327090501c6a03e0983b"),
"zeros" => b256!("0bd94b83e5f67d384bbbf2a21e3ae26e58caf51aedc1f5d51c8314b1c0a6c07b"),
"maxed" => b256!("4dacc0230564d04ba8e4e12d03215c76ed2381eb868ed78b870c60064c9303e7"),
"basic_fok" => b256!("e3b007a1c212696aa565ccd6daae20d439aa584796dd949c6e04f0f4b753a56a"),
other => panic!("unknown vector {other}"),
}
}A matching Foundry test asserts the Solidity OrderLib produces the same hex. The vectors are chosen to stress the edges: an all-zeros order, an all-max order (type(uint256).max, type(uint64).max, 0xFF... addresses), and a pair that differs only in partiallyFillable to prove the bool is actually bound into the hash. And expected() panics on an unknown vector instead of returning a default. In a parity gate, an unpinned case must be a loud error, not a silent pass. In the build plan this is milestone M2, and the note next to it reads: do not proceed to settlement until this is green.
The validTo trap
The best bug I caught here never made it to a commit, but it taught the most. On the wire, validTo is uint256. In the ergonomic Rust type, I store it as a u64, because a unix timestamp is obviously a u64, right? It is. But the struct fed to the hasher must encode it as uint256. Narrow the encoded width and the digest changes, the on-chain ecrecover recovers a stranger, and the order silently fails to settle with no error anywhere.
The lesson generalizes: when you hash a struct across an ABI boundary, the encoded widths must match the canonical schema exactly, even when your in-memory type is more convenient. Your ergonomic type and your wire type are not the same type.
The settlement contract: re-verify everything, hold nothing
CrossbookSettlement is where the trust boundary becomes code. A single permissioned solver submits a batch of signed orders plus a list of fills, and the contract believes none of it. Inside settle(), guarded by onlySolver, whenNotPaused, and nonReentrant, it re-derives every digest and recovers every signer:
bytes32[] memory hashes = new bytes32[](orders.length);
for (uint256 i = 0; i < orders.length; i++) {
Order calldata o = orders[i].order;
bytes32 h = _hashTypedDataV4(o.hash());
if (ECDSA.recover(h, orders[i].signature) != o.maker) revert InvalidSignature();
if (block.timestamp > o.validTo) revert OrderExpired();
if (cancelled[h]) revert OrderIsCancelled();
hashes[i] = h;
}Then, per fill, it bounds the cumulative amount, enforces fill-or-kill semantics, and re-checks the limit price in 512-bit math, the same comparison as the Rust core, on the other side of the boundary:
uint256 newFilled = filledSell[h] + f.sellFilled; // checked add
if (newFilled > o.sellAmount) revert Overfill();
if (!o.partiallyFillable && (filledSell[h] != 0 || f.sellFilled != o.sellAmount)) {
revert PartialFillNotAllowed();
}
// buyFilled / sellFilled >= buyAmount / sellAmount, cross-multiplied in 512 bits
if (!_ge512(f.buyFilled, o.sellAmount, f.sellFilled, o.buyAmount)) {
revert LimitPriceViolated();
}
filledSell[h] = newFilled;Cumulative fills live in mapping(bytes32 => uint256) filledSell, keyed by the EIP-712 order hash, which is CoW's filledAmount pattern. That's what lets a partiallyFillable order be filled across several settlements without ever exceeding its signed sellAmount, and it's why the order id is the digest. The fill-or-kill branch is subtler than it reads: it rejects unless filledSell[h] == 0 && f.sellFilled == o.sellAmount, so a fill-or-kill order must be filled in exactly one shot and can never be touched twice.
Net-to-zero, and 512 bits in the EVM
The contract holds no inventory, ever, and that is enforced rather than assumed. It accumulates per-token inflow and outflow in memory and, at the end of the batch, requires every touched token to balance:
for (uint256 k = 0; k < nToks; k++) {
if (inflow[k] != outflow[k]) revert InventoryNotZero(toks[k]);
}
function _ge512(uint256 a, uint256 b, uint256 c, uint256 d) private pure returns (bool) {
(uint256 hi1, uint256 lo1) = _mul512(a, b);
(uint256 hi2, uint256 lo2) = _mul512(c, d);
if (hi1 != hi2) return hi1 > hi2;
return lo1 >= lo2;
}
function _mul512(uint256 a, uint256 b) private pure returns (uint256 hi, uint256 lo) {
assembly {
let mm := mulmod(a, b, not(0))
lo := mul(a, b)
hi := sub(sub(mm, lo), lt(mm, lo))
}
}That mulmod(a, b, not(0)) is the classic trick for recovering the high word of a 256x256 product without a wider integer type: not(0) is 2^256 - 1. It's the EVM mirror of the Rust U512 widening, the same overflow-safe limit check expressed in two completely different machines.
The whole thing is ordered as strict checks-effects-interactions: all signature, expiry, fill, and limit checks plus the filledSell writes happen first with no external calls; then one loop pulls every sellToken in via safeTransferFrom; then a second loop sends every buyToken out via safeTransfer. Reentrancy guard, Pausable, owner-controlled solver rotation, and SafeERC20 round it out. Nine custom errors define the entire revert surface.
A quiet benefit falls out of net-to-zero: fee-on-transfer tokens are rejected for free. A deflationary token delivers less than it says, so the contract ends up short, so the outbound safeTransfer reverts and the whole batch rolls back. I didn't write a special case for it. The strict invariant is the defense. Those tokens simply become unsettleable, which is the correct outcome, and the engine rejects them at intake anyway.
Testing like an attacker
This is the part I'd actually want judged. The Foundry suite goes well past the happy path: balanced crosses, partial-fill accumulation, ten distinct revert paths, fee-on-transfer rejection, admin access control, an amount fuzz test, and a stateful invariant.
The boundary tests are one wei wide, because the checks are strict inequalities and I want to prove it. The overfill test sets sellFilled = AMT + 1; the below-limit test sets buyFilled = AMT - 1. One wei over the signed amount, or one wei under the maker's limit, reverts the entire batch.
The reentrancy proof-of-concept is a malicious ERC-20 that re-enters settle(), but only during the outbound send phase, the genuinely dangerous window where value is leaving:
function _update(address from, address to, uint256 value) internal override {
if (armed && address(settlement) != address(0) && from == address(settlement)) {
// Reenter during the send phase. This reverts (NotSolver or the
// guard) and bubbles up to revert the whole settlement.
settlement.settle(new SignedOrder[](0), new Fill[](0));
}
super._update(from, to, value);
}I'll be honest about a limitation here, because pretending otherwise is worse: this PoC reverts on either the nonReentrant guard or the NotSolver gate (the token isn't the solver). So it proves an unauthorized reentrant call can't succeed, but it doesn't cleanly isolate the guard from the access control. A reviewer should know the difference between "reentry blocked because the attacker can't impersonate the solver" and "reentry blocked because of CEI." Now you do.
The centerpiece is the non-custody invariant. Over thousands of random balanced settlements, with amounts fuzzed across [1, 1e30], the contract must end every run holding exactly zero of every token:
/// The settlement contract is non custodial: it must hold zero inventory of
/// any token after any sequence of settlements.
function invariant_SettlementHoldsNoInventory() public view {
assertEq(tokenA.balanceOf(address(settlement)), 0);
assertEq(tokenB.balanceOf(address(settlement)), 0);
}And it ties back to the threat model, which I wrote as a table: each threat maps to a specific mitigation and a named test that proves it. A PoC-backed threat model is the artifact, not a paragraph of reassurance.
The review that found the one real bug
I had the design adversarially reviewed before I trusted it, and that review is the most useful thing that happened to this project, so I kept a record of it in the repo, including the findings I rejected.
It found exactly one real security hole. An earlier draft's normative checklist for the settlement contract had omitted the on-chain per-fill limit-price check, even though two other sections assumed it existed. The consequence: the trusted solver could pull a maker's full sellToken while delivering less than the signed minimum buyToken. That's the difference between a venue and a heist. The fix became the _ge512 check above, and it's why the threat model and the code's actual checklist now get cross-read against each other. An invariant assumed in prose somewhere else is not enforced until it appears in the list of checks the code actually runs.
Three more findings turned into real fixes: a per-fill floor limit check was wrong because sum(floor) <= floor(sum) lets realized price drift below the limit across many partial fills, so the invariant became cumulative and rounds in the maker's favor; the overflow-safe comparison was hardened; and a binary nonceUsed flag was replaced with the stateful filledSell accounting, because a one-shot nonce is incompatible with partial fills.
What I find more interesting is what I didn't change. Several plausible-sounding findings dissolved under the invariants already in place, and I think being able to defend a design is as important as being able to fix it:
- "The solver can steal surplus." Refuted by the conservation invariant: net-to-zero plus per-order limits leave no surplus to skim against a maker's signed terms.
- "
ecrecovermalleability is a forgery vector." OpenZeppelin'sECDSAalready rejects high-sand the zero address, and a malleated signature recovers to the same maker anyway. - "
&self/&mut selfon the book is a concurrency conflict." It isn't. It's correct single-owner Rust, and one task owns the book.
Accepting every review comment uncritically is its own failure mode. Some bugs are real; some are the reviewer not yet holding the whole model.
Wrapping the core in a service
The pure core knows nothing about networks or databases. crossbook-engine gives it a body: a Tokio service where a single writer task owns the OrderBook and is the only code that mutates it. Everyone else sends commands over a bounded channel, each carrying a oneshot reply sender. The read path never touches that channel. After every mutation the writer republishes an immutable snapshot into a watch channel, so readers clone a snapshot without ever blocking the writer:
tokio::spawn(async move {
let mut book = OrderBook::new();
let mut fills = Vec::new();
while let Some(cmd) = rx.recv().await {
match cmd {
Command::Submit { order, reply } => {
fills.clear();
let outcome = book.submit(*order, &mut fills);
let result = SubmitResult { outcome, fills: fills.clone() };
// Publish the snapshot before replying.
let _ = book_tx.send(Arc::new(book.resting_orders()));
let _ = reply.send(result);
}
Command::Cancel { hash, reply } => {
let removed = book.cancel(&hash);
let _ = book_tx.send(Arc::new(book.resting_orders()));
let _ = reply.send(removed);
}
}
}
});The comment "publish the snapshot before replying" is load-bearing. If the writer replied first, a caller could act on its reply and then read a stale book. Publish the new state, then acknowledge the mutation. That ordering is the difference between a consistent read path and a race.
Order intake runs typed validation: static checks, EIP-712 signature recovery (binding the recovered address to the claimed maker, since verifying a signature is valid is not the same as verifying who signed), then on-chain balance and allowance reads through an Alloy client. Every rejection is one RejectReason enum that is the single source of truth for the HTTP status, the Prometheus metric label, and the human-readable error, so they can never drift:
Err(reason) => {
metrics::counter!("crossbook_orders_rejected_total", "reason" => reason.label())
.increment(1);
let code = StatusCode::from_u16(reason.http_status()).unwrap_or(StatusCode::BAD_REQUEST);
return (
code,
Json(json!({ "error": reason.to_string(), "reason": reason.label() })),
).into_response();
}A unit test enforces the guarantees the design leans on: every label is unique and every status is a 4xx. Add a variant that duplicates a label or returns a 5xx and the build fails. "Single source of truth" is a test, not a comment.
The rest is plumbing done carefully: sqlx with compile-time-checked queries, uint256 stored as NUMERIC(78,0) and carried as BigDecimal, idempotent ON CONFLICT DO NOTHING inserts keyed by order hash and (tx_hash, log_index). A polling indexer reads Trade events into Postgres, advances a durable cursor (storing the block hash so a reorg can be detected), and fans each trade out over a broadcast channel to WebSocket subscribers, where a lagged receiver is treated as recoverable (drop missed trades, keep the connection) rather than fatal.
Proving the whole pipeline
Unit tests prove the pieces. One end-to-end test proves the seams, against real infrastructure rather than mocks. full_flow_settles_indexes_and_broadcasts deploys two ERC-20s and the settlement contract on a live Anvil node, boots the engine in-process on an ephemeral port, POSTs two crossing EIP-712-signed orders over HTTP, and then asserts each stage of the pipeline independently:
assert!(indexed, "trade was not indexed within the timeout");
let broadcast = tokio::time::timeout(Duration::from_secs(5), ws_rx.recv()).await;
assert!(broadcast.is_ok() && broadcast.unwrap().is_ok(), "no trade broadcast");
let bal = MockERC20::new(tb, &deployer).balanceOf(maker_a.address()).call().await.unwrap();
assert_eq!(bal, amt, "maker A should have received token B");The settlement landed on-chain, the trade reached Postgres, it was pushed to a live subscriber, and the tokens actually moved. Layering the assertions means a red test points at the specific stage that broke. The test resets its own tables first, including the indexer cursor, so a stale run can't make a fresh assertion pass.
CI runs three jobs in parallel: a pure Rust suite (fmt + clippy -D warnings + test, building with SQLX_OFFLINE against committed query metadata so it needs no database), a Foundry contract suite, and an integration job that provisions a Postgres service container and a background Anvil and runs the db + e2e tests against them.
And because a green CI badge convinces no one on its own, just demo brings the whole thing up with one command: Postgres, Anvil, deployed demo contracts, and the engine serving a single-page dashboard. The dashboard signs orders in the browser with viem, animates a five-step Sign → Match → Settle → Index → Feed lifecycle, renders a live order book, and streams fills onto a trade tape over a self-reconnecting WebSocket. You watch an order travel from a signature to an atomic on-chain swap in real time.
const account = privateKeyToAccount(MAKERS[maker].key);
const domain = { name: "Crossbook", version: "1", chainId: cfg.chain_id, verifyingContract: cfg.settlement };
const message = { maker: account.address, sellToken, buyToken, sellAmount: amount, buyAmount: amount, validTo, nonce: BigInt(Date.now()), partiallyFillable: true };
const signature = await account.signTypedData({ domain, types, primaryType: "Order", message });
await fetch("/orders", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });Lessons Learned
1. A trust boundary is worth more than clever code
Deciding up front that the contract trusts nothing the engine says made every other choice easier. The engine could be fast and simple precisely because it carried no safety burden. Draw the boundary first; the architecture falls out of it.
2. Make every claim a test
Zero-allocation, byte-identical digests, non-custody, bounded reject cardinality. None of these are assertions in a README. Each is a test that fails if the property breaks. A performance or safety claim a reviewer can't run is a claim a reviewer is right to distrust.
3. Fixed-width integer ratios are a 2N-bit operation
Comparing two limit prices means cross-multiplying full-width integers, and that product is twice as wide as its inputs. U512 in Rust, mulmod in the EVM: the same overflow-safe comparison on two machines. A naive 256-bit multiply here is a latent exploit.
4. Defend the design, don't just patch it
The adversarial review found one real bug and several phantoms. Fixing the real one mattered; refuting the phantoms with the existing invariants mattered just as much. Knowing why something is safe is a different skill from making it safe, and both belong in the repo.
5. Your ergonomic type is not your wire type
validTo is a u64 in memory and a uint256 on the wire, and conflating them silently breaks every signature. Across an ABI boundary, encode to the canonical widths exactly.
Conclusion
Crossbook is small on purpose: a pure matching core, one settlement contract, one engine service, one dashboard. But the interesting work is in the spaces between them: the cross-language digest that has to be byte-identical, the limit check that has to hold on both sides of a 512-bit overflow, the contract that refuses to trust the thing feeding it. Building it taught me more about EIP-712, EVM integer math, and how to test an adversarial system than any amount of reading would have.
It is built in milestones M0 through M5 for the MVP, each committed with tests passing and CI green, and it's deliberately heavy on threat modeling because that's the signal I wanted to send: I can build the venue, and I can also be the person trying to break it.
Resources
- Repository: https://github.com/frdrckj/crossbook
- CoW Protocol (the trust model): https://cow.fi
- EIP-712: https://eips.ethereum.org/EIPS/eip-712
- Alloy (Rust Ethereum): https://github.com/alloy-rs/alloy
- Foundry: https://book.getfoundry.sh
FJerusha