Join our community of builders on

Telegram!Telegram
Packages

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.5 is 1_500_000_000, no binary fixed-point surprises.
  • Fits in u128, keeping storage and arithmetic light compared to u256-based decimal types.

The two types

  • UD30x9 — unsigned. Decimal range from 0 to roughly 3.4 × 10^29.
  • SD29x9 — signed (two's complement). Decimal range from roughly -1.7 × 10^29 to 1.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 to mul_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 modulo 2^128 instead 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 prefer add / 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:

  1. 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.
  2. Bit-shift arithmetic puts the rounding decision in the operator instead of the call site. A >> is silent. A mul_div-shaped helper makes the rounding direction visible. The Cetus exploit hinged on a checked_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.000000042

Cross-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 UD30x9

Use 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 == true

Use 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 42 means 42, use *_convert::from_u128 / to_u*_trunc. If 42 means 0.000_000_042, use wrap / 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 == true

Two consequences worth knowing:

  1. to_parts_trunc always reports is_negative = false when the magnitude is zero, regardless of the underlying bit pattern. This avoids "negative zero" surprises in callers that branch on the flag.
  2. to_u128_trunc and to_u64_trunc abort on negative input. Use the try_* variants when you want a none() instead, or call to_parts_trunc to 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 pairRoundingWhen to use
mul, mul_trunctoward zeroDefault. The two are aliases — pick mul_trunc if you want the rounding direction visible at the call site.
mul_awayaway from zeroFees, slippage caps, anywhere the protocol should never undercharge or under-reserve.
div, div_trunctoward zeroDefault for division. Same alias relationship as mul.
div_awayaway from zeroWhen 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 to UD30x9::sub only. Subtraction would produce a negative value, which is unrepresentable in an unsigned type.
  • EDivideByZero — applies to div, div_trunc, div_away, mod, and rem.

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 abort

Reach 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); // true

SD29x9::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:

  1. lshift aborts when shifting by >= 128 bits or when the shift would consume non-zero high bits. unchecked_lshift truncates instead of aborting and returns 0 for bits >= 128.
  2. The bitwise operators work on raw bits, not on the decimal value. x.lshift(1) doubles the underlying u128, but it does not double the decimal value in any meaningful sense (you'd want x.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 exp grows.
  • For 0 < x < 1 (and the analogous range in SD29x9), 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 with EOverflow.
  • negate(min()) — aborts with EOverflow.
  • wrap(2^127, true) is fine (it produces min); but wrap(2^127, false) aborts with EOverflow. There is no way to construct min via wrap; use min() 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 is abs(x) % abs(y), and the sign of the result follows the dividend x. rem(-7, 3) = -1.
  • mod(x, y) — Euclidean remainder. Always non-negative; satisfies 0 <= 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.0

Both 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 test

API Reference

For function-level signatures and parameters, see the Fixed-Point Math API reference.

Next steps