Lesson 1 — RATE_SCALE — the constant that defends consensus
Question
Funding rates are fractions like 0.000125 (1.25 basis points). Rust's integer types can't hold fractions. RATE_SCALE = 10^9 is the constant that makes the math integer-only while preserving precision. Why this one constant matters for consensus.
Principle (minimum model)
RATE_SCALE = 10^9. Multiply every rate by 10⁹ so the integer representation isi128.1.25 bps = 125_000scaled.- Why integers? Consensus determinism.
f64arithmetic varies between CPU families (rounding modes differ). Integers are byte-for-byte reproducible. - Why
i128? Range.i64overflows on (max_premium × max_notional) for realistic inputs.i128has headroom. - Why this scale specifically. 10⁹ gives 9 decimal places of precision — enough for Hyperliquid's 8-decimal precision + 1 for compounding.
- Conversion is one-way. Multiply on input (
scale_rate(rate) = rate * 1e9); divide on output (unscale_rate(scaled) = scaled / 1e9). Inside the engine, everything is scaled. - Saturating arithmetic.
saturating_mul(rate_scaled, notional)— clamps on overflow rather than panicking. Consensus-safe. - Why this is Lesson 1. Every later lesson uses scaled rates. Getting
RATE_SCALEwrong cascades through everything.
Worked example + steps
Lesson 1 — RATE_SCALE — the constant that defends consensus
Goal
Concepts you'll grasp in this lesson:
- Why floats can't run in consensus — FMA, rounding modes, denormals all diverge across compilers/CPUs; a single LSB disagreement on a rate forks the chain.
- Why
RATE_SCALE = 1e9is the sweet spot for fixed-point in i64 — parts-per-billion gives 9 decimal digits of precision while keeping 11 orders of magnitude of i64 headroom for products;1e6loses precision,1e12loses headroom. - The crate scaffolding move — turning an empty
lib.rsinto a real crate with onepub modand one re-export — and why module declarations land only when the file exists. - The "set once, never change" constant —
RATE_SCALEis consensus state, not a tunable; doc-comment placement and immutability matter.
Verification:
cargo build -p openhl-funding
…compiles.
Specific changes:
- Cargo.toml wiring an
openhl-clobdependency (we'll needAccountIdfrom there later, but the dep goes in now so it's not a surprise at Lesson 3) and a[dev-dependencies]block ready forproptest(used at Lessons 4 / 7). src/types.rs— newly created, containing the module doc +pub const RATE_SCALE: i64 = 1_000_000_000. Nothing else yet.src/lib.rs— was empty, now declarespub mod types;+ re-exportsRATE_SCALEat the crate root.
That's the lesson. One constant, the most important constant in the entire crate. Every rate, every premium, every settlement in the next 10 lessons will be expressed in terms of RATE_SCALE. Get this right and the rest of the math is straightforward; get it wrong and validators fork.
There are no tests in Lesson 1 — RATE_SCALE is a value, not a behavior. Lesson 2's first money type will get the first test.
Recap
After Lesson 0:
- You understand why funding payments exist (mark/index drift correction).
- You understand why floats are a consensus fork hazard.
- The funding crate scaffold (Cargo.toml + empty
src/lib.rs) was already in your workspace from before Stage 8b.
Lesson 1 turns the empty crate into a real crate with one publicly-visible value.
Plan
Three edits:
crates/funding/Cargo.toml— addopenhl-clob = { path = "../clob" }to[dependencies], add a new[dev-dependencies]block withproptest.- Create
crates/funding/src/types.rs— module doc explaining the determinism rationale +RATE_SCALEconstant. crates/funding/src/lib.rs— was empty; add the crate doc +pub mod types;+pub use types::RATE_SCALE;re-export.
That's it. Compile, see green, move on.
(Answer: i64 max is ~9.2e18. With RATE_SCALE = 1e9, a raw value of 1e18 represents 1e9 = a billion. We don't need rates in the billion range — funding rates are typically 0.0001 to 0.04 per interval. RATE_SCALE = 1e9 gives 9 decimal digits of precision with massive headroom: 40_000_000 (0.04, the HL cap) is 11 orders of magnitude below i64::MAX. Going to 1e12 (parts-per-trillion) would buy more precision but cost headroom — multiplying two 1e12-scaled values would need i256 to stay safe. Going to 1e6 would save no real headroom and lose meaningful precision when a funding rate is 0.0001% = 10 ppb. 1e9 is the sweet spot for fixed-point rates in i64.)
Lining the headroom up on a magnitude ruler makes it obvious why 1e9 sits in the safe zone:
magnitude value what lives there
─────────────────────────────────────────────────────────────────────────────────────
1e18 ──────── 9_223_372_036_854_775_807 ─────── i64::MAX (~9.2 × 10^18)
1e18 ↑ ceiling for intermediate values
│
1e15 ──────── 1_600_000_000_000_000 ─────── 4% × 4% (cap²) = 1.6 × 10^15
│ ← absorbed comfortably by i128 intermediates
│
1e9 ──────── 1_000_000_000 ─────── RATE_SCALE = 100% (one billion)
│
1e7 ──────── 40_000_000 ─────── HL Funding Cap 4% (40 million)
│
1e6 ──────── 1_000_000 ─────── 0.1%
│
1e1 ──────── 10 ─────── 0.0001% = 10 ppb (realistic minimum granularity)
Three things this picture nails down:
- The cap (
4e7) and RATE_SCALE (1e9) are still more than one order of magnitude apart — a rate of0.04represented as40_000_000has comfortable room above it. - Even cap² is only
1.6e15— that's still 3+ orders of magnitude below the i64 ceiling (9.2e18). So products like "rate × rate" or "rate × notional" can be promoted toi128, computed safely, then divided back byRATE_SCALEto land ini64. Module 2'scompute_premium/apply_fundingis exactly the place where those three orders of margin get spent. - The smallest realistic granularity (
10ppb =0.0001%) is still representable — at1e6(parts-per-million), this value would round to0and precision would be destroyed.1e9is the integer size that fits both the realistic floor and ceiling of a funding rate into one space.
Walk-through
Step 1: Update Cargo.toml
Open crates/funding/Cargo.toml. Currently it looks like:
[package]
name = "openhl-funding"
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-funding"
version = { workspace = true }
edition = { workspace = true }
rust-version = { workspace = true }
license = { workspace = true }
repository = { workspace = true }
authors = { workspace = true }
[dependencies]
openhl-clob = { path = "../clob" }
[dev-dependencies]
proptest = { workspace = true }
[lints]
workspace = true
Two changes:
openhl-clob = { path = "../clob" }in[dependencies]. The funding crate will needAccountIdfromopenhl-clob(it appears inPositionat Lesson 3). Adding the dep now keeps the diff focused at Lesson 3. Cost: ~0 — declaring a path dep doesn't recompile anything until the firstuse.[dev-dependencies]block withproptest. Used at Lesson 4 (premium antisymmetry test) and Lesson 7 (balanced-book zero-sum). Same logic: declare the dev-dep now, use it later. Production builds don't include proptest.
Step 2: Create src/types.rs
Create crates/funding/src/types.rs. The file doesn't exist yet — it's brand new in this lesson. Initial content:
//! Core types for the funding state machine.
//!
//! Pure data — no I/O, no allocation beyond what's needed for settlements.
//! Every type is `Copy`-friendly (or, in the case of `Position`, `Clone +
//! Copy`) so callers can pass snapshots without lifetime gymnastics.
//!
//! ### Why fixed-point integers, not floats
//!
//! Consensus determinism — every validator must compute the *same* funding
//! rate from the *same* inputs. Float arithmetic gives different bit patterns
//! across compilers and CPUs (FMA, rounding mode, denormal handling); the
//! moment two validators disagree on a single LSB they fork. We use signed
//! integers scaled by [`RATE_SCALE`] (parts-per-billion) for rates and
//! premiums, and a separate `Notional` type for quote-currency deltas.
/// Scale factor for [`FundingRate`] and [`Premium`]. A raw value of
/// `RATE_SCALE` represents `1.0` (i.e., 100%). With `1e9` we get 9 decimal
/// digits of precision — more than enough for funding rates that typically
/// sit in the ±0.01% to ±0.05% per interval band.
pub const RATE_SCALE: i64 = 1_000_000_000;
Four things to notice about this 15-line file:
- Module doc has a "Why fixed-point integers, not floats" section. This is the load-bearing rationale for the entire crate. The next engineer reading
types.rssix months from now needs to see this explanation at the top — not buried in a commit message. The phrase at the end of the module doc — "callers can pass snapshots without lifetime gymnastics" — encodes a design choice that drives the rest of the crate: "snapshot" means values are passed by-value (a copy of the state at a point in time, not a reference into someone's storage); "lifetime gymnastics" is Rust's idiom for the cascade of'a/'bannotations that creep into signatures when you start holding&'a Teverywhere. Every money type added in Lesson 2+ (MarkPrice,Premium, etc.) is aCopynewtype precisely so that callers can hand values around freely without those lifetime annotations. - The
[FundingRate]and[Premium]cross-references. Those types don't exist yet (Lessons 2 / 3). Rustdoc will warn about broken links during the Lesson 1 build. Tolerate the warnings — they resolve as we add types in Lessons 2/3. If you really want zero warnings now, use[FundingRate](no backticks) in plain text rather than[FundingRate]— but the cross-reference style matches the source convention. pub const RATE_SCALE: i64 = 1_000_000_000—i64, notu64. Rates and premiums are signed (longs paying = positive premium, shorts paying = negative). Signed integers also let the arithmetic incompute.rsflow without sign-checking, sincei128intermediates absorb the products naturally.- The doc says
1.0=100%. That's a unit-of-account decision. A rawRATE_SCALEvalue (1e9) means a 100% funding rate per interval.40_000_000means 4%.1_000_000means 0.1%. Read it as parts-per-billion of "1 unit notional."
Step 3: Update src/lib.rs
Open crates/funding/src/lib.rs. Currently empty (e69de29 blob). Replace with:
//! `openhl-funding` — funding-rate state machine.
//!
//! Pure state machine: no I/O, no async, no networking. Funding is applied
//! deterministically on a fixed cadence (see [`FundingClock`]); every tick is
//! a pure function over `(now, mark, index, positions)` → settlements.
//!
//! ### Hyperliquid-shape funding, in one paragraph
//!
//! Perpetual contracts don't expire, so the mark price can drift arbitrarily
//! from the spot ("index") price. Funding payments push it back: when mark >
//! index (longs are overpaying), longs pay shorts; when mark < index, shorts
//! pay longs. The premium `(mark - index) / index` is divided by a
//! per-day-interval count (HL: divisor 8 — one settlement every 1 hour, scaled to a daily rate) to derive a
//! per-interval rate, capped at a network-set absolute max. At each tick
//! every account with an open position settles `position_size * mark * rate`
//! in quote currency.
//!
//! Integration with the rest of openhl happens at the EVM bridge: settlement
//! deltas become balance updates that the bridge bundles into payloads. That
//! integration lives in `crates/evm/`; the rate math and tick gating are here.
pub mod types;
pub use types::RATE_SCALE;
Notice what's missing compared to the Lesson 11-end version: pub mod clock, pub mod compute, the rest of the pub use types::{...} re-exports. Those come in Lessons 4–10 as we add the modules. Lesson 1's lib.rs is the minimum that compiles.
The crate-level doc (//! ...) explains:
- This is a pure state machine. No I/O.
- A 1-paragraph HL funding recap — for any reader who lands on the crate root without context.
- Where integration happens (the bridge, not here).
The cross-reference [FundingClock] will be broken until Lesson 8 adds it; same handling as types.rs cross-refs.
(Answer: Compile error. pub mod compute; tells the compiler to find either compute.rs or compute/mod.rs in the same directory. With neither present, you get error[E0583]: file not found for module 'compute'. That's why we add the pub mod declarations as we create each file, not all upfront.)
Step 4: Compile
cargo build -p openhl-funding
Expected output:
Compiling openhl-funding v0.1.0 (/Users/.../my-openhl/crates/funding)
warning: unresolved link to `FundingRate`
warning: unresolved link to `Premium`
warning: unresolved link to `FundingClock`
Finished `dev` profile [unoptimized + debuginfo] in 0.5s
Three rustdoc warnings about unresolved links. Those are expected — the linked types arrive in Lessons 2/3 (types.rs) and Lesson 8 (clock.rs). All three resolve by Lesson 11. Don't suppress them with #[allow(rustdoc::broken_intra_doc_links)] — they're useful "you still need to add X" indicators while building.
Common errors:
error[E0463]: can't find crate for 'openhl_clob'— you forgot theopenhl-clob = { path = "../clob" }line in Cargo.toml. We don't useopenhl_clobin Lesson 1 code, but if you preempted Lesson 3 and added theuse openhl_clob::AccountIdimport to types.rs without the dep, this fires.error[E0583]: file not found for module 'clock'or'compute'— you preemptively addedpub mod clock;to lib.rs. Remove it; we'll add it back in Lesson 8.error: failed to parse manifest— Cargo.toml syntax. Double-check the[dev-dependencies]block isn't typo'd as[dev-dependences].
Design reflection
Three load-bearing decisions in this lesson:
-
RATE_SCALE = 1e9is i64, not u64. Signed because rates are signed. The arithmetic incompute.rswill usei128intermediates to absorb products;u64would complicate sign handling for no benefit. -
Module doc comment is the rationale, not a tutorial. The "Why fixed-point integers, not floats" paragraph explains why this design exists. A reader who lands on
types.rsin 6 months needs the why — the how is in the code itself. Doc comments earn their keep when they preempt the questions a future reader would ask. -
pub use types::RATE_SCALEat the crate root. Callers can writeuse openhl_funding::RATE_SCALE;instead ofuse openhl_funding::types::RATE_SCALE;. The shorter path is the canonical one; the module path is internal. Re-export at the crate root for anything callers actually use.
Answer key
cd ~/code/openhl-reference
git checkout cd94137
diff -u ~/code/my-openhl/crates/funding/Cargo.toml ./crates/funding/Cargo.toml
diff -u ~/code/my-openhl/crates/funding/src/types.rs ./crates/funding/src/types.rs
diff -u ~/code/my-openhl/crates/funding/src/lib.rs ./crates/funding/src/lib.rs
After Lesson 1:
- Cargo.toml matches Stage 8b exactly.
- types.rs matches the first ~30 lines of Stage 8b's types.rs — module doc +
RATE_SCALE. Everything below (the type definitions) is Lessons 2/3. - lib.rs is shorter than Stage 8b's lib.rs — only
pub mod types;+ the onepub use. The other module decls and re-exports come in later lessons.
Return:
git checkout main
Common questions
Q: Why declare [dev-dependencies] proptest now if Lesson 1 has no tests?
Because the Cargo.toml is a single diff target. Adding proptest at Lesson 4 would mean two Cargo.toml touches across the course; doing it once at Lesson 1 means the file stops changing after this lesson. Cargo.toml stability is worth a small unused dep declaration.
Q: What's a "parts-per-billion" interpretation in practice?
A funding rate of 1_250_000 raw means 0.00125 (0.125% per interval). Read it as "1,250,000 out of 1,000,000,000" — i.e., 0.125%. With HL's 8 settlements per day and a 4% cap, the range of values you'll see in practice is ±40_000_000 raw = ±4%/interval = ±32%/day worst case. All comfortably representable in i64.
Q: Could we change RATE_SCALE later without breaking consumers?
No. RATE_SCALE is a chain-consensus constant. Every persisted balance, every historical settlement, every test fixture is calibrated against RATE_SCALE = 1e9. Changing it requires a coordinated network upgrade. Treat it as immutable post-deployment. This is why we set it once, in a const, at the start of the crate.
Q: Why no test for RATE_SCALE?
What would the test assert? assert_eq!(RATE_SCALE, 1_000_000_000) is tautological — it tests the constant against itself. The constant's meaning lives in how other code uses it. Lesson 2's first money type gets the first meaningful test.
Next lesson (Lesson 2)
Lesson 2 adds the four "money types" — MarkPrice, IndexPrice, Premium, Notional. Each is a newtype wrapping a primitive. The teaching focus shifts from "why fixed-point" to "why newtypes": preventing accidental cross-feeding (e.g., passing an IndexPrice where a MarkPrice is expected). The four types add ~30 lines to types.rs and prove out the newtype pattern that the remaining types (Lesson 3) will follow.
Summary (3 lines)
RATE_SCALE = 10^9is the integer-scale constant for rates.i128for range; saturating arithmetic for safety.- Why integers: consensus determinism (
f64varies across CPUs). Why this scale: 9 decimal places = 8 Hyperliquid + 1 for compounding. - Convert on input/output; scaled inside. Foundation for every later lesson. Next: money types (newtypes).