Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

XFL: Decimal Floating Point

Xahau amounts and rates are not f64. They are XFL, a 64-bit decimal floating-point format the Hook API itself defines: a sign bit, an 8-bit biased exponent, and a 54-bit mantissa normalized to exactly 16 significant decimal digits. Every arithmetic operation on an XFL value is a host call — rshooks never computes on the bit pattern itself — because only the host knows how to normalize a result and detect overflow the same way the rest of the ledger does. This page covers the XFL type, how to construct values (including at compile time), the checked operators and comparison methods, reading an Amount field as XFL, and XFLUnchecked for hot paths that can defer validation to the end of a chain.

Why not f64

f64 cannot represent most decimal fractions exactly — 0.1 in binary floating point is already an approximation. XFL exists precisely to avoid that: every decimal value expressible in 16 significant digits has exactly one correct XFL encoding, bit-for-bit. Any bridge from decimal text to XFL that routes through f64 reintroduces the very rounding error XFL is designed to eliminate — which is why the XFL! literal macro below never touches f64 at all.

The XFL type

rshooks::xfl::XFL wraps a raw XFL bit pattern. The inner value is private: a host XFL call can return a negative value down the same i64 channel used for float results, so a public field would let an error code masquerade as a value. Two explicit escape hatches cross that boundary:

use rshooks::xfl::XFL;

let one = XFL::one();
let bits = one.raw_bits();
let same = XFL::from_raw_bits(bits);
assert_eq!(same.raw_bits(), bits);

XFL::one() is the only constructor guaranteed not to fail. XFL::new(exponent, mantissa) builds a normalized value from its two components:

let min_share = XFL::new(-21, 1_000_000_000_000_000)?;

(from examples/07_xfl-math). XFL’s mantissa is always normalized to 16 significant digits (10^15 to 10^16 - 1), so 0.000001 is not written as exponent -6 with mantissa 1 — it has to be mantissa 1_000_000_000_000_000 (1e15) with exponent -21, since 1e15 * 10^-21 == 10^-6. Getting the mantissa/exponent split wrong is an easy mistake, and XFL::new returning Result rather than silently normalizing is what catches it.

The XFL! compile-time literal macro

For a fixed constant, hand-computing that mantissa/exponent split — or the raw bit pattern — is exactly the kind of arithmetic a macro should do instead. XFL! takes a decimal literal and expands, at compile time, to XFL::from_raw_bits(<bits>i64):

use rshooks::XFL;
use rshooks::xfl::XFL as XflType;

const DEFAULT_REWARD_RATE: XflType = XFL!(0.003333333333333333);
assert_eq!(DEFAULT_REWARD_RATE.raw_bits(), 6_038_156_834_009_797_973);

Because the expansion is XFL::from_raw_bits, a const fn, the result works directly in const/static position:

use rshooks::XFL;
use rshooks::xfl::XFL as XflType;

const ONE: XflType = XFL!(1);
static REWARD_DELAY: XflType = XFL!(2600000);
assert_eq!(ONE.raw_bits(), 6_089_866_696_204_910_592);
assert_eq!(REWARD_DELAY.raw_bits(), 6_199_553_087_261_802_496);

The macro parses the literal’s text by hand — integer arithmetic on the digit string — rather than going through f64, which is the whole point: bit-exactness for every representable decimal, not an approximation.

Grammar. An optional leading -, then exactly one numeric literal token: a plain integer (123456789, optionally with _ separators like 1_000_000), a decimal (0.1, 1., 1.50), or either with a decimal exponent (1e-5, 2.6E6, 1e+3). Trailing zeros are normalized away, so 1.50, 1_000, and 2600000 encode exactly as if written 1.5, 1e3, and 2.6e6.

What gets rejected, always as a compile_error!, never a panic:

  • anything that is not a single numeric literal token — a string/char/byte literal, a hex/octal/binary integer (0x../0o../0b..), missing input, or extra tokens
  • a numeric type suffix (1i64, 1.0f64) — XFL! always produces its own i64 expansion, so a suffix can only be a mistake
  • more than 16 significant decimal digits after trailing-zero normalization — XFL’s mantissa cannot hold them, and the macro never silently rounds
  • a magnitude outside XFL’s representable range, roughly 1e-81 to 1e96 (unbiased exponent bounds -96..=80) — reported as a distinct “too small” or “too large” message
// More than 16 significant digits.
rshooks::XFL!(1.2345678901234567);
// Magnitude too large to represent.
rshooks::XFL!(1e96);

Checked arithmetic

XFL implements Add, Sub, Mul, Div, and Neg — but every one of these has Output = Result<XFL, HookError>, not a bare XFL. There is no local arithmetic: self + rhs issues a float_sum host call, self * rhs issues float_multiply, and so on. Sub is built from Neg plus float_sum (there is no dedicated float_subtract host function), and Neg is a real float_negate round trip, never a local sign-bit flip.

let remaining = match amount - share {
    Ok(x) => x,
    Err(_) => rollback!(b"xfl-math: amount - share failed", ...),
};

(from examples/07_xfl-math). Because every operator’s Output is a Result, rshooks also implements the mixed combinations Result<XFL> op XFL and XFL op Result<XFL>, so a chain that alternates a plain XFL in on each side short-circuits on the first error without an explicit ? between every step. (Rust’s orphan rules forbid Result on both sides of one of these impls, so an independently-fallible value on each side still needs a ? first.)

mulratio(round_up, num, den) computes self * (num / den) in one host call — used for percentage-style scaling:

let share = amount.mulratio(false, 1, 100)?; // 1% of `amount`, rounding down

It takes two extra scale parameters beyond a plain rhs, so it stays a named method rather than trying to fit an operator shape.

Comparison: methods and operators

.eq(rhs), .lt(rhs), .gt(rhs), and .compare(rhs, mode) all return Result<bool>, backed by the fallible float_compare host call. .compare() takes a bitmask (COMPARE_EQUAL, COMPARE_LESS, COMPARE_GREATER, freely combined — e.g. COMPARE_LESS | COMPARE_EQUAL for <=, since there’s no dedicated le/ge method).

XFL also implements PartialEq/PartialOrd (==, <, >, …), forwarding to those same methods — but these traits have a fixed bool/Option<Ordering> return type with no room for an Err, so on a float_compare failure they fall back to false/None, the same convention f64 uses for NaN. That is the wrong choice whenever a comparison gates a rollback decision on an operand that hasn’t been separately validated — silently treating “the comparison failed” the same as “not below the minimum” would mean accepting a transaction the hook never actually validated. Prefer the Result-returning methods, matched three ways, for exactly those cases:

match share.lt(min_share) {
    Ok(true) => rollback!(b"xfl-math: computed share below minimum", ...),
    Ok(false) => {}
    Err(_) => rollback!(b"xfl-math: comparison failed", ...),
}

The operators are reasonable specifically when both operands are already host-validated XFL values with no realistic path to a float_compare failure — a pure sanity check where “incomparable” and “not greater than” deserve identical handling:

if compounded > remaining {
    rollback!(b"xfl-math: compounded share unexpectedly exceeds remaining amount", ...);
}

(compounded and remaining here only exist because two earlier Results already returned Ok — see examples/07_xfl-math’s README for the full reasoning on when each style applies.)

Reading an Amount field as XFL

The typed slot layer (see Slots and Ledger Objects) reads an Amount field as XFL directly, working identically whether the amount is native (XRP/XAH) or an IOU:

let txn = SlotObject::from_otxn()?;
let amount: XFL = txn.get(sfAmount)?.as_xfl()?;

SlotObject<Amount>::as_xfl is a direct slot_float call. For a native amount, the result comes back in XAH units, not drops — the host builds it from the drop count as mantissa with exponent -6, then normalizes. Recover the drop count with xfl.to_int(6, false).

An equivalent route using the raw numbered slot API, reading the same field without the typed layer:

let txn_slot = otxn_slot(0)?;
let amount_slot = slot_subfield(txn_slot, sfAmount, 0)?;
let amount = XFL::from_slot(amount_slot)?;

Both call the same host function under the hood; the typed form just needs no slot numbers. XFL::sto/XFL::sto_set are the corresponding encode/decode pair for a serialized Amount buffer that isn’t already in a slot (e.g. building a transaction’s own Amount field by hand).

XFLUnchecked for hot paths

rshooks::xfl_unchecked::XFLUnchecked is the deferred-validation counterpart to XFL: every operator is still a real host round trip — there is no local fast path — but with no guest-side Result branch between steps. A poisoned or invalid operand propagates through the host calls (an invalid input is rejected by the host and the result is INVALID_FLOAT’s own raw bits, itself a valid poison value to keep propagating), and a single .validate() call at the end turns the final raw value into a real Result<XFL, HookError>:

let compounded_raw =
    share.unchecked() * growth.unchecked() * growth.unchecked() * growth.unchecked();
let compounded = match compounded_raw.validate() {
    Ok(x) => x,
    Err(_) => rollback!(b"xfl-math: compounded share failed to validate", ...),
};

(from examples/07_xfl-math.) XFL::unchecked() is a zero-cost reinterpretation into XFLUnchecked — no host call — and the two types mix freely at a chain’s boundary (XFLUnchecked op XFL and XFL op XFLUnchecked are both implemented, treating the XFL side as implicitly unchecked), so the usual shape is: start from a known-valid XFL, run the hot loop in XFLUnchecked, validate once at the end.

validate() is implemented as float_sum(self, 0) — a real host round trip, not a guest-side range check — specifically so it reuses the host’s own validation gate instead of re-deriving XFL’s mantissa/exponent rules locally and risking drift.

This is only worth reaching for on a measured hot path. Benchmarked against a checked Result-chain of the same operations, XFLUnchecked’s marginal cost per chained multiply matches a hand-written raw host-call chain exactly (+3 instructions/op vs. the checked chain’s +14); for Sub (two host calls per step, since Neg isn’t free), it’s +5 vs. +27. The win is entirely about when validation happens — once, at the end, instead of once per step — never about skipping any host validation a correct implementation actually needs. Use XFL’s checked operators by default, and reach for XFLUnchecked only once a chain like this is the measured bottleneck.