Lesson 1 — MARGIN_SCALE + LiquidationParams — the dials on the risk engine
Question
MARGIN_SCALE = 10⁴ is the constant that lets margin ratios be integer-only (basis points). LiquidationParams is the struct holding initial/maintenance margins + liquidation fee. The dials on the risk engine.
Principle (minimum model)
MARGIN_SCALE = 10⁴. Multiply every ratio by 10⁴ → 1.5 % = 150 scaled. Integer-only math.- Why 10⁴? 4 decimal places = enough for basis points (0.01 % = 1 scaled unit).
LiquidationParamsstruct.initial_margin_bps + maintenance_margin_bps + liquidation_fee_bps. All in scaled bps.- Hyperliquid values. Initial = 1000 bps (10 %), maintenance = 200 bps (2 %), liquidation fee = 150 bps (1.5 %).
- Loaded from chainspec. Per-market values; chain has many markets with different risk profiles (BTC vs SOL vs niche tokens).
- Used by every later compute function.
margin_ratio,margin_health,close_order_specall read these dials. - Saturating arithmetic. Consensus-safe.
Worked example + steps
Lesson 1 — MARGIN_SCALE + LiquidationParams — the dials on the risk engine
Goal
Concepts you'll grasp in this lesson:
- Why basis points are the right fixed-point unit for margin — bps gives you 4 decimal digits of precision, which is exactly the resolution real exchanges (HL, Binance, Drift) express margin requirements in. Same i64-saturating discipline as
RATE_SCALE, different scale. - Why margin and rates need different scales — funding rates need parts-per-billion because a single funding interval moves wealth by
0.0001to0.04of notional; margin requirements move in0.02to0.10of notional. Two orders of magnitude difference → two orders of magnitude difference in scale. LiquidationParamsas network state, not user state — the 10% / 2% / 1.5% defaults are consensus parameters, set once at network genesis and changed only by governance. The struct's job is to make the parameters first-class and explicit, not magic constants scattered throughcompute.rs.- The
hyperliquid_default()constant constructor —const fnso the defaults can land instaticcontexts, in test fixtures, in compile-time assertions.#[must_use]so the struct can't be silently dropped after construction.
Verification:
cargo build -p openhl-liquidation
…compiles.
Specific changes:
- Cargo.toml wiring
openhl-clobandopenhl-fundingdependencies (AccountId,Side,Qtycome from clob;MarkPrice,PositionSize,Notionalcome from funding — both are part of the production type signature, not test-only). src/types.rs— newly created, containing the module doc +MARGIN_SCALEconstant +LiquidationParamsstruct + impl block with defaults and accessors.src/lib.rs— was empty, now declarespub mod types;+ re-exportsMARGIN_SCALEandLiquidationParamsat the crate root.
Lesson 1 has no tests — MARGIN_SCALE is a value and LiquidationParams is a passive struct. Lesson 2's first behavior-bearing type (the MarginHealth enum) earns the first unit test.
Recap
After Lesson 0:
- You understand why a perp DEX runs liquidations in consensus, not off-chain.
- You understand why floats are a chain-fork hazard (same as funding).
- The liquidation crate scaffold (Cargo.toml + empty
src/lib.rs) is already in your workspace from before Stage 10a — same as the funding crate scaffold was before Stage 8b.
Lesson 1 turns the empty crate into a real crate with one publicly-visible scale + the parameters that govern the entire engine.
Plan
Three edits, exactly mirroring funding Lesson 1's shape but with two deps instead of one:
crates/liquidation/Cargo.toml— addopenhl-clob = { path = "../clob" }andopenhl-funding = { path = "../funding" }to[dependencies], plus a[dev-dependencies]block withproptest(used at Lessons 5 / 6).- Create
crates/liquidation/src/types.rs— module doc explaining the bps rationale +MARGIN_SCALEconstant +LiquidationParamsstruct + impl block. crates/liquidation/src/lib.rs— was empty; add the crate doc +pub mod types;+pub use types::{LiquidationParams, MARGIN_SCALE};.
(Answer: the resolution you need scales with the smallest meaningful step. A funding rate of 0.0001% per interval is a meaningful difference for high-volume traders — ppb is the right resolution. A maintenance margin of 0.02% instead of 0.05% is not a meaningful difference at the engine layer — production deployments set maintenance in whole bps (200 bps, 500 bps). Bps is the conventional unit; using ppb would buy precision the system can't actually use. Use the smallest scale that covers your real range.)
Laying RATE_SCALE and MARGIN_SCALE side by side makes it obvious why each one is "just right" for its own domain:
Course 9 (funding) Course 10 (liquidation)
───────────────────── ────────────────────────
Scale constant RATE_SCALE = 1_000_000_000 MARGIN_SCALE = 10_000
(parts-per-billion, 10⁹) (basis points, 10⁴)
Precision 9 decimal digits 4 decimal digits
Typical range 0.0001% — 4% / interval 2% — 10% (maintenance)
10% — 50% (initial)
Smallest meaningful 0.0001% (= 10 ppb) 1 bp = 0.01%
step in production
Raw value for 1.0 1_000_000_000 10_000
Raw value for 4% 40_000_000 400
↑ ppb: 1 step = 0.0000001% ↑ bps: 1 step = 0.01%
For a world where traders For a world where operators
feel sub-basis-point diffs run with whole-bps boundaries
The discipline: pick the resolution that matches the domain's conventional unit. Funding lives in per-billion sub-bp deltas, margin lives in whole-bp operator settings. The two scales don't need to match because the two domains are independent; if they did match, you'd waste i64 headroom on precision the system never uses.
Walk-through
Step 1: Update Cargo.toml
Open crates/liquidation/Cargo.toml. Currently:
[package]
name = "openhl-liquidation"
version = { workspace = true }
edition = { workspace = true }
rust-version = { workspace = true }
license = { workspace = true }
repository = { workspace = true }
authors = { workspace = true }
[dependencies]
[lints]
workspace = true
Update to:
[package]
name = "openhl-liquidation"
version = { workspace = true }
edition = { workspace = true }
rust-version = { workspace = true }
license = { workspace = true }
repository = { workspace = true }
authors = { workspace = true }
[dependencies]
openhl-clob = { path = "../clob" }
openhl-funding = { path = "../funding" }
[dev-dependencies]
proptest = { workspace = true }
[lints]
workspace = true
Three changes:
openhl-clob = { path = "../clob" }— needed forAccountId,Side,Qty(the bridge layer reuses these for liquidation orders, andAccountSnapshotcarriesAccountId).openhl-funding = { path = "../funding" }— needed forMarkPrice,PositionSize,Notional. These types are the contact surface between funding and liquidation: both crates speak the same currency.[dev-dependencies]block withproptest. Used at Lesson 5 (margin-ratio monotonicity test) and Lesson 6 (margin-health determinism test). Declared now, used later.
Step 2: Create src/types.rs
Create crates/liquidation/src/types.rs. The file doesn't exist yet — brand new this lesson. Initial content:
//! Core types for the liquidation engine.
//!
//! Pure data — no I/O, no allocation. Every type is `Copy`-friendly so the
//! engine can be invoked on snapshots taken at the bridge layer without
//! lifetime gymnastics. The convention follows `openhl-funding`: the
//! liquidation crate never owns mutable state in Stage 10a; it computes
//! over snapshots that the caller assembled.
//!
//! ### Why fixed-point integers, not floats
//!
//! Same answer as `openhl-funding`: consensus determinism. Every validator
//! must reach the same `MarginHealth` from the same inputs, and float
//! arithmetic varies bit-for-bit across compilers and CPUs. We use signed
//! integers scaled by [`MARGIN_SCALE`] (basis points, 10⁴) for margin
//! ratios.
/// Scale factor for `MarginRatio` — basis points (1 bp = 0.01%).
///
/// A raw value of `MARGIN_SCALE` represents `100%`; `MARGIN_SCALE / 10`
/// (= 1_000) represents `10%`. Bps is the conventional unit for margin
/// in TradFi and in crypto perp venues (Hyperliquid, Binance, Drift all
/// express margin requirements in bps).
pub const MARGIN_SCALE: i64 = 10_000;
/// Network parameters governing the margin model.
///
/// Bps convention: `initial_margin_bps = 1000` means a 10% initial margin
/// requirement. Maintenance must be ≤ initial; if a misconfigured network
/// sets them equal, every position at exactly that threshold classifies as
/// `Liquidatable` (the conservative default).
///
/// `liquidation_fee_bps` is charged on the notional being closed, paid
/// out of the account's collateral, and credited to the insurance fund
/// (Stage 10b). A typical HL-style value is 1–2% (100–200 bps).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LiquidationParams {
/// Initial margin requirement in bps (e.g., 1000 = 10%).
pub initial_margin_bps: u32,
/// Maintenance margin requirement in bps (e.g., 200 = 2%).
pub maintenance_margin_bps: u32,
/// Liquidation fee in bps, charged on closed notional.
pub liquidation_fee_bps: u32,
}
impl LiquidationParams {
/// Hyperliquid-style defaults: 10% initial, 2% maintenance, 1.5% fee.
/// Real production deployments use tiered maintenance (higher margin
/// for larger position sizes) — out of scope for Stage 10a.
#[must_use]
pub const fn hyperliquid_default() -> Self {
Self {
initial_margin_bps: 1_000,
maintenance_margin_bps: 200,
liquidation_fee_bps: 150,
}
}
#[must_use]
pub const fn initial_margin_bps(&self) -> u32 {
self.initial_margin_bps
}
#[must_use]
pub const fn maintenance_margin_bps(&self) -> u32 {
self.maintenance_margin_bps
}
#[must_use]
pub const fn liquidation_fee_bps(&self) -> u32 {
self.liquidation_fee_bps
}
}
Five things to notice about this file:
-
MARGIN_SCALE: i64 = 10_000—i64, notu32ori32. Even though the scale itself fits in i32, every multiplication that produces a margin ratio uses i128 intermediates and then saturates back to i64 — keepingMARGIN_SCALEas i64 means no extraas i64casts at every arithmetic site. -
#[derive(Clone, Copy, Debug, PartialEq, Eq)]onLiquidationParams. All three of the fields areu32; the struct is 12 bytes and triviallyCopy. The engine passesLiquidationParamstomargin_healthby reference (&LiquidationParams), but the type beingCopymeans callers don't get yelled at if they accidentally pass by value. -
pubfields andconst fngetters. The fields are public for the same reasonMarkPrice.0is — these are transparent newtypes / params, not encapsulation boundaries. Theconst fngetters exist alongside the public fields because they're useful in constant contexts (e.g., a compile-time assertion thatmaintenance_bps < initial_bps) whereparams.initial_margin_bpsworks only inconstif the type isCopy. Both styles, both fine. -
hyperliquid_default()isconst fn. This lets the defaults appear instaticitems:static PARAMS: LiquidationParams = LiquidationParams::hyperliquid_default();works in any context, including embedded in tests, fixtures, and protobuf-encoded genesis state. Aconst fnconstructor is the bridge between "value I want" and "value I can declare anywhere." -
#[must_use]on the constructor and getters. Constructed-but-droppedLiquidationParamsis almost certainly a bug — you computed the defaults and threw them away. Same logic for accessor: readinginitial_margin_bps()and ignoring the result is almost always wrong.#[must_use]makes the compiler ask the reader to confirm. This isn't just a hint — it's a defensive-programming technique that promotes logic bugs that human reviewers typically miss (discarded return values) into compiler warnings — or, with#![deny(unused_must_use)], into outright compile errors. The discipline behind it is "drive the compiler as far as it'll go as a static-analysis tool, so review cost trends toward zero" — a Rust-native pattern worth internalizing.
Step 3: Update src/lib.rs
Open crates/liquidation/src/lib.rs. Currently empty. Replace with:
//! `openhl-liquidation` — perpetual-position liquidation engine.
//!
//! Pure compute in Stage 10a: no I/O, no async, no networking. Liquidation
//! decisions are deterministic functions over `(account_snapshot, mark,
//! params)`. Every validator on the chain must reach the same
//! [`MarginHealth`] from the same inputs; if two validators classify the
//! same account differently, the chain forks.
//!
//! ### Hyperliquid-shape liquidation, in one paragraph
//!
//! Perpetual contracts are levered positions backed by deposited
//! collateral. As the mark price moves against an open position,
//! unrealized PnL eats into the account's equity. When `equity / notional`
//! drops below the network's maintenance-margin requirement, the engine
//! force-closes the position at market — opposite side, full size, no
//! limit price. The liquidation fee is debited from collateral and
//! credited to the insurance fund. Any residual collateral, after fee
//! and PnL settlement, stays with the account. If equity went negative
//! before the close (the account is "underwater"), the insurance fund
//! absorbs the deficit instead of the position closing solvently.
pub mod types;
pub use types::{LiquidationParams, MARGIN_SCALE};
Notice what's missing compared to the Lesson 11-end version: pub mod compute, the rest of the pub use types::{...} re-exports for MarginHealth, MarginRatio, AccountSnapshot, CloseOrderSpec. Those come in Lessons 2–7 as we add the types and the compute functions. Lesson 1 lib.rs is the minimum that compiles.
The cross-reference [MarginHealth] will be broken until Lesson 2 adds the enum; rustdoc will emit a warning that we tolerate (same handling as funding Lesson 1).
(Answer: pub use types::* would re-export everything that ever lives in types.rs, including future helpers and private support types you might accidentally pub. Explicit pub use types::{LiquidationParams, MARGIN_SCALE} makes the crate's public surface a deliberate decision — every time you add a public type to types.rs, you also have to add it to the lib.rs re-export, which forces a moment of "is this part of the public API?" Glob re-exports are a maintenance hazard: a future helper added with pub instead of pub(crate) accidentally becomes part of the public API. Explicit re-export is a checklist for the public API surface.)
Step 4: Compile
cargo build -p openhl-liquidation
Expected output:
Compiling openhl-liquidation v0.1.0 (/Users/.../my-openhl/crates/liquidation)
warning: unresolved link to `MarginHealth`
Finished `dev` profile [unoptimized + debuginfo] in 0.4s
One rustdoc warning about an unresolved link to MarginHealth (added at Lesson 2). Don't suppress it — it's the build telling you what's still missing.
Common errors:
error[E0463]: can't find crate for 'openhl_clob'or'openhl_funding'— you forgot to add one of thepath = "..."deps in Cargo.toml. Lesson 1 code doesn't actually use them yet, but if you preempted the Lesson 3 imports they'll fire.error[E0583]: file not found for module 'compute'— you preemptively addedpub mod compute;to lib.rs. Remove it; we'll add it back at Lesson 4.error: failed to parse manifest— Cargo.toml syntax. Easy mistake:[dev-dependences]typo.
Design reflection
Three load-bearing decisions in this lesson:
-
MARGIN_SCALE = 10_000, not1_000_000_000. Two orders of magnitude finer than funding'sRATE_SCALEwould be wrong — production margin parameters are not set in ppb. Two orders coarser (100, percent) would lose meaningful resolution. Bps is the unit the world has settled on for margin; we match it. -
Default constructor is
const fn, not aDefaultimpl. Why both styles aren't right:Default::default()returns reasonable zero-ish defaults across many types.LiquidationParams::default()would suggest "zero margin, zero fee" which is dangerous — a network running withdefault()params has no liquidations at all.hyperliquid_default()is a named, intentional default — callers have to ask for it by name, which keeps the safety-critical nature visible. -
Three independent
u32fields, not aLiquidationConfigstruct nested inside. Future migration to tiered maintenance margin (HL-style: higher maintenance % for larger positions) might want aVec<MaintenanceTier>field. We don't add that now — premature generalization. Stage 10a uses flat margin; Stage 10c+ can revisit if tiered is needed.
Answer key
cd ~/code/openhl-reference
git checkout 22eedf9
diff -u ~/code/my-openhl/crates/liquidation/Cargo.toml ./crates/liquidation/Cargo.toml
diff -u ~/code/my-openhl/crates/liquidation/src/types.rs ./crates/liquidation/src/types.rs
diff -u ~/code/my-openhl/crates/liquidation/src/lib.rs ./crates/liquidation/src/lib.rs
After Lesson 1:
- Cargo.toml matches Stage 10a exactly.
- types.rs matches the first ~50 lines of Stage 10a's types.rs — module doc +
MARGIN_SCALE+LiquidationParams+ impl. The rest (MarginRatio,MarginHealth,AccountSnapshot,CloseOrderSpec) is Lessons 2 / 3. - lib.rs matches the first ~25 lines of Stage 10a's lib.rs — crate doc +
pub mod types;+ the two re-exports. The other re-exports come as we add their types.
Common questions
Q1: Why not put MARGIN_SCALE in lib.rs alongside the crate doc?
It belongs with the type system it scales. types.rs is where everything related to the unit-of-account (margin ratios, bps, classification thresholds) lives. The lib.rs is the public-API surface — re-exporting MARGIN_SCALE from types.rs to the crate root is cleaner than splitting the source of truth.
Q2: Should LiquidationParams validate that maintenance ≤ initial in the constructor?
Stage 10a says no — the struct accepts any combination. Stage 10c will add a validated() constructor that returns Result<Self, ParamsError> when called by genesis-loading code; the unvalidated constructor stays for tests and proptest generators that want to feed pathological inputs.
Q3: Why is hyperliquid_default() 10% / 2% / 1.5% and not something else?
HL's actual maintenance margin tiers run from 1.25% to 6.67% depending on position size; we picked 2% as a representative middle value. Initial is 10× maintenance — a common shape. Fee at 1.5% is the public HL number for ETH/BTC; lighter assets are lower. None of these are precious — your network sets its own.
Q4: What's the actual i64-overflow risk on a margin ratio computation?
margin_ratio = equity * MARGIN_SCALE / notional. With MARGIN_SCALE = 10_000 and equity and notional bounded by i64::MAX, the product equity * MARGIN_SCALE can overflow i64 when equity > i64::MAX / 10_000 ≈ 9.2e14. At realistic exchange scales that's $920 trillion of equity — far above plausible inputs, but Lesson 5 still does the multiplication in i128 and saturates back. The discipline is the same as funding: any product that can exceed i64 will exceed i64 at some adversarial input — assume that as the default.
Q5: Could we use u32 for MARGIN_SCALE and bps and avoid the i64 conversion noise?
You could — and you'd save a few i64::from(...) calls. The cost: every margin-ratio calculation involves equity (signed) and notional (unsigned), and mixing signed/unsigned in arithmetic requires explicit casts at every site. Better to upcast to i64 once at the boundary (i64::from(params.initial_margin_bps)) and keep the arithmetic signed throughout. Convert at the boundary, compute in one type.
Next lesson (Lesson 2)
Lesson 2 adds the MarginRatio newtype + the MarginHealth enum. MarginHealth is the load-bearing classification type — the next 5 lessons all return or consume it. You'll see why we made it a 4-variant enum and not a bool or a u8.
Summary (3 lines)
MARGIN_SCALE = 10⁴for integer-only ratio math (basis points).LiquidationParamsholds initial/maintenance/fee.- Hyperliquid: initial 10 % / maintenance 2 % / fee 1.5 %. Per-market values from chainspec.
- Foundation for every later compute. Next: MarginRatio + MarginHealth classification types.