Lesson 8 — InsuranceFund — where the crate stops being pure
Question
InsuranceFund is the first stateful piece of the liquidation crate. Tracks the absorption buffer: deposits from liquidation fees, withdrawals on shortfalls.
Principle (minimum model)
InsuranceFundstruct.balance: u128. Total absorption buffer in stable-token units.deposit(amount).balance += amount(saturating). Called from liquidation fee collection.withdraw_shortfall(needed) -> WithdrawOutcome { amount, unfilled }. Returns up toneeded; tracks unfilled if balance insufficient.WithdrawOutcomenewtype. Forces caller to handle both successfully-withdrawn and unfilled amounts. Conservation:amount + unfilled == needed.- Why stateful. State is the absorption buffer balance; deposits and withdrawals mutate it. Not pure compute.
- Why not a database? Single small piece of state; the L1 contract holds it. Rust-side struct mirrors the L1 storage.
- Tests. (1) Deposit + withdraw less than balance. (2) Withdraw equal to balance. (3) Withdraw more than balance (unfilled > 0).
Worked example + steps
Lesson 8 — InsuranceFund — where the crate stops being pure
Goal
Concepts you'll grasp in this lesson:
- The pure → stateful boundary. Stage 10a's
compute.rsis pure: every function is a deterministic projection of its arguments. Stage 10b introduces the first state in the liquidation crate — the insurance fund's accumulating balance — because the fund is genuinely a fact about history, not a fact about a single snapshot. State appears in code only when the value can't be re-derived from its inputs. - The
balance ≥ 0type invariant. Every public operation onInsuranceFundpreserves it. The field type isi64(for arithmetic uniformity with the rest of the crate), but the invariant is enforced in code, not in the type system.new(-500)clamps to 0;deposit(-50)is a no-op;withdraw_shortfall(...)saturates at 0 with the unfilled portion surfaced viaWithdrawOutcome(Lesson 9). The discipline: make every public method a transition that preserves the invariant. - Defensive boundaries vs. defensive functions. The
computemodule trusts its inputs; theinsurancemodule doesn't. Why the difference?computeis a pure projection — its caller already constructed a validAccountSnapshot.InsuranceFundis the boundary — bridges, scanners, and (later) ADL routines all call it from different layers, and any of them can be buggy. Defensive coding earns its keep at boundaries that aggregate many callers. - Saturating arithmetic in consensus state.
depositusessaturating_addinstead of+. The reason isn't just "to avoid panics in dev." Rust's+operator has two failure modes across build profiles: debug builds panic on overflow (one validator crashes, others continue → fork), and release builds silently wrap in two's-complement (every validator produces a differenti64from its peers → fork). The release wrap is the deceptive one — no crash, no error, just disagreement.saturating_addclamps toi64::MAX(orMIN) under every build profile, so every validator sees the same value regardless of which compiler flags they used. Saturation is the consensus-safe arithmetic discipline.
Verification:
cargo test -p openhl-liquidation
…passes 33 tests (24 from Lessons 0–7 + 9 new tests for construction + deposit). The 22 additional withdrawal / proptest cases land in Lesson 9.
Specific changes:
src/insurance.rs— new module file. AddsInsuranceFundstruct, three constructors (new/empty/Default::default),balance()accessor,deposit()mutator, and 9 unit tests.src/lib.rs— addspub mod insurance;and re-exportsInsuranceFund.
Lesson 8 lands roughly half of insurance.rs. The withdraw path — including the WithdrawOutcome enum — is the Lesson 9 capstone of the insurance-fund module.
Recap
After Lesson 7:
compute.rsis complete for Stage 10a: 6 functions (notional_value,unrealized_pnl,account_equity,margin_ratio,margin_health,close_order_spec) plus thesaturate_i128_to_i64helper.lib.rsre-exports all 6 compute functions and the Stage 10a types.cargo testruns 24 tests, all green.- The crate is purely functional: no
&mut self, no module-level state, every function returns a value derived from its arguments alone.
Lesson 8 starts Stage 10b. The first thing that changes is that the crate is no longer purely functional.
Plan
Three edits:
- Create
crates/liquidation/src/insurance.rs— a new module file with theInsuranceFundstruct, two constructors, thebalance()accessor, thedeposit()mutator, theWithdrawOutcomeenum scaffold (used in Lesson 9), and 9 unit tests covering construction + deposit. - Add
pub mod insurance;and the re-exports tocrates/liquidation/src/lib.rs. - Update
lib.rs's top-of-file roadmap to mark Stage 10b in progress.
(Answer: All three. new defends against a negative initial — clamp to 0. deposit defends against a negative fee — treat as no-op (a negative fee would silently drain the fund). withdraw defends against (a) a negative shortfall — treat as a 0-amount Covered, (b) an amount exceeding the balance — drain to 0 and surface the unfilled portion. Each defense exists because the public API is callable from many layers and any single bad call must not violate the type invariant. Lesson 8 covers new + deposit; Lesson 9 covers withdraw.)
The architectural picture of why state appears here:
┌────────────────────────────────────────────────────────────────┐
│ Stage 10a — pure compute (compute.rs) │
│ │
│ margin_health(snapshot, mark, params) → MarginHealth │
│ margin_ratio(snapshot, mark) → MarginRatio │
│ close_order_spec(snapshot) → CloseOrderSpec │
│ │
│ Every result is a projection of inputs. Re-evaluable forever. │
└────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ Stage 10b — state machine (insurance.rs) │
│ │
│ InsuranceFund { balance: i64 } ← the fund accumulates │
│ .deposit(fee) ← fees CREDIT the fund │
│ .withdraw_shortfall(amount) ← deficits DEBIT the fund │
│ .balance() ← current accumulated value │
│ │
│ Balance is a fact about *history*, not a fact about an input. │
│ Two different sequences of (deposit, withdraw) calls produce │
│ two different balances — even if the *final* call's arguments │
│ are identical. │
└────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ Stage 10c — scanner (scanner.rs, Lessons 11–12) │
│ │
│ Owns an InsuranceFund; calls .deposit / .withdraw_shortfall │
│ per liquidation event; threads outcomes back into ScanReport. │
└────────────────────────────────────────────────────────────────┘
The point: pure compute returns; stateful modules accumulate. Stage 10a told the engine what the world looks like for each account. Stage 10b lets the engine remember what happened across accounts and across blocks. The scanner (Lessons 11–12) is the layer that orchestrates the two.
Walk-through
Step 1: Create src/insurance.rs
Create a new file crates/liquidation/src/insurance.rs. The whole-module doc comment comes first; it's the single most-read piece of prose in the module because every doc generator and every cargo doc reader sees it before any function.
//! Insurance fund state machine (Stage 10b).
//!
//! The insurance fund is the venue's pooled buffer that absorbs the
//! deficit when a Liquidatable account's close turns underwater, or when
//! an Underwater account is liquidated outright. It accumulates the
//! liquidation fees that solvent closes pay in. Stage 10c's scanner will
//! own an [`InsuranceFund`] and call its deposit / withdraw operations
//! from the per-account liquidation loop.
//!
//! ### Why stateful here when the rest of the crate is pure
//!
//! Margin classification, fee math, and close-outcome computation
//! ([`crate::compute`]) are pure functions over per-account snapshots —
//! they can be re-evaluated lossless at any time. The insurance fund's
//! balance, in contrast, accumulates effects from many liquidation events
//! across many blocks; it is genuinely state. The shape mirrors
//! `openhl_funding::clock` — a small state machine, owned by the bridge,
//! mutated only on well-defined boundary events.
//!
//! ### Sign discipline
//!
//! The balance is `i64` internally for arithmetic uniformity with
//! [`crate::compute`], but the type invariant is **`balance ≥ 0`** —
//! every public operation preserves it. Withdrawals that exceed the
//! balance saturate at 0 and surface the unfilled portion via
//! [`WithdrawOutcome`]. Stage 10c's scanner reads the unfilled portion
//! as the trigger to escalate to ADL (Stage 10d).
//!
//! ### Deposit semantics
//!
//! `deposit` accepts a non-negative fee amount. Negative deposits are
//! treated as zero (saturating semantics, no panic) — defensive coding
//! against accidental misuse from the caller. Saturating-add caps at
//! `i64::MAX` for network-pathological accumulated balances.
Four things to notice about this preamble:
- It opens with the role, not the type. "The insurance fund is the venue's pooled buffer that absorbs the deficit…" — a reader who skims only the first sentence already knows where this module fits in the safety-net cascade. Module docs are read by people deciding whether to keep reading. Lead with the role.
- It cites Stage 10c and Stage 10d by name. Even though those stages don't exist yet in the reader's checkout, the doc anticipates them so the reader knows the module is part of a planned arc — not a one-off addition. Forward references in docs are a contract with the future: "this is going somewhere."
- The sign-discipline section is not about Rust's type system. It's about a invariant the type doesn't enforce. Document invariants that the compiler can't check; the compiler already documents the ones it can.
- "openhl_funding::clock" is a cross-module cite to a pattern the reader has already seen — small state machine, owned by the bridge, mutated only on boundary events. Anchoring a new module to a familiar one shortens the learning curve. When introducing a new pattern, point at a previous instance of the same pattern in the codebase.
Step 2: Add the InsuranceFund struct and its constructors
Below the doc comment, add the struct definition and its three constructors:
/// The insurance fund's accumulating balance.
///
/// Owned by the bridge (Stage 10c+), exposed via deposit / withdraw
/// operations that maintain the `balance ≥ 0` invariant.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct InsuranceFund {
balance: i64,
}
Two things to notice about the struct shape:
- The field is private (
balance: i64, nopub). That's the entire enforcement mechanism for thebalance ≥ 0invariant. Ifbalancewere public, any caller could writefund.balance = -1and silently violate the contract. Private fields are how Rust expresses "this invariant exists, and you must go through my public methods to change it." Clone + Copy + Debug + PartialEq + Eq— every trait that the compiler can derive for a single-i64struct, derived. Cheap to pass by value, easy to assert in tests, comparable in proptests. For pure-value types, derive the standard four (or five, withHash) eagerly.
Now the constructors:
impl InsuranceFund {
/// Create a fund with the given initial balance.
///
/// Negative initial balances are clamped to zero — defensive against
/// accidental misuse. A negative initial balance can't represent any
/// physical state of the fund and would violate the type invariant.
#[must_use]
pub const fn new(initial_balance: i64) -> Self {
Self {
balance: if initial_balance > 0 {
initial_balance
} else {
0
},
}
}
/// An empty fund; equivalent to [`InsuranceFund::new(0)`].
#[must_use]
pub const fn empty() -> Self {
Self { balance: 0 }
}
/// Current balance of the fund. Always `≥ 0`.
#[must_use]
pub const fn balance(&self) -> i64 {
self.balance
}
}
impl Default for InsuranceFund {
fn default() -> Self {
Self::empty()
}
}
Five things to notice:
newclamps negatives to 0 silently. NoResult<Self, ...>, no panic. Why? Because the physical meaning of a negative initial balance is undefined — a fund that owes money isn't a fund. When the only sensible interpretation of a bad input is "make it the nearest valid input," do that without ceremony. AResulthere would force every caller to handle an error that should never happen in practice; a panic would create a debug-vs-release behaviour split. Clamping is the cheapest correct answer.empty()exists despitenew(0)doing the same thing. Two reasons. First,InsuranceFund::empty()reads more clearly at call sites thanInsuranceFund::new(0)— intent over numerics. Second,emptyis also whatDefault::default()calls, so the two names point at the same construction site. A named constructor for the canonical zero value is a small clarity win that pays compounding interest.const fnon every method that touches the field. The struct has onei64field; everything that doesn't mutate the underlying state is trivially const-evaluable. This lets future code useInsuranceFundin const contexts (e.g., as a default in a config struct), and signals to readers that these operations are pure.const fnis documentation as much as it is capability — it says "this method does nothing fancy."#[must_use]onnewandempty. Constructing a fund and throwing it away is almost always a bug — usually a leftover from a refactor.#[must_use]makes the compiler complain about it. Marker attributes catch the "obviously wrong, easily missed" cases.Default::default()is implemented manually, not derived. The derivedDefaultfor a struct withbalance: i64would producebalance: 0— same result. But pointing the manual impl atSelf::empty()makes the intent explicit: "the default fund is the empty fund, by design, not by coincidence." ManualDefaultimpls are valuable when the default value has semantic meaning beyond zero-initialization.
Step 3: Add the WithdrawOutcome enum scaffold
Even though Lesson 8 doesn't implement withdraw_shortfall, we declare WithdrawOutcome now so the Lesson 9 changes are purely additive in impl InsuranceFund (no enum-introduction churn). Add this above the impl InsuranceFund block:
/// Outcome of attempting to absorb a shortfall via
/// [`InsuranceFund::withdraw_shortfall`].
///
/// The three variants are exactly the three transitions across the
/// "Layer 2 → Layer 3" boundary in the safety-net cascade:
/// - [`WithdrawOutcome::Covered`] — the fund had enough; Layer 2
/// fully absorbed the deficit.
/// - [`WithdrawOutcome::PartiallyDrained`] — the fund drained to
/// zero and covered part of the shortfall; the remainder must
/// escalate to Layer 3 (ADL).
/// - [`WithdrawOutcome::Depleted`] — the fund was already empty
/// before the call; nothing covered, full shortfall escalates.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WithdrawOutcome {
/// Fund had enough balance to cover the request in full.
Covered {
/// Amount paid out of the fund (= requested shortfall).
amount: i64,
},
/// Fund partially covered the shortfall before draining to zero.
PartiallyDrained {
/// Amount actually paid out (= fund's prior balance).
amount: i64,
/// Remaining shortfall that the caller must escalate to ADL.
unfilled: i64,
},
/// Fund was already empty; nothing was paid out.
Depleted {
/// Full shortfall that must escalate to ADL.
unfilled: i64,
},
}
This enum is declared now and used in Lesson 9. Lesson 8 introduces it because:
- The enum's existence is part of the public surface story. A reader who skims
insurance.rsafter Lesson 8 should see the full type vocabulary of the module, even if some methods are deferred. Vocabulary before mechanism. - Each variant carries its own payload.
CoveredandPartiallyDrainedboth carryamount(what was actually paid out), andPartiallyDrainedandDepletedboth carryunfilled(what the scanner must escalate). The Lesson 9 proptestwithdraw_amount_plus_unfilled_equals_shortfallis the conservation law that ties them together — but you can already see the shape of the law in the variant payloads. Self-describing variants are documentation that the compiler enforces. Layer 2 → Layer 3 boundaryin the doc comment names the cascade architecture explicitly: margin (Layer 1, Stage 10a) → fund (Layer 2, Stage 10b) → ADL (Layer 3, Stage 10d). The reader gets the map every time they look at this enum. When a type sits at an architectural seam, say so in its doc.
Step 4: Add the deposit method
Append deposit to the existing impl InsuranceFund block:
/// Credit the fund with a fee. Returns the new balance.
///
/// Negative inputs are treated as a no-op (defensive against the
/// caller passing a signed value where the contract expects a credit).
/// Saturates at `i64::MAX` for network-pathological accumulated
/// balances.
pub fn deposit(&mut self, fee: i64) -> i64 {
if fee > 0 {
self.balance = self.balance.saturating_add(fee);
}
self.balance
}
Five things to notice:
fee > 0(strict). A fee of0is also a no-op, so>and>=produce identical behaviour for zero. The strict form makes the branch fire only when there's actual work to do. For predicates that gate side effects, prefer> 0(the "is this meaningful?" test) over>= 0(the "is this non-negative?" test) when zero is a no-op.- Negative inputs are silently ignored, not panicked or errored on. Why? Because the alternative is consensus disaster. A panic-on-negative would halt one validator while others continue if a single bridge bug ever pushed a negative fee — and Rust's panic semantics are particularly cruel here (debug vs release, hook differences, etc.). A
Result<i64, ...>would force every caller in the scanner to eitherunwrap(panic-by-other-name) or thread an error type through code that has no good error path. Saturating-no-op semantics give consensus determinism for free. saturating_add, not+. Two failure modes if you use+: in debug,100i64 + i64::MAXpanics with overflow (one validator halts, others continue → fork); in release, it silently wraps to a negative value — which violates thebalance ≥ 0invariant AND produces a differenti64than every peer that handled the same operation differently → fork.saturating_addcaps ati64::MAXunder every build profile, so every validator sees the same number. The network can never accumulate more than9.2 × 10^18of fees anyway, and the cap is invisible in any non-pathological state.saturating_*family is the consensus-safe arithmetic family.- Returns the new balance. The caller often wants to log it ("fee credited: 150, fund balance now: 2_400_150") and a single chained call is cleaner than a two-step
let _ = f.deposit(150); let new_balance = f.balance();. The method is&mut self-and-returns; that pattern shows up in Rust's standard library too (e.g.,HashMap::insertreturns the old value).&mut selfmethods that return useful state save a follow-upbalance()call. - The doc string says "non-negative fee amount" then handles negatives anyway. This isn't a contradiction — it's defensive documentation. The doc says "this is what you should pass"; the implementation says "but if you pass garbage, we won't crash." Doc the intended contract; implement the merciful failure mode.
Step 5: Wire the module into lib.rs
Open crates/liquidation/src/lib.rs. Make two changes:
First, add the module declaration. Find the existing pub mod compute; and pub mod types; block and insert insurance between them:
pub mod compute;
pub mod insurance;
pub mod types;
Second, add the InsuranceFund re-export. Find the existing pub use compute::{ ... }; block and add an insurance re-export after it:
pub use compute::{
account_equity, close_order_spec, margin_health, margin_ratio, notional_value, unrealized_pnl,
};
pub use insurance::{InsuranceFund, WithdrawOutcome};
pub use types::{
AccountSnapshot, CloseOrderSpec, LiquidationParams, MarginHealth, MarginRatio, MARGIN_SCALE,
};
Both re-exports — the type and the enum — go on one line. Why both now? Because users of the crate import what they call, and the Lesson 9 path that calls withdraw_shortfall will pattern-match on WithdrawOutcome immediately. Re-exporting the enum at Lesson 8 means Lesson 9 needs no changes to lib.rs. Re-export the public surface once per module, not per method.
Step 6: Add the 9 unit tests
Append the test module at the bottom of insurance.rs:
#[cfg(test)]
mod tests {
use super::*;
// ─── construction ──────────────────────────────────────────────
#[test]
fn new_with_positive_balance() {
let f = InsuranceFund::new(1_000);
assert_eq!(f.balance(), 1_000);
}
#[test]
fn new_with_zero_is_empty() {
let f = InsuranceFund::new(0);
assert_eq!(f.balance(), 0);
}
#[test]
fn new_with_negative_clamps_to_zero() {
let f = InsuranceFund::new(-500);
assert_eq!(f.balance(), 0);
}
#[test]
fn empty_is_zero() {
let f = InsuranceFund::empty();
assert_eq!(f.balance(), 0);
}
#[test]
fn default_is_empty() {
let f = InsuranceFund::default();
assert_eq!(f.balance(), 0);
}
// ─── deposit ───────────────────────────────────────────────────
#[test]
fn deposit_accumulates() {
let mut f = InsuranceFund::empty();
assert_eq!(f.deposit(100), 100);
assert_eq!(f.deposit(250), 350);
assert_eq!(f.balance(), 350);
}
#[test]
fn deposit_zero_is_noop() {
let mut f = InsuranceFund::new(100);
assert_eq!(f.deposit(0), 100);
}
#[test]
fn deposit_negative_is_noop() {
// Defensive: negative deposits must not silently drain the fund.
let mut f = InsuranceFund::new(100);
assert_eq!(f.deposit(-50), 100);
assert_eq!(f.balance(), 100);
}
#[test]
fn deposit_saturates_at_max() {
let mut f = InsuranceFund::new(i64::MAX - 10);
assert_eq!(f.deposit(1_000), i64::MAX);
}
}
Six things to notice about how this test module is shaped:
// ─── construction ───section headers. Box-drawing-character comments mark the four logical groups (construction · deposit · in Lesson 9: withdrawal-covered · withdrawal-partial · withdrawal-depleted · sequencing · proptests). The headers exist because the eventual module has ~22 tests; scanning the file by section name beats scrolling by line number. In a test file with more than ~10 tests, group them.new_with_zero_is_emptyexists even though it's trivially derivable from thenewsource. It's not redundant — it locks the behaviour in. A future refactor that accidentally addedif initial_balance >= 0instead of> 0would still pass this test (because 0 falls through both predicates correctly), but a refactor that flipped toif initial_balance < 0with a typo would break exactly this case. Boundary tests on small predicates catch typos that bigger tests miss.new_with_negative_clamps_to_zerodirectly tests the defensive surface. The test isn't there to verify the function works; it's there to verify the invariant is preserved. If a future refactor "fixed" the apparent dead code innewby removing the clamp, this test would catch it. Tests for defensive code defend the defensive code.default_is_emptyis a one-liner that proves theDefaultimpl points atSelf::empty()and didn't accidentally get derived (which would also producebalance: 0, but with different intent). Tests can lock in which path produces a result, not just the result.deposit_negative_is_noophas a// Defensivecomment. The comment names the failure mode the test guards against: "negative deposits must not silently drain the fund." A reader who removes the test will see the comment and reconsider. Brief test-level comments are scaffolding for future maintainers who might think a test is unnecessary.deposit_saturates_at_maxusesi64::MAX - 10as the initial balance. Why noti64::MAX? Because a deposit of any amount into a max-balance fund saturates at max — the test would also pass even ifsaturating_addwere replaced bywrapping_add, sincei64::MAX + anything >= 0wraps to a negative value, but+1000would wrap, and the test would catch it. Starting near max gives the test room to fire the saturation logic. Boundary tests on saturating arithmetic need a buffer so the boundary actually fires.
Step 7: Run the tests
cargo test -p openhl-liquidation
Expected output:
running 33 tests
test compute::tests::close_flat_has_zero_qty ... ok
test compute::tests::close_long_with_sell ... ok
test compute::tests::close_short_with_buy ... ok
test compute::tests::equity_can_go_negative ... ok
... (21 more Stage 10a tests)
test insurance::tests::default_is_empty ... ok
test insurance::tests::deposit_accumulates ... ok
test insurance::tests::deposit_negative_is_noop ... ok
test insurance::tests::deposit_saturates_at_max ... ok
test insurance::tests::deposit_zero_is_noop ... ok
test insurance::tests::empty_is_zero ... ok
test insurance::tests::new_with_negative_clamps_to_zero ... ok
test insurance::tests::new_with_positive_balance ... ok
test insurance::tests::new_with_zero_is_empty ... ok
test result: ok. 33 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
33 tests passing. The insurance fund module exists, its invariant is enforced, and deposit semantics are locked in. Withdraw (and the WithdrawOutcome payload semantics) land in Lesson 9.
Common errors:
new_with_negative_clamps_to_zerofails withassertion failed: f.balance() == 0 — left: -500, right: 0— you wroteif initial_balance >= 0 { initial_balance } else { 0 }, which would clamp on negatives but pass-500through if the comparison were reversed. Or you wroteSelf { balance: initial_balance }without the clamp. Re-check theifcondition:> 0.deposit_saturates_at_maxfails with overflow panic — you wroteself.balance += feeinstead ofself.balance.saturating_add(fee). Debug build panics on overflow; release build silently wraps.saturating_*is the only consensus-safe choice.deposit_negative_is_noopfails withleft: 50, right: 100— you forgot theif fee > 0guard and letsaturating_add(-50)run, which decremented the balance to 50. Saturating add doesn't preserve the invariant by itself; the predicate is load-bearing.new_with_zero_is_emptyfails withleft: 1, right: 0— you wroteif initial_balance > 0 { initial_balance } else { 1 }(or similar typo in the else branch). Re-check the else branch literal:0.
Design reflection
Three load-bearing decisions in this lesson:
-
State appears at the layer where history matters. The fund's balance is a fact about all the deposits and withdraws that have ever happened to it; the snapshot type can't represent that, because a snapshot is a fact about one account at one moment. State appears in code only at the boundary where re-derivation from inputs stops being possible. Stage 10a was that boundary in one direction; Stage 10b crosses it deliberately.
-
The
balance ≥ 0invariant is enforced in code, not the type system. We could have usedbalance: u64and let the compiler enforce it. We didn't, because the rest of the crate computes ini64and a u64 field would force casts at every interaction. The decision is a type-discipline tradeoff: pick the representation that makes the crate-internal code cleanest, and defend the invariant at the methods that take untyped inputs from outside. Cross-crate uniformity beats per-field type safety when the per-field invariant is single-line code. -
Defensive code is concentrated at boundaries, not sprinkled throughout.
compute.rstrusts every input;insurance.rschecks every input. The difference:compute.rsis called by other in-crate code that already constructed the inputs correctly, whileinsurance.rsis the boundary where the bridge, the scanner, ADL, and (future) governance all converge. One module pays the defensive cost; the rest of the crate goes fast.
Answer key
cd ~/code/openhl-reference
git checkout 260883b
diff -u ~/code/my-openhl/crates/liquidation/src/insurance.rs ./crates/liquidation/src/insurance.rs
diff -u ~/code/my-openhl/crates/liquidation/src/lib.rs ./crates/liquidation/src/lib.rs
After Lesson 8:
- insurance.rs matches Stage 10b's
insurance.rsup through line 118 (everything exceptwithdraw_shortfall, the proptest section, and the sequencing test, which land in Lesson 9). Specifically: doc comment + struct +WithdrawOutcomeenum +implblock ending atdeposit+impl Default+ tests up throughdeposit_saturates_at_max. - lib.rs matches Stage 10b's
lib.rsbyte-for-byte for thepub modlines andInsuranceFund / WithdrawOutcomere-exports. (The roadmap comment at the top oflib.rsis also updated — that's an optional cosmetic edit in this lesson; Lesson 9 brings it in line with the answer key anyway.)
Common questions
Q1: Why not use Option<NonZeroI64> or similar to make the invariant a type-level fact?
Because every consumer in compute.rs would have to unwrap the option to do arithmetic. The compute.rs functions are already validated to handle zero correctly; forcing them through an Option boundary adds dead branches without protecting anything. Type-level invariants are great when many callers will read the value with structurally-aware code; less great when many callers want to compute with it.
Q2: Should deposit return Result<i64, FundError> instead of returning the balance unconditionally?
No. There's no failure mode worth distinguishing at the call site. Saturation is silent because it's the right behaviour (the fund really does cap at i64::MAX); negative fees are silent because the caller is buggy (a Result here would force a thread of error handling through every scanner site, just to ignore the error). Use Result when the caller has a meaningful action to take; here they don't.
Q3: Why does WithdrawOutcome get declared in Lesson 8 if withdraw_shortfall is in Lesson 9?
Three reasons. (1) Re-exports — lib.rs exports the enum at Lesson 8 so Lesson 9 doesn't touch lib.rs again. (2) Public-surface vocabulary — a reader who lands on insurance.rs after Lesson 8 sees the full type vocabulary of the module, even if some methods are deferred. (3) The variants document the safety-cascade architecture; their shapes tell the reader where the fund sits in Layer 2→3 transitions. Types are documentation that compile; declare them when you can describe them, not when you call them.
Q4: Could InsuranceFund be a free-standing i64 value with module-level functions that mutate it, like global state?
Technically yes, mechanically no. The Stage 10c scanner owns the fund as a field of LiquidationScanner; the bridge owns the scanner. Threading the fund through the call stack (rather than reaching for global state) is what makes the scanner unit-testable in isolation. State that touches consensus must be owned by a known component; ownership-by-stack-position is the discipline that lets multiple scanners coexist without interference.
Q5: Why is new const fn but deposit isn't?
new reads only its argument and the Self constructor; nothing it does involves mutation through &mut self. deposit mutates self.balance — which Rust currently doesn't allow in const fn for non-trivially-const types. new being const lets static FUND: InsuranceFund = InsuranceFund::new(0); compile, which is useful for tests and (later) for default-config constants. const fn what you can; the boundary is usually whether the function mutates state.
Next lesson (Lesson 9) — withdraw_shortfall
Lesson 9 closes out insurance.rs with the withdrawal path. The WithdrawOutcome enum we declared in Lesson 8 finally gets used: withdraw_shortfall(amount) returns Covered { amount } when the fund has enough, PartiallyDrained { amount, unfilled } when it drains to zero, and Depleted { unfilled } when it was already empty.
The two interesting parts: (1) the three-variant outcome is exactly the three transitions across the Layer 2 → Layer 3 boundary in the safety-net cascade, and (2) four proptests enforce conservation laws — balance_never_negative, deposit_is_additive, withdraw_amount_matches_balance_delta, and withdraw_amount_plus_unfilled_equals_shortfall. The proptests are where the cascade math becomes a property the type-system-but-not-quite enforces.
Summary (3 lines)
InsuranceFund { balance: u128 }is the absorption buffer. Mutated by deposit + withdraw_shortfall.withdraw_shortfall -> WithdrawOutcome { amount, unfilled }forces the caller to handle both halves.- Conservation: amount + unfilled == needed. Tests cover the three regimes. Next: withdraw_shortfall in detail.