FABRKNT
Build OpenHL Funding — perpetual funding state machine
Pure compute
Lesson 5 of 12·CONTENT40 min80 XP

Treat this page as a workbench, not a blog post. The goal is to extract a reusable mental model from the source and carry it into the rest of the Fabrknt stack.

Course
Build OpenHL Funding — perpetual funding state machine
Lesson role
CONTENT
Sequence
5 / 12

Lesson 4 — compute_premium — first math, first tests

Question

First actual math function. compute_premium(mark, index) -> Premium = mark - index typed-and-saturating. Three unit tests + introduction to the testing discipline that scales to the rest of the course.

Principle (minimum model)

  • compute_premium(mark: Price, index: Price) -> Premium. Type-correct subtraction returning a Premium.
  • Saturating subtraction. mark.saturating_sub(index) clamps on underflow rather than panicking. Consensus-safe.
  • Three canonical tests. (1) mark > index → positive premium. (2) mark < index → negative premium. (3) mark = index → zero premium.
  • Test naming. test_premium_positive_when_mark_above_index — descriptive, not abbreviated. Future readers know what failed.
  • Unit tests are not enough alone. Cover specific examples; the next lesson adds proptest for universal claims.
  • Reusable in higher layers. compute_premium is pure compute → callable from any context (test, fuzz, scanner, sequencer). No async, no I/O.
  • Pinned to SHA cd94137. Reproducible byte-for-byte against the openhl reference.

Worked example + steps

Lesson 4 — compute_premium — first math, first tests

Goal

Concepts you'll grasp in this lesson:

  • Pick integer width by the intermediate range, not the inputsmark and index are u64, but (mark - index) * RATE_SCALE can hit ~1.8e28; i128 intermediates are non-optional, and the upcasts go in before the subtraction so signs survive.
  • Multiply before divide preserves precision(mark - index) / index in integer math rounds to zero for any sub-100% premium; scaling by RATE_SCALE first turns the fraction into i128 magnitude, then the divide produces a meaningful integer.
  • Subtraction in u64 is the canonical sign bugMarkPrice(99) - IndexPrice(100) wraps to u64::MAX and produces a huge positive premium when truth is a small negative one. The i128::from(...) upcast is what makes the subtraction algebraically correct.
  • Graceful degradation for missing oracleindex == 0 returns Premium(0) instead of erroring. Funding propagates through the bridge as balance updates; an Err would surface as a transaction failure on unrelated payloads. Zero is the right answer when there's no signal to drive a rate.
  • Test comments as paper math// (101-100) * 1e9 / 100 = 10_000_000 next to the assertion lets any future debugger verify the test against the formula, not against the test author's promise.

Verification:

cargo test -p openhl-funding

…passes 4 unit tests.

Specific changes:

The openhl-funding crate goes from "all type definitions" to "type definitions + first piece of math":

  • crates/funding/src/compute.rs — new file with the module doc + 2 functions:
    • compute_premium(mark, index) -> Premium — derives (mark - index) / index, scaled by RATE_SCALE.
    • saturate_i128_to_i64(v) -> i64 — clamp helper (private). 3 lines.
  • 4 hand-traced unit tests in compute.rs's #[cfg(test)] mod tests block:
    • premium_zero_when_mark_equals_index
    • premium_positive_when_mark_above_index
    • premium_negative_when_mark_below_index
    • premium_saturates_to_zero_when_index_is_zero
  • crates/funding/src/lib.rs — adds pub mod compute; + re-exports compute_premium.

This is the first lesson with actual math. From now on, every code change has the potential to silently shift wealth between accounts. The hand-traced tests pin the expected output against specific input values you can verify by paper math.

Recap

After Lesson 3:

  • 9 types + RATE_SCALE in types.rs — Stage 8b's complete type roster.
  • Zero behavior yet. The crate compiles but does nothing.

Lesson 4 introduces the first function. The function is short (~10 lines of body) but encodes 3 design decisions: graceful handling of index == 0, i128 intermediates for overflow safety, and saturation rather than wrap/panic.

Plan

Three edits:

  1. Create crates/funding/src/compute.rs — module doc + imports + compute_premium + private saturate_i128_to_i64 helper.
  2. Add #[cfg(test)] mod tests to compute.rs with 4 hand-traced unit tests.
  3. Update crates/funding/src/lib.rs — add pub mod compute; declaration and re-export compute_premium at the crate root.

(Answer: u64::MAX * 1e9 overflows i64 by 10 orders of magnitude. Worst case mark = u64::MAX, index = 0 (we handle this separately), or mark = u64::MAX, index = 1(u64::MAX - 1) * 1e9 ≈ 1.8e28. i64::MAX is ~9.2e18; we need i128 for the intermediate. After the divide by index, we're back in i64 range — but the divide must happen after the multiply, so the intermediate must fit i128. i128 is mandatory for the product; saturation handles the rare cases where even the final result overflows i64.)

Walk-through

Step 1: Create compute.rs with the module doc

Create crates/funding/src/compute.rs. Initial content:

//! Pure funding-rate math.
//!
//! Three building blocks, each stateless:
//!   - [`compute_premium`] derives the mark/index gap as a signed fraction
//!   - [`compute_rate`] divides + caps to produce a per-interval rate
//!   - [`apply_funding`] turns a rate + position snapshot into settlements
//!
//! Each function is deterministic and saturates on overflow rather than
//! wrapping. Validators that disagree about funding fork the chain, so the
//! cost of an unexpected overflow has to be bounded behavior, not panic.

use crate::types::{
    FundingParams, FundingRate, IndexPrice, MarkPrice, Notional, Position, Premium, Settlement,
    RATE_SCALE,
};

Two things to notice:

The module doc previews 3 functions but we only ship 1 in Lesson 4. The cross-references [compute_rate] and [apply_funding] will be broken until Lessons 6 and 7. Tolerate the warnings — same as the Lesson 1/2 [FundingRate] cross-refs we let resolve incrementally.

The use statement imports types we don't all use yet. FundingParams, FundingRate, Notional, Position, Settlement are needed by Lesson 6/7's functions. Importing them now means the import block stabilizes after Lesson 4 — same logic as Lesson 1's [dev-dependencies] proptest. Stabilize boilerplate early; iterate on logic.

Step 2: Add compute_premium

After the use block:

/// Compute the premium `(mark - index) / index`, scaled by [`RATE_SCALE`].
///
/// Returns `Premium(0)` if `index == 0` — the safest behavior, since with no
/// reliable reference price the funding rate should not push capital around.
/// Real deployments should guard upstream (e.g., refuse to tick when the
/// oracle is missing); the saturation here is the second line of defense.
#[must_use]
pub fn compute_premium(mark: MarkPrice, index: IndexPrice) -> Premium {
    if index.0 == 0 {
        return Premium(0);
    }
    // (mark - index) as i128 so we can't lose sign on subtraction; multiply
    // by RATE_SCALE in i128 to avoid overflow before the divide.
    let diff = i128::from(mark.0) - i128::from(index.0);
    let scaled = diff.saturating_mul(i128::from(RATE_SCALE));
    let premium = scaled / i128::from(index.0);
    // Saturate back to i64 — at i64 range with index prices in u64::MAX
    // territory, this only clips at network-pathological inputs.
    Premium(saturate_i128_to_i64(premium))
}

Drawing where types widen, saturate, and narrow inside this function makes the spine of the logic visible at a glance:

  MarkPrice(u64) ──► i128 ──┐
                            ▼
  IndexPrice(u64) ──► i128 ─► [ subtract ] ──► diff (i128: sign preserved)
                                                  │
                                                  ▼
  RATE_SCALE(i64) ──► i128 ────────────────► [ saturating_mul ] ──► scaled (i128, overflow clamped)
                                                                       │
                                                                       ▼
  IndexPrice(u64) ──► i128 ──────────────────────────────────────► [ / divide ]   (index == 0 already guarded above)
                                                                       │
                                                                       ▼
                                                                    premium (i128)
                                                                       │
                                                                       ▼
  Premium(pub i64) ◄──────────────────────────────────── [ saturate_i128_to_i64 ]

Three things this picture pins down: (a) the inputs (MarkPrice / IndexPrice / RATE_SCALE) and the output (Premium) are deliberately narrow types, while only the intermediates get widened to i128; (b) overflow is absorbed at two places — saturating_mul in the middle and saturate_i128_to_i64 at the end — but diff itself doesn't need saturation, because i128's headroom is more than enough for the subtraction; (c) the "multiply-then-divide" order is what the label scaled represents on the diagram.

10 lines of body. Four moving parts:

  1. Early return on index == 0. A zero index means the oracle hasn't delivered a price (boot state) or the asset has no spot reference. Either case should produce zero funding — there's no meaningful (mark - index) to compute when there's no index. Returning Premium(0) is graceful degradation; an error would propagate as a transaction-level failure through the bridge, which is the wrong response to a transient oracle issue. This early return also pre-empts a divide-by-zero panic: the next line, scaled / i128::from(index.0), would otherwise be reached with a zero denominator. Graceful degradation and "never panic" share the same two lines.

  2. i128::from(mark.0) - i128::from(index.0). Both operands upcast to i128 before the subtraction. Subtracting two u64s would underflow for mark < index — the result would wrap to near u64::MAX instead of producing a negative number. Upcasting to signed i128 makes the subtraction algebraically correct.

  3. diff.saturating_mul(i128::from(RATE_SCALE)). The multiply uses saturating_mul, not regular *. At worst case (mark close to u64::MAX, index very small), the product can approach i128::MAX — and would overflow with regular multiplication. saturating_mul clamps to i128::MAX / i128::MIN instead of panicking.

  4. scaled / i128::from(index.0). The division comes after the multiplication. If we divided first, we'd lose precision(mark - index) / index in integer math would produce 0 for any premium less than 1.0 (the entire useful range!). Multiplying by RATE_SCALE first preserves the fractional digits as integer magnitude, then the divide produces the scaled premium.

Then saturate_i128_to_i64 clips back to the Premium's i64 range.

Step 3: Add the saturate_i128_to_i64 helper

After compute_premium:

/// Clamp an `i128` into the `i64` range. Used wherever an intermediate
/// product can exceed `i64::MAX` at network-pathological inputs (e.g., a
/// `u64::MAX` index price). Saturation, not wrapping — see the module-doc
/// comment on why panicking would be a worse failure mode.
fn saturate_i128_to_i64(v: i128) -> i64 {
    i64::try_from(v).unwrap_or(if v > 0 { i64::MAX } else { i64::MIN })
}

Three lines of body. i64::try_from(v) returns ResultOk(value) if v fits in i64, Err otherwise. unwrap_or(...) provides the default for the Err case: clamp to i64::MAX if the overflow was positive, i64::MIN if negative.

This function is private to the module (fn, not pub fn). Callers don't need it — they pass MarkPrice / IndexPrice in, get Premium back, and the saturation happens behind the scenes. Keeping it private prevents accidental misuse and keeps the public surface clean.

Lesson 7's apply_funding will be the second caller of this helper; that's why it's a helper and not inlined into compute_premium.

(Answer: i64::MAX. i128::MAX is ~1.7e38, way beyond i64::MAX (~9.2e18). i64::try_from(i128::MAX) fails; unwrap_or(if v > 0 { i64::MAX } else { i64::MIN }) evaluates the closure since v > 0, returning i64::MAX. Symmetric on the negative side: i128::MIN clamps to i64::MIN.)

Step 4: Add the test module + 4 unit tests

At the end of compute.rs:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn premium_zero_when_mark_equals_index() {
        let p = compute_premium(MarkPrice(100), IndexPrice(100));
        assert_eq!(p, Premium(0));
    }

    #[test]
    fn premium_positive_when_mark_above_index() {
        // mark 101, index 100 → premium = 1/100 = 0.01 → 10_000_000 ppb
        let p = compute_premium(MarkPrice(101), IndexPrice(100));
        assert_eq!(p, Premium(10_000_000));
    }

    #[test]
    fn premium_negative_when_mark_below_index() {
        let p = compute_premium(MarkPrice(99), IndexPrice(100));
        assert_eq!(p, Premium(-10_000_000));
    }

    #[test]
    fn premium_saturates_to_zero_when_index_is_zero() {
        let p = compute_premium(MarkPrice(1_000_000), IndexPrice(0));
        assert_eq!(p, Premium(0));
    }
}

Four hand-traced tests. Each is short, but each pins a specific meaning:

  1. premium_zero_when_mark_equals_index — the symmetry case. Mark = index means no dislocation. The math is straightforward: (100 - 100) * 1e9 / 100 = 0. This test catches an off-by-one or sign-flip in the formula.

  2. premium_positive_when_mark_above_index — the longs-overpaying case. Mark 101 > Index 100 → positive premium. The expected value 10_000_000 is the paper math: (101-100) * 1e9 / 100 = 1e9 / 100 = 1e7 = 10_000_000. In ppb: 1% premium. This test catches an inverted sign convention.

  3. premium_negative_when_mark_below_index — the shorts-overpaying case. Mark 99 < Index 100 → negative premium. Same magnitude as test 2, opposite sign. Catches the "subtract as u64 → underflow" bug specifically.

  4. premium_saturates_to_zero_when_index_is_zero — the graceful-degradation case. Premium(0) is the expected output, not a panic or error. Catches anyone who deletes the early-return guard "for simplicity."

The comment // mark 101, index 100 → premium = 1/100 = 0.01 → 10_000_000 ppb in test 2 is the paper math, written in the test. Anyone debugging this in the future can verify by hand that the assertion is correct — no need to trust the test author got it right.

Step 5: Update lib.rs

Open crates/funding/src/lib.rs. The current state:

//! `openhl-funding` — funding-rate state machine.
//! ...

pub mod types;

pub use types::{
    FundingParams, FundingRate, IndexPrice, MarkPrice, Notional, Position, PositionSize,
    Premium, Settlement, RATE_SCALE,
};

Add the compute module declaration + re-export:

//! `openhl-funding` — funding-rate state machine.
//! ...

pub mod compute;
pub mod types;

pub use compute::compute_premium;
pub use types::{
    FundingParams, FundingRate, IndexPrice, MarkPrice, Notional, Position, PositionSize,
    Premium, Settlement, RATE_SCALE,
};

Two changes:

  • pub mod compute; — declares the new module.
  • pub use compute::compute_premium; — re-exports the function at the crate root. Callers can write use openhl_funding::compute_premium; instead of use openhl_funding::compute::compute_premium;.

Module declarations stay alphabetical (compute before types). Same for the pub use ordering. Consistency matters in long re-export blocks.

Step 6: Run tests

cargo test -p openhl-funding

Expected output:

   Compiling openhl-funding v0.1.0 (/Users/.../my-openhl/crates/funding)
warning: unresolved link to `compute_rate`
warning: unresolved link to `apply_funding`
warning: unresolved link to `FundingClock`
    Finished `test` profile [unoptimized + debuginfo] in 0.6s
     Running unittests src/lib.rs

running 4 tests
test compute::tests::premium_negative_when_mark_below_index ... ok
test compute::tests::premium_positive_when_mark_above_index ... ok
test compute::tests::premium_saturates_to_zero_when_index_is_zero ... ok
test compute::tests::premium_zero_when_mark_equals_index ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

4 tests pass. First green run in the crate. The 3 rustdoc warnings are still expected (compute_rate/apply_funding/FundingClock — resolved by Lessons 6/7/8).

Common errors:

  • assertion failed: left=0 right=10_000_000 on the positive test — your compute_premium is missing the * RATE_SCALE step. Without that scaling, integer division (101 - 100) / 100 rounds to 0.
  • assertion failed: left=18446744073709541616 right=-10_000_000 on the negative test — you did the subtraction in u64 instead of upcasting to i128. The huge positive number is u64::MAX + (99 - 100) underflow-wrapped. Add the i128::from(...) upcasts on both operands.
  • Panic in test — you used regular * instead of saturating_mul. Regular multiplication panics on overflow in debug builds. Switch to saturating_mul.
  • error: cannot find function 'saturate_i128_to_i64' — the helper is defined below compute_premium in the same file. Either move it above the caller, or leave it below — Rust doesn't care about declaration order in modules.

Design reflection

Four load-bearing decisions in this lesson:

  1. index == 0 returns Premium(0), not an error. Graceful degradation when the oracle is unavailable. Erroring would propagate as a transaction failure through the bridge, blocking unrelated payload work. Zero is the right answer for "we have no information to drive a rate."

  2. i128 intermediates, never u64. The subtraction can be negative; the multiplication can exceed u64::MAX. Both operations need signed and wider arithmetic. Choose the integer width by the intermediate range, not the input range.

  3. saturating_mul, not *. Overflow during the multiply would either panic (debug) or wrap (release). Both are worse than saturation: panic = chain fork via halt, wrap = chain fork via wrong value. Saturation is the only bounded-behavior option for consensus-critical math.

  4. Test comments are the paper math. // (101-100) * 1e9 / 100 = 10_000_000 next to the assertion lets any future debugger verify the assertion against the formula, not against the test author's promise. Tests are documentation; their comments are the doc body.

Answer key

cd ~/code/openhl-reference
git checkout cd94137
diff -u ~/code/my-openhl/crates/funding/src/compute.rs ./crates/funding/src/compute.rs
diff -u ~/code/my-openhl/crates/funding/src/lib.rs ./crates/funding/src/lib.rs

After Lesson 4:

  • compute.rs matches Stage 8b through compute_premium + saturate_i128_to_i64 + the 4 hand-traced premium tests. compute_rate, apply_funding, the rate tests, and the proptests are Lessons 5–7.
  • lib.rs has pub mod compute; and the compute_premium re-export. apply_funding, compute_rate, and the clock module are Lessons 5–8.

Return:

git checkout main

Common questions

Q: Why does compute_premium use i128 everywhere instead of just for the dangerous step? The conversion i128::from(u64) is free (it's just a zero-extend). Doing the whole calculation in i128 is one mental model — "this function uses i128 arithmetic" — vs the mixed model "u64 here, i128 there." Uniform width is a readability win at zero cost. The final saturation back to i64 is the only conversion that has any semantic weight.

Q: Why is RATE_SCALE upcast via i128::from(RATE_SCALE) and not just RATE_SCALE as i128? from is the idiomatic, non-truncating conversion. as i128 works here (i64 → i128 doesn't truncate), but from documents intent: "this is a widening, not a reinterpretation." Use from for widening, as only when you've already verified no truncation can happen. A future engineer reading as i128 has to verify safety; from documents that the conversion is safe.

Q: Why is the helper named saturate_i128_to_i64 and not just clamp_to_i64? "Saturate" is the established term for "clamp at type boundary" — same word as u64::saturating_mul, i128::saturating_sub. Using the standard vocabulary makes the function's behavior obvious to any Rust dev. "Clamp" can mean any user-defined bounds; "saturate" specifically means type-bound clamping.

Q: Should compute_premium be pub(crate) instead of pub? pub because external callers (the bridge integration in course 10, or external observers querying funding state for telemetry) need it. pub(crate) would forbid that. The function is part of the public API. saturate_i128_to_i64 is the implementation detail; compute_premium is the contract.

Next lesson (Lesson 5)

Lesson 5 doesn't add a new function. Instead, it does a deep dive on the overflow philosophy: why saturation is the only acceptable behavior for consensus-critical math, what the alternatives look like and why they fork the chain, and how saturate_i128_to_i64's edges behave under pathological inputs. The lesson also adds 1 proptest (premium_is_antisymmetric_in_mark_index) — the property that swapping mark and index flips the premium sign. First proptest in the crate.

Summary (3 lines)

  • compute_premium(mark, index) -> Premium = mark - index (saturating, typed).
  • Three canonical tests (positive / negative / zero); descriptive naming. Unit tests for examples; proptest for universals (next lesson).
  • Pure compute = reusable everywhere. Pinned to SHA cd94137. Next: overflow philosophy + first proptest.