Lesson 1 — CLOB newtypes, Side, OrderType
Question
8 newtypes + 2 enums for the CLOB. Price / Size / OrderId / MarketId / AccountId are i128/u64-backed; Side / OrderType are enums. Type-system catches unit bugs at compile time.
Principle (minimum model)
- 8 newtypes.
Price(i128)/Size(u64)/OrderId(u64)/MarketId(u8)/AccountId(u64)/Timestamp(u64)/Sequence(u64)/Notional(i128). - 2 enums.
Side { Buy, Sell }andOrderType { Limit, Market, Cancel }. - Why u64 for Size. Size is always positive; u64 has enough range; saturating_add on overflow.
- Why i128 for Price. Prices can be negative (e.g. funding rates); i128 has the range; scaled by 10⁸ for 8 decimal places.
- Constructors validate.
Size::new(0)→Err(size must be positive);Price::new(0)→ OK (price zero is valid for market orders). - Arithmetic.
impl Mul<Price> for Size -> Notionaletc. Type-correct multiplication. - Saturating throughout. Consensus-safe; no panics.
Worked example + steps
Lesson 1 — CLOB newtypes, Side, OrderType
Goal
Concepts you'll grasp in this lesson:
- Newtype-as-type-safety — wrapping
u64inAccountId/OrderId/Price/Qtyturns argument-swap bugs into compile errors instead of silent runtime mis-credits. - Integer-only money math —
PriceandQtyareu64-backed, neverf64; float intermediates would break the engine's exact-integer invariants (e.g. "fills conserve quantity") at the boundaries. - Struct-style enum variants for named roles —
OrderType::Limit { price }reads more clearly thanLimit(Price)at every pattern match site, because the field has a name, not just a position. - Field-level vs. record-level types as a layering strategy — atomic types belong in Lesson 1 so every later lesson reuses them; record types (
Order,Fill) layer on top in Lesson 2.
Verification:
cargo check -p openhl-clob
…compiles cleanly.
Specific changes:
You'll have one new crate (crates/clob/) registered in the workspace, with a single file src/types.rs containing the atomic, field-level types the matching engine uses:
- 4 newtypes over
u64—AccountId,OrderId,Price,Qty— for type-safety against accidental swaps. Sideenum (Buy|Sell) with anopposite()helper.OrderTypeenum —Limit { price }orMarket.Displayimpls onOrderId,Price,Qtyso debug output reads naturally ("#42","1000000", etc.).
No record types yet (those are Lesson 2). No book yet (that's Lesson 3 onward). This lesson is the foundation — every later lesson uses the types you build here.
Recap
After course 6, your workspace has:
crates/types/ — BlockHash, PayloadId, PayloadAttrs, ExecutedBlock, PayloadStatus
crates/evm/ — InMemoryEvmBridge, RethEvmBridge, LiveRethEvmBridge
crates/consensus/ — full BFT engine (Context, signing, codec, node, engine_app)
bin/openhl/ — stub binary
cargo test passes ~38 tests workspace-wide. LiveRethEvmBridge::commit sends ForkchoiceUpdated to Reth. But build_payload produces empty blocks — nothing to fill them with.
Plan
Four things:
- Create
crates/clob/directory withCargo.tomlandsrc/. - Register
crates/clob/in the workspace — add to[workspace.members]in the rootCargo.toml. - Add
openhl-clobto workspace dependencies in the rootCargo.tomlso other crates can depend on it. - Write
src/types.rs— the 4 newtypes,Side,OrderType, andDisplayimpls. No record types yet (those go in Lesson 2). - Wire
pub mod types;+ re-exports intosrc/lib.rsso the crate's public API is the types.
The lesson is short because the types are short. The interesting part isn't the code — it's the design decisions (why newtypes over raw u64, why Limit carries its price as a struct field, what unit Qty is in).
Walk-through
Step 1: Create the crate directory + Cargo.toml
From the workspace root (~/code/my-openhl/):
mkdir -p crates/clob/src
touch crates/clob/Cargo.toml crates/clob/src/lib.rs crates/clob/src/types.rs
Now open crates/clob/Cargo.toml and write:
[package]
name = "openhl-clob"
version = { workspace = true }
edition = { workspace = true }
rust-version = { workspace = true }
license = { workspace = true }
repository = { workspace = true }
authors = { workspace = true }
[lints]
workspace = true
No dependencies. The CLOB matching engine is pure data + pure logic; it doesn't even need serde at this stage (Stage 8b adds it for funding, but we don't need it now).
Step 2: Register in workspace
Open the root Cargo.toml. Find [workspace] members = [...]. Add "crates/clob" to the list. Make sure to keep the existing ordering (alphabetical or insertion-order is fine):
[workspace]
resolver = "3"
members = [
"bin/openhl",
"crates/types",
"crates/clob", # NEW
"crates/evm",
"crates/consensus",
]
Then in the same root Cargo.toml, find [workspace.dependencies] and add a path entry for openhl-clob:
[workspace.dependencies]
# --- Internal crates ---
openhl-types = { path = "crates/types" }
openhl-clob = { path = "crates/clob" } # NEW
openhl-evm = { path = "crates/evm" }
openhl-consensus = { path = "crates/consensus" }
Now any crate that wants openhl-clob can declare openhl-clob = { workspace = true } in its own Cargo.toml. We'll use this in Lesson 9 when the bridge consumes the CLOB.
Step 3: Write the newtypes
Open crates/clob/src/types.rs. Start with the module doc and the 4 newtypes:
//! Core types for the CLOB matching engine.
//!
//! Pure data — no I/O, no allocation beyond what's needed for fills. The
//! whole module is deterministic by construction: every type's `PartialEq`
//! and `Ord` impl derives from byte-equal field comparison.
use core::fmt;
/// Account identifier. Opaque to the CLOB; chain integration maps these to
/// EVM addresses, validator addresses, or whatever the chain uses.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct AccountId(pub u64);
/// Sequential order identifier. Caller allocates; the book doesn't generate.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct OrderId(pub u64);
/// Price in minor units. For a USDC market, `Price(1_000_000) = $1.00`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Price(pub u64);
/// Quantity in minor units.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Qty(pub u64);
Four structures, all 1 line each, all wrapping u64. The 7 derives are identical across all 4 types — that's intentional. The newtype pattern works because the types have the same operations as u64 but the type system rejects mixing them.
Three things to notice in the doc comments:
AccountIdis opaque — the CLOB doesn't know whether your chain uses EVM addresses, ed25519 pubkeys, or sequential integers. It just compares them for equality. Chain integration (course 8 with precompiles, eventually production node code) mapsAccountId(...)to whatever the chain wants.OrderIdis caller-allocated — the book doesn't generate IDs; callers do. This keeps the book pure-stateless:submit_orderis a function of (book, order), not (book, order, generator-state).Price/Qtyare in minor units —Price(1_000_000)represents $1.00 for a 6-decimal token like USDC. There's nof64in the matching engine. Floats are forbidden in money math.
Step 4: Add the Side enum and opposite() helper
Continue in types.rs:
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Side {
Buy,
Sell,
}
impl Side {
#[must_use]
pub const fn opposite(self) -> Self {
match self {
Self::Buy => Self::Sell,
Self::Sell => Self::Buy,
}
}
}
Two variants. The opposite() method is a one-liner now, but it's load-bearing later: when a taker order arrives, you walk the opposite side of the book to find liquidity. A buy taker walks the asks; a sell taker walks the bids. Encoding the rule in opposite() once means you can't forget which side to walk when reading book code later.
#[derive(PartialOrd, Ord)] is intentionally NOT here. Asking "is Buy less than Sell?" is meaningless — these aren't ordered values, they're tags. Leaving the trait off keeps callers from accidentally writing if side < Side::Sell and getting an unintended ordering (which would be Buy < Sell since Buy comes first in the source).
Step 5: Add the OrderType enum
Below the Side impl:
/// Order type — describes liquidity-taking + liquidity-providing behavior.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OrderType {
/// Take liquidity at or better than `price`; rest the remainder on the book.
Limit { price: Price },
/// Take whatever liquidity is available at any price; never rests.
Market,
}
Two variants:
Limit { price: Price }— a struct-style enum variant. The order has a price; if you can't match at-or-better (a buyer pays no more than the limit; a seller receives no less than the limit), the remainder rests on the book.Market— unit variant. No price; takes whatever's available at any price, then discards the remainder.
The struct-style Limit { price: Price } is deliberate over a tuple-style Limit(Price). When code reads order.order_type and pattern-matches, Limit { price } makes the field name price part of the pattern. Tuples force you to write Limit(p) and remember what p means. Named fields make the type self-documenting.
Step 6: Add Display impls for the 3 user-facing newtypes
Display needs the fmt module; we already added use core::fmt; at the top of the file in Step 3 — placing all use lines together makes the file's structure easier to scan than sprinkling imports per Step. Append at the end of types.rs:
impl fmt::Display for OrderId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "#{}", self.0)
}
}
impl fmt::Display for Price {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl fmt::Display for Qty {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
Three Display impls. Note AccountId doesn't get one — that's intentional. AccountIDs are opaque IDs; if you want to print them, you probably want the chain-integration's mapping to a real address, not the raw u64. Leaving Display off forces callers to be explicit (e.g., format!("{}", a.0) or "render via the chain's address renderer").
OrderId formats as "#42" so test output reads naturally (fill from #1 to #2). Price and Qty are just their numeric values — but having the Display impl means you can use them in format! and println! without writing .0.
Step 7: Wire types into lib.rs
Open crates/clob/src/lib.rs:
//! Pure-Rust CLOB (central limit order book) matching engine for openhl.
//!
//! No I/O. No allocation beyond fill output. Deterministic by construction.
//! See [`book::Book`] for the matching state machine (Lesson 3+).
pub mod types;
pub use types::*;
Three lines of body, plus a doc comment. pub use types::* re-exports the types at the crate root so callers can write use openhl_clob::{Order, Side} instead of use openhl_clob::types::{Order, Side} — the shorter form is what we'll use everywhere.
The book module comes in Lesson 3; the pub mod types; line goes alone for now.
Test
cargo check -p openhl-clob
Expected:
Compiling openhl-clob v0.1.0 (.../crates/clob)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.23s
No warnings. No errors. The crate's public API is now AccountId, OrderId, Price, Qty, Side, OrderType (no records yet).
If you want to also verify nothing in the workspace broke:
cargo check --workspace
Should complete cleanly. The new crate is empty of dependents; nothing's affected.
Common errors and fixes:
error: failed to read 'crates/clob/Cargo.toml'— typo in workspacememberslist, or the file doesn't exist. Re-check Step 2.error[E0432]: unresolved import 'fmt'— forgotuse core::fmt;at the top oftypes.rs. Re-check Step 3.- **
error[E0277]: 'Price' doesn't implementDisplay** — addedDisplayforOrderIdbut notPrice/Qty`. Re-check Step 6. warning: unused import: 'types'— yourlib.rssaysmod types;(private) instead ofpub mod types;. Re-check Step 7.
Design reflection
Three load-bearing decisions encoded here:
-
Newtypes prevent argument-swap bugs at compile time. Code that took
submit(book, account: u64, price: u64, qty: u64)would compile with any permutation of those threeu64s passed in. Code that takessubmit(book, AccountId, Price, Qty)rejects the wrong types at compile time. The cost is two extra.0deref calls; the benefit is bugs you can't write. -
Money math uses integers, not floats.
PriceandQtyareu64-backed. There's noPrice::from_f64. Anyone wanting to display a price as "$1.00" does the integer-to-decimal conversion at the rendering boundary, outside the engine. The matching engine's invariants (e.g., "total fills always conserve quantity") are exact-integer invariants; introducing float intermediates would break them. -
OrderType::Limit { price }notLimit(Price). When you later writematch order.order_type { Limit { price } => ..., Market => ... }, thepricebinding makes the role obvious. Tuple-style enum variants are right when the variant is just "I'm a wrapper around this one thing"; struct-style is right when the field has a name. Here it does (price), so struct-style wins.
Answer key
cd ~/code/openhl-reference
git checkout 55a9dff
diff -u ~/code/my-openhl/crates/clob/src/types.rs ./crates/clob/src/types.rs
diff -u ~/code/my-openhl/crates/clob/Cargo.toml ./crates/clob/Cargo.toml
diff -u ~/code/my-openhl/Cargo.toml ./Cargo.toml
The reference at 55a9dff has types.rs at ~109 lines total (the full type set). Your version after Lesson 1 has only the newtypes + Side + OrderType + Display impls — about 65 lines. The remaining ~45 lines (Order, Fill, FillResult) are Lesson 2's scope. Diff should show those as the differences.
Return:
git checkout main
Common questions
Q: Why are AccountId, OrderId, Price, Qty all Copy?
They're u64 under the hood — just 8 bytes, no heap allocation. Marking them Copy lets the engine pass them by value freely, with no .clone() calls anywhere. Copying a u64 flows through a CPU register, so there's zero runtime overhead compared to a move.
Q: Why Hash on these types?
Future use: HashMap<OrderId, RestingOrder> for O(1) cancel-by-id (Lesson 6). Adding Hash now means no derive-cascade churn later.
Q: Why isn't Side: PartialOrd + Ord?
Because asking "is Buy less than Sell" is a meaningless question. If we derived Ord, callers could write if side < Side::Sell { ... } and get whichever variant Rust enumerated first (Buy, in our case) — but that's an artifact of declaration order, not semantically meaningful. Leaving the trait off forces callers to use match or ==.
Q: Why #[must_use] on opposite()?
Because writing side.opposite(); (without assigning the result) is almost certainly a bug — opposite() returns a new Side, it doesn't mutate. #[must_use] makes that bug a warning. Good practice for any function whose only purpose is to return a value.
Next lesson (Lesson 2)
You have the field-level types — the atomic pieces. Lesson 2 builds the record-level types that combine them: Order (the input to the matching engine), Fill (the output), FillResult (the wrapper that bundles fills with remaining-quantity info). After Lesson 2, the type vocabulary is complete; Lesson 3+ uses these types to build the actual matching state machine.
Summary (3 lines)
- 8 newtypes + 2 enums for CLOB. Price/Size/OrderId/MarketId/AccountId/Timestamp/Sequence/Notional; Side/OrderType.
- u64 for Size (always positive), i128 for Price (can be negative). Constructors validate; arithmetic is type-correct.
- Saturating arithmetic throughout. Next: Order + Fill + FillResult records.