Fixed-Point Math
The example code snippets used in this guide are experimental and have not been audited. They simply help exemplify usage of the OpenZeppelin Sui Package.
The openzeppelin_fp_math package adds two decimal fixed-point types with 9 decimals of precision, matching Sui's native coin precision (10^9). It is the right tool whenever you need real-valued arithmetic — prices, fees, rates, ratios, balance deltas — without giving up determinism or onchain auditability.
Use cases
Use openzeppelin_fp_math when your app needs:
- Prices, fees, rates, or ratios with fractional precision.
- Signed balance deltas or accounting adjustments.
- Decimal fixed-point values that align with Sui's native 9-decimal coin precision.
- Explicit truncating or away-from-zero rounding for fixed-point multiplication and division.
The package complements openzeppelin_math, which covers integer arithmetic. Use openzeppelin_math when your values are whole numbers; reach for openzeppelin_fp_math when fractional values are part of the protocol.
Why a 9-decimal scale
- Matches Sui's native coin decimals, so converting between token amounts and fixed-point values is straightforward.
- Decimal scale is intuitive for humans, UIs, and offchain systems —
1.5is1_500_000_000, no binary fixed-point surprises. - Fits in
u128, keeping storage and arithmetic light compared tou256-based decimal types.
The two types
UD30x9— unsigned. Decimal range from0to roughly3.4 × 10^29.SD29x9— signed (two's complement). Decimal range from roughly-1.7 × 10^29to1.7 × 10^29. Useful for balance deltas, signed adjustments, and any quantity that can dip below zero.
The names encode the layout: UD30x9 uses up to 30 integer digits with 9 fractional digits, SD29x9 is the signed counterpart with one bit reserved for the sign. Both fit in 16 bytes of storage, so swapping u128 for either type costs nothing in storage or arithmetic budget.
Pick UD30x9 for any quantity that cannot be negative: token balances, prices, fees, exchange rates, supply totals. Pick SD29x9 when the value can dip below zero: balance deltas, signed adjustments, P&L, position sides.
Usage
Add the dependency in Move.toml:
[dependencies]
openzeppelin_fp_math = { r.mvr = "@openzeppelin-move/fixed-point-math" }Import the modules you need:
use openzeppelin_fp_math::{ud30x9, ud30x9_convert};Quickstart examples
Build a fixed-point value from a whole integer
module my_sui_app::pricing;
use openzeppelin_fp_math::{ud30x9, ud30x9_convert};
public fun reference_price(): ud30x9::UD30x9 {
// 1.5 = 1.0 + 0.500000000
ud30x9_convert::from_u128(1).add(ud30x9::wrap(500_000_000))
}Multiply with explicit rounding
module my_sui_app::quote;
use openzeppelin_fp_math::{ud30x9, ud30x9_convert};
public fun apply_fee(amount: ud30x9::UD30x9, fee_rate: ud30x9::UD30x9): ud30x9::UD30x9 {
// Round away from zero so quoted fees never under-charge.
amount.mul_away(fee_rate)
}Track a signed balance delta
module my_sui_app::ledger;
use openzeppelin_fp_math::{sd29x9, sd29x9_convert};
public fun apply_adjustment(
balance: sd29x9::SD29x9,
magnitude: u128,
is_negative: bool,
): sd29x9::SD29x9 {
let delta = sd29x9_convert::from_u128(magnitude, is_negative);
balance.add(delta)
}Convert back to a whole integer
module my_sui_app::settlement;
use openzeppelin_fp_math::{ud30x9, ud30x9_convert};
/// Truncate a `UD30x9` value to a `u64` token amount.
public fun settle(amount: ud30x9::UD30x9): u64 {
amount.to_u64_trunc()
}Choosing a function
mul,div— round toward zero. Equivalent tomul_trunc,div_trunc. Use as the default when rounding direction does not matter.mul_trunc,div_trunc— round toward zero (truncate). Use when you want to spell the rounding direction out at the call site.mul_away,div_away— round away from zero when inexact. Use for fees, slippage caps, and protocol-side accounting where rounding should never favor the user against the protocol.unchecked_add,unchecked_sub— wrap modulo2^128instead of aborting on overflow/underflow. Use only when you have already proved the operation is safe and you want to avoid the abort path; otherwise preferadd/sub.pow— approximate, biased toward zero, and not associative under truncation. Reasonable for small, integer exponents over typical fixed-point ranges; verify expected behavior at your application's exponent and operand magnitudes before relying on it.
For SD29x9 specifically, prefer mod (Euclidean, always non-negative) over rem (truncating, sign follows dividend) unless your protocol explicitly wants the truncating variant.
SD29x9::min() is -2^127. There is no representable +2^127, so calling abs or negate on min() aborts with EOverflow. Same caveat for wrap(_, true) at maximum magnitude.
Why fixed-point on-chain
Move has no native fractional type. Real-valued protocol math — prices, fees, interest rates, signed balance deltas — has to be encoded in integers. The naive approach is to scale by a power of two ("Q-notation": Q64.64, Q96, Q128, etc.) and use bit shifts. That works, but it has two persistent drawbacks:
- Binary scales misalign with the units protocols actually report. Token amounts, exchange rates, and oracle prices are quoted as decimals. Converting to and from a binary scale at every boundary creates rounding seams that are easy to get wrong and hard to audit.
- Bit-shift arithmetic puts the rounding decision in the operator instead of the call site. A
>>is silent. Amul_div-shaped helper makes the rounding direction visible. The Cetus exploit hinged on achecked_shl-shaped function that silently passed a value it should have rejected; the same class of bug is harder to write when rounding is a named choice at every call site.
openzeppelin_fp_math picks decimal fixed-point with 9 decimals (10^9) — the same precision Sui uses for native coin amounts. 1.5 is 1_500_000_000, 0.000_000_001 is 1. Conversions between token amounts and fixed-point values become a single multiply or divide by 10^9, with no bit-pattern reasoning involved.
The package complements openzeppelin_math: integer arithmetic stays in openzeppelin_math, fractional arithmetic lives here. They use the same explicit-rounding philosophy.
Casting vs converting: the most important distinction
The package draws a sharp line between two kinds of "convert this number to fixed-point" operations. Mixing them up is the most common way to introduce a 10^9-sized bug, so it is worth getting clear on before writing any arithmetic.
Casting — preserves raw scaled bits
Casting reinterprets a value that is already in fixed-point form. It does not multiply or divide by 10^9.
The core cast helpers live on the sd29x9 and ud30x9 modules:
use openzeppelin_fp_math::{sd29x9, ud30x9};
let one = ud30x9::wrap(1_000_000_000); // 1.0
let raw = ud30x9::wrap(42); // 0.000000042 — NOT 42.0
let positive_one = sd29x9::wrap(1_000_000_000, false); // 1.0
let small_negative = sd29x9::wrap(42, true); // -0.000000042Cross-type casts also count as casting: they preserve the scaled numeric meaning and only validate signedness or range.
use openzeppelin_fp_math::{ud30x9, ud30x9_convert};
let unsigned = ud30x9_convert::from_u128(42); // 42.0
let signed = unsigned.into_SD29x9(); // 42.0 as SD29x9
let roundtrip = signed.into_UD30x9(); // 42.0 as UD30x9Use casting when your input is already fixed-point bits (e.g., loaded from storage, returned by another fixed-point function, or hand-encoded in a constant).
Converting — applies or removes the 10^9 scale
Converting changes between whole-integer semantics and fixed-point semantics. Use the *_convert modules for this.
use openzeppelin_fp_math::{sd29x9_convert, ud30x9_convert};
let whole = ud30x9_convert::from_u128(42); // 42.0 — multiplies by 10^9
let back = whole.to_u128_trunc(); // 42 — divides by 10^9, truncates fractional
let delta = sd29x9_convert::from_u128(5, true); // -5.0
let (magnitude, is_negative) = delta.to_parts_trunc();
// magnitude == 5, is_negative == trueUse converting whenever the input or output represents a whole-number quantity in your domain (a token amount the user typed, a count, a percentage point).
The mental rule
If
42means42, use*_convert::from_u128/to_u*_trunc. If42means0.000_000_042, usewrap/unwrap.
Mixing the two collapses to a 10^9 scale error somewhere downstream, usually far from where it was introduced.
Negative values: why the sign flag
Move does not provide a native signed integer, so SD29x9 conversions take an unsigned magnitude plus a bool sign flag. to_parts_trunc returns the same shape on the way out:
use openzeppelin_fp_math::sd29x9_convert;
// Build -5.0
let neg_five = sd29x9_convert::from_u128(5, true);
// Read it back as (magnitude, is_negative)
let (mag, neg) = neg_five.to_parts_trunc();
// mag == 5, neg == trueTwo consequences worth knowing:
to_parts_truncalways reportsis_negative = falsewhen the magnitude is zero, regardless of the underlying bit pattern. This avoids "negative zero" surprises in callers that branch on the flag.to_u128_truncandto_u64_truncabort on negative input. Use thetry_*variants when you want anone()instead, or callto_parts_truncto get sign and magnitude in one go.
Rounding direction in fixed-point arithmetic
Like openzeppelin_math, this package treats rounding as a protocol decision. But the spelling is different: the rounding mode is encoded in the function name, not in a RoundingMode parameter.
| Function pair | Rounding | When to use |
|---|---|---|
mul, mul_trunc | toward zero | Default. The two are aliases — pick mul_trunc if you want the rounding direction visible at the call site. |
mul_away | away from zero | Fees, slippage caps, anywhere the protocol should never undercharge or under-reserve. |
div, div_trunc | toward zero | Default for division. Same alias relationship as mul. |
div_away | away from zero | When the divisor is user-supplied or quote-related and the protocol must bound itself conservatively. |
The same vault-lifecycle logic from the integer-math guide applies here, just with finer granularity. If your vault holds UD30x9 value internally:
use openzeppelin_fp_math::ud30x9::UD30x9;
/// Convert tokens to shares — round toward zero so the vault keeps the dust.
public fun deposit_shares(amount: UD30x9, total_assets: UD30x9, total_supply: UD30x9): UD30x9 {
amount.mul_trunc(total_supply).div_trunc(total_assets)
}
/// Convert shares to tokens — round toward zero so the vault keeps the dust.
public fun withdraw_assets(shares: UD30x9, total_assets: UD30x9, total_supply: UD30x9): UD30x9 {
shares.mul_trunc(total_assets).div_trunc(total_supply)
}Both directions truncate toward zero, so the vault never gives away fractional remainders. Flipping either to mul_away / div_away opens the same round-trip extraction attack discussed in the integer-math guide.
When _trunc and _away differ
mul and mul_trunc differ only when the exact mathematical product cannot be represented in 9 decimals. For values that already fit cleanly, mul_trunc(x, y) == mul_away(x, y). The choice only matters at the precision boundary — which, in fee-and-rate land, is exactly where it matters most.
div_away is the helper you want for inverse-rate conversions. 1.0 / 3.0 is 0.333333333 with div_trunc and 0.333333334 with div_away. If your protocol owes the user some fraction of x / 3, rounding down on each individual settlement systematically under-pays the user.
Overflow and underflow
Unlike openzeppelin_math, the fixed-point package does not return Option<T> from arithmetic. It aborts. The error constants come in three families:
EOverflow— the result exceeds the representable range of the target type.EUnderflow— applies toUD30x9::subonly. Subtraction would produce a negative value, which is unrepresentable in an unsigned type.EDivideByZero— applies todiv,div_trunc,div_away,mod, andrem.
There are also the unchecked-add and unchecked-subtract escape hatches, plus unchecked_lshift / unchecked_rshift on UD30x9. They wrap modulo 2^128 instead of aborting.
use openzeppelin_fp_math::ud30x9;
let a = ud30x9::max();
let b = ud30x9_convert::from_u128(1);
let bad = a.add(b); // aborts with EOverflow
let wrapped = a.unchecked_add(b); // wraps to a small value, no abortReach for unchecked_* only when you have already proved the operation is safe and want to skip the abort path (rare). For normal application code, prefer the checked versions and let aborts surface programmer bugs.
If you need an Option-style API, build it locally:
use openzeppelin_fp_math::ud30x9::UD30x9;
public fun try_add(x: UD30x9, y: UD30x9): Option<UD30x9> {
let raw_sum = (x.unwrap() as u256) + (y.unwrap() as u256);
if (raw_sum > std::u128::max_value!() as u256) {
option::none()
} else {
option::some(ud30x9::wrap(raw_sum as u128))
}
}Comparison and equality
Comparison operators on UD30x9 and SD29x9 work how you would expect — eq, neq, gt, gte, lt, lte, is_zero — with one subtle point on the signed type:
use openzeppelin_fp_math::sd29x9;
let a = sd29x9::wrap(0, false);
let b = sd29x9::wrap(0, true);
// Both encode 0; eq compares raw bits, but `wrap` normalizes 0 magnitude to a single representation.
let same = a.eq(b); // trueSD29x9::wrap rejects "negative zero" by mapping (0, true) and (0, false) to the same underlying bits, so equality on zeros behaves as expected. If you ever construct an SD29x9 from raw bits via the package-internal from_bits, this normalization does not happen — but from_bits is not part of the public API surface, so most callers never encounter it.
Cross-type casts
UD30x9 and SD29x9 represent the same scaled meaning when their values overlap in the non-negative range, so the package provides explicit casts both ways:
use openzeppelin_fp_math::{sd29x9_convert, ud30x9_convert};
// UD30x9 -> SD29x9
let unsigned = ud30x9_convert::from_u128(42); // 42.0 UD30x9
let signed = unsigned.into_SD29x9(); // 42.0 SD29x9
// `into_SD29x9` aborts with `ECannotBeConvertedToSD29x9` if the magnitude exceeds 2^127 - 1.
// SD29x9 -> UD30x9
let positive = sd29x9_convert::from_u128(42, false); // 42.0 SD29x9
let back = positive.into_UD30x9(); // 42.0 UD30x9
// `into_UD30x9` aborts with `ECannotBeConvertedToUD30x9` if the value is negative.Each direction has a try_into_* counterpart that returns Option<T> instead of aborting. Use the try_* variants on values that come from external input or untrusted callers; use the abort variants in internal protocol code where a failed cast is a program bug.
Bitwise operations on UD30x9
UD30x9 exposes a full set of bitwise helpers — and, and2, or, xor, not, plus lshift / rshift and their unchecked_* siblings. SD29x9 does not expose bitwise operations, on purpose: bit-twiddling on a two's complement representation is rarely what protocol authors mean.
The most common use for these is packing flags or scale-shifting raw bit patterns inside a fixed-point pipeline:
use openzeppelin_fp_math::ud30x9;
// Mask to keep only the low 64 bits of the raw representation.
let truncated = ud30x9::wrap(some_value).and(0xFFFFFFFFFFFFFFFF);
// Shift the raw representation right by 9 bits — note this is a bit shift,
// not a divide-by-2^9, and not a divide-by-10^9.
let shifted = ud30x9::wrap(some_value).rshift(9);Two cautions:
lshiftaborts when shifting by>= 128bits or when the shift would consume non-zero high bits.unchecked_lshifttruncates instead of aborting and returns0forbits >= 128.- The bitwise operators work on raw bits, not on the decimal value.
x.lshift(1)doubles the underlyingu128, but it does not double the decimal value in any meaningful sense (you'd wantx.add(x)for that).
If you need a * 2 or / 2 operation, use arithmetic. The bitwise helpers are for explicit bit manipulation, not for fast arithmetic.
pow: useful but approximate
Both types expose pow(x, exp: u8) for integer exponents. The implementation uses binary exponentiation with fixed-point multiplication, which means every intermediate multiply applies a fixed-point truncation by 10^9.
The consequences:
- Results are biased toward zero. Rounding error compounds as
expgrows. - For
0 < x < 1(and the analogous range inSD29x9), intermediate values can reach zero before the final mathematically-correct result would. - Because truncation is applied at intermediate steps, fixed-point multiplication is not associative under truncation. The grouping that binary exponentiation uses can change the result.
In practice:
use openzeppelin_fp_math::{ud30x9, ud30x9_convert};
let two = ud30x9_convert::from_u128(2);
let eight = two.pow(3); // 8.0 — exact for whole-number bases.
let half = ud30x9::wrap(500_000_000); // 0.5
let q = half.pow(20); // ≈ 0.000000953... but truncation may bias the result.For small integer exponents over operands that are not far from 1.0, pow is fine. For long compounded products (interest accrual over many periods, statistical computations), prefer to:
- Restructure the computation to reduce the number of intermediate truncations, or
- Switch to a pre-computed lookup or higher-precision representation in the worst-case region, or
- Bound the rounding error analytically and check whether your protocol can absorb it.
The min() gotcha on SD29x9
SD29x9::min() returns -2^127. There is no representable +2^127, so several operations abort when called on min():
abs(min())— aborts withEOverflow.negate(min())— aborts withEOverflow.wrap(2^127, true)is fine (it producesmin); butwrap(2^127, false)aborts withEOverflow. There is no way to constructminviawrap; usemin()directly.
Most protocol code never sees min(). If your design naturally produces values near the signed-integer boundary, plan for it explicitly:
use openzeppelin_fp_math::sd29x9;
public fun safe_negate(x: sd29x9::SD29x9): sd29x9::SD29x9 {
if (x.eq(sd29x9::min())) {
// Domain decision: clamp, abort with a domain error, or saturate to max.
sd29x9::max()
} else {
x.negate()
}
}mod vs rem on SD29x9
UD30x9::mod is straightforward: it is the unsigned remainder, always non-negative. SD29x9 exposes two remainder functions because the signed semantics differ:
rem(x, y)— truncating remainder. The magnitude isabs(x) % abs(y), and the sign of the result follows the dividendx.rem(-7, 3) = -1.mod(x, y)— Euclidean remainder. Always non-negative; satisfies0 <= mod(x, y) < abs(y).mod(-7, 3) = 2.
Most protocol code wants mod (Euclidean), because it gives a stable representative of the residue class regardless of the dividend's sign. rem is the right choice when you specifically want the remainder to follow the sign of the dividend (e.g., to preserve sign in a pipeline of signed adjustments).
use openzeppelin_fp_math::sd29x9_convert;
let dividend = sd29x9_convert::from_u128(7, true); // -7.0
let divisor = sd29x9_convert::from_u128(3, false); // 3.0
let r = dividend.rem(divisor); // -1.0
let m = dividend.mod(divisor); // 2.0Both abort with EDivideByZero if y is zero.
Putting it together
Here is a pricing module example that combines several functions from the package:
module my_sui_app::pricing;
use openzeppelin_fp_math::{ud30x9, ud30x9_convert};
use openzeppelin_fp_math::ud30x9::UD30x9;
const ONE_HUNDRED_PERCENT_BPS: u128 = 10_000;
/// Apply a fee rate (in basis points) to an amount. Round away from zero
/// so the protocol never undercharges.
public fun apply_fee_bps(amount: UD30x9, fee_bps: u128): UD30x9 {
let bps_one = ud30x9_convert::from_u128(ONE_HUNDRED_PERCENT_BPS);
let fee = ud30x9_convert::from_u128(fee_bps);
amount.mul_away(fee).div_away(bps_one)
}
/// Quote a constant-product swap output, less a fee.
public fun quote_swap_with_fee(
amount_in: UD30x9,
reserve_in: UD30x9,
reserve_out: UD30x9,
fee_bps: u128,
): UD30x9 {
let fee = apply_fee_bps(amount_in, fee_bps);
let effective_in = amount_in.sub(fee);
// Constant-product output, rounded toward zero (in the user's favor as the
// taker, against the user as a fee recipient — model your protocol explicitly).
reserve_out.mul_trunc(effective_in).div_trunc(reserve_in.add(effective_in))
}
/// Settle a `UD30x9` quote into a whole `u64` token amount.
public fun settle_to_u64(amount: UD30x9): u64 {
amount.to_u64_trunc()
}And the same pattern with signed deltas:
module my_sui_app::ledger;
use openzeppelin_fp_math::{sd29x9, sd29x9_convert};
use openzeppelin_fp_math::sd29x9::SD29x9;
/// Apply a signed adjustment to a running balance.
public fun apply_delta(balance: SD29x9, magnitude: u128, is_negative: bool): SD29x9 {
let delta = sd29x9_convert::from_u128(magnitude, is_negative);
balance.add(delta)
}
/// Read out the absolute value as a positive UD30x9.
public fun absolute(balance: SD29x9): ud30x9::UD30x9 {
balance.abs().into_UD30x9()
}Build and test:
sui move build
sui move testAPI Reference
For function-level signatures and parameters, see the Fixed-Point Math API reference.
Next steps
- Fixed-Point Math API reference for full function signatures
- Integer Math for integer arithmetic primitives
- Access for ownership-transfer policies on privileged capabilities
- GitHub issue tracker to report bugs or request features
- Sui Discord and Sui Developer Forum to connect with other builders