feat: exact big.Int fixed-point decimal (no precision ceiling, no float)

This commit is contained in:
2026-07-12 23:02:00 -07:00
commit 50f1238717
5 changed files with 399 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Hanzo AI, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+17
View File
@@ -0,0 +1,17 @@
# decimal
Exact, arbitrary-precision fixed-point decimal for Go. A value is `coefficient × 10⁻ˢᶜᵃˡᵉ`
with a **big.Int coefficient** — no precision ceiling, no float, immutable.
Unlike int64/uint64 decimal & money libraries (which cap around 19 digits — about `$9.20`
at 18 decimals), this holds a fiat cent and an 18-decimal on-chain wei balance with equal
exactness. `0.1 + 0.2` is exactly `0.3`.
```go
d := decimal.MustParse("6.60")
cost := decimal.New(200, 0).Mul(d).Quo(decimal.New(1_000_000, 0), 18) // 200 tokens @ $6.60/1M
cost.String() // "0.00132" (never floored to 0)
```
The numeric core beneath [`hanzoai/money`](https://github.com/hanzoai/money). Stdlib only.
MIT.
+290
View File
@@ -0,0 +1,290 @@
// Package decimal is an exact, arbitrary-precision fixed-point decimal number.
//
// A value is coefficient × 10^-scale, where the coefficient is a big.Int and the scale
// is a non-negative number of fractional digits. Because the coefficient is a big.Int
// there is NO precision ceiling — unlike int64/uint64 money types (which cap around
// 19 digits, ~$9.20 at 18 decimals), a Decimal holds a fiat cent and an 18-decimal
// on-chain wei balance with equal exactness. No float64 ever touches a value, so
// arithmetic is exact and reproducible; 0.1 + 0.2 is exactly 0.3.
//
// A Decimal is IMMUTABLE: every operation returns a new value and the wrapped big.Int is
// never mutated after construction, so a Decimal is a safe value object to copy, compare
// and share. The zero value is a valid 0 (scale 0).
//
// This is the numeric core beneath github.com/hanzoai/money — the ONE exact-number
// definition for the Hanzo stack. It is a leaf: stdlib only, no money/currency concern.
package decimal
import (
"fmt"
"math/big"
"strings"
)
// Decimal is an exact fixed-point number: Coef × 10^-Scale.
type Decimal struct {
coef *big.Int // signed coefficient; nil == 0. Never mutated after construction.
scale int32 // number of fractional digits; always >= 0.
}
var (
bigTen = big.NewInt(10)
bigOne = big.NewInt(1)
bigTwo = big.NewInt(2)
)
func pow10(n int32) *big.Int {
if n <= 0 {
return new(big.Int).Set(bigOne)
}
return new(big.Int).Exp(bigTen, big.NewInt(int64(n)), nil)
}
// Zero is the additive identity (0 at scale 0).
func Zero() Decimal { return Decimal{} }
// bi returns a non-nil, non-aliased big.Int of the coefficient (safe to mutate).
func (d Decimal) bi() *big.Int {
if d.coef == nil {
return new(big.Int)
}
return new(big.Int).Set(d.coef)
}
// New returns coef × 10^-scale. A negative scale is clamped to 0 (scale is fractional
// digits; a "negative scale" is folded into the coefficient).
func New(coef int64, scale int32) Decimal {
return NewFromBig(big.NewInt(coef), scale)
}
// NewFromBig returns coef × 10^-scale, copying coef. Negative scale multiplies the
// coefficient up so the stored scale stays >= 0.
func NewFromBig(coef *big.Int, scale int32) Decimal {
c := new(big.Int)
if coef != nil {
c.Set(coef)
}
if scale < 0 {
c.Mul(c, pow10(-scale))
scale = 0
}
return Decimal{coef: c, scale: scale}
}
// Parse reads a decimal string ("123", "-0.00132", "+6.60") EXACTLY — no float. The
// scale is the number of fractional digits written (so "6.60" has scale 2). An empty
// string is 0.
func Parse(s string) (Decimal, error) {
s = strings.TrimSpace(s)
if s == "" {
return Decimal{}, nil
}
neg := false
switch s[0] {
case '+':
s = s[1:]
case '-':
neg, s = true, s[1:]
}
intPart, fracPart := s, ""
if i := strings.IndexByte(s, '.'); i >= 0 {
intPart, fracPart = s[:i], s[i+1:]
}
if intPart == "" {
intPart = "0"
}
digits := intPart + fracPart
if digits == "" {
return Decimal{}, fmt.Errorf("decimal: invalid %q", s)
}
c, ok := new(big.Int).SetString(digits, 10)
if !ok {
return Decimal{}, fmt.Errorf("decimal: invalid %q", s)
}
if neg {
c.Neg(c)
}
return Decimal{coef: c, scale: int32(len(fracPart))}, nil
}
// MustParse is Parse that panics on error — for constants/tests.
func MustParse(s string) Decimal {
d, err := Parse(s)
if err != nil {
panic(err)
}
return d
}
// Scale reports the number of fractional digits.
func (d Decimal) Scale() int32 { return d.scale }
// Coef returns a fresh big.Int of the coefficient (the value is Coef × 10^-Scale).
func (d Decimal) Coef() *big.Int { return d.bi() }
// rescaled returns the coefficient scaled to target (target >= d.scale, so exact — no
// rounding). Used to align two operands before add/compare.
func (d Decimal) coefAt(target int32) *big.Int {
c := d.bi()
if target > d.scale {
c.Mul(c, pow10(target-d.scale))
}
return c
}
// Rescale returns the value at exactly `scale` fractional digits. Increasing the scale is
// exact; decreasing rounds half-away-from-zero (banker's rounding is not used — money
// convention is half-up on magnitude).
func (d Decimal) Rescale(scale int32) Decimal {
if scale < 0 {
scale = 0
}
if scale >= d.scale {
return Decimal{coef: d.coefAt(scale), scale: scale}
}
// reduce: divide by 10^(d.scale-scale), round half-away-from-zero.
div := pow10(d.scale - scale)
c := d.bi()
neg := c.Sign() < 0
if neg {
c.Neg(c)
}
q, r := new(big.Int).QuoRem(c, div, new(big.Int))
if new(big.Int).Mul(r, bigTwo).Cmp(div) >= 0 {
q.Add(q, bigOne)
}
if neg {
q.Neg(q)
}
return Decimal{coef: q, scale: scale}
}
// Round rounds to `scale` fractional digits (alias of Rescale for reducing scale; pads
// when increasing).
func (d Decimal) Round(scale int32) Decimal { return d.Rescale(scale) }
// Add returns d + b, at the finer of the two scales (exact).
func (d Decimal) Add(b Decimal) Decimal {
s := max32(d.scale, b.scale)
return Decimal{coef: new(big.Int).Add(d.coefAt(s), b.coefAt(s)), scale: s}
}
// Sub returns d − b, at the finer of the two scales (exact).
func (d Decimal) Sub(b Decimal) Decimal {
s := max32(d.scale, b.scale)
return Decimal{coef: new(big.Int).Sub(d.coefAt(s), b.coefAt(s)), scale: s}
}
// Mul returns d × b EXACTLY; the result scale is the sum of the operand scales.
func (d Decimal) Mul(b Decimal) Decimal {
return Decimal{coef: new(big.Int).Mul(d.bi(), b.bi()), scale: d.scale + b.scale}
}
// Quo returns d ÷ b rounded to `scale` fractional digits (half-away-from-zero). Panics on
// divide-by-zero (a programming error, like integer division).
func (d Decimal) Quo(b Decimal, scale int32) Decimal {
if b.IsZero() {
panic("decimal: divide by zero")
}
if scale < 0 {
scale = 0
}
// (d.coef × 10^(scale + b.scale)) / (b.coef × 10^d.scale), rounded half-up.
num := d.bi()
num.Mul(num, pow10(scale+b.scale))
den := b.bi()
den.Mul(den, pow10(d.scale))
neg := num.Sign() < 0 != (den.Sign() < 0)
num.Abs(num)
den.Abs(den)
q, r := new(big.Int).QuoRem(num, den, new(big.Int))
if new(big.Int).Mul(r, bigTwo).Cmp(den) >= 0 {
q.Add(q, bigOne)
}
if neg {
q.Neg(q)
}
return Decimal{coef: q, scale: scale}
}
// Neg returns −d.
func (d Decimal) Neg() Decimal { return Decimal{coef: new(big.Int).Neg(d.bi()), scale: d.scale} }
// Abs returns |d|.
func (d Decimal) Abs() Decimal { return Decimal{coef: new(big.Int).Abs(d.bi()), scale: d.scale} }
// Cmp reports −1, 0, +1 as d <, ==, > b (scale-aligned).
func (d Decimal) Cmp(b Decimal) int {
s := max32(d.scale, b.scale)
return d.coefAt(s).Cmp(b.coefAt(s))
}
// Sign reports −1, 0, +1 as d <, ==, > 0.
func (d Decimal) Sign() int {
if d.coef == nil {
return 0
}
return d.coef.Sign()
}
// IsZero reports whether d == 0.
func (d Decimal) IsZero() bool { return d.Sign() == 0 }
// Equal reports whether d and b are numerically equal (scale-independent).
func (d Decimal) Equal(b Decimal) bool { return d.Cmp(b) == 0 }
// String renders the exact decimal ("6.6", "0.00132", "-0.5", "0"). Trailing fractional
// zeros implied by the scale are kept only up to the scale, then trimmed.
func (d Decimal) String() string {
c := d.bi()
neg := c.Sign() < 0
if neg {
c.Neg(c)
}
if d.scale <= 0 {
out := c.String()
if neg {
out = "-" + out
}
return out
}
div := pow10(d.scale)
q, r := new(big.Int).QuoRem(c, div, new(big.Int))
out := q.String()
if r.Sign() != 0 {
frac := fmt.Sprintf("%0*s", d.scale, r.String())
frac = strings.TrimRight(frac, "0")
if frac != "" {
out += "." + frac
}
}
if neg {
out = "-" + out
}
return out
}
// MarshalJSON emits the exact decimal string — a STRING, never a JSON number.
func (d Decimal) MarshalJSON() ([]byte, error) { return []byte(`"` + d.String() + `"`), nil }
// UnmarshalJSON accepts a quoted decimal string or a bare number, parsed exactly.
func (d *Decimal) UnmarshalJSON(b []byte) error {
s := strings.Trim(strings.TrimSpace(string(b)), `"`)
if s == "null" || s == "" {
*d = Decimal{}
return nil
}
p, err := Parse(s)
if err != nil {
return err
}
*d = p
return nil
}
func max32(a, b int32) int32 {
if a > b {
return a
}
return b
}
+68
View File
@@ -0,0 +1,68 @@
package decimal
import "testing"
func TestParseString(t *testing.T) {
for _, s := range []string{"0", "6.6", "0.00132", "-0.5", "100", "-123.456", "1000000"} {
if got := MustParse(s).String(); got != s {
t.Errorf("Parse/String(%q) = %q", s, got)
}
}
if MustParse("6.60").String() != "6.6" { // trailing zero trimmed
t.Errorf("6.60 should print 6.6")
}
}
func TestNoFloat(t *testing.T) {
// the canonical float bug: 0.1 + 0.2 != 0.3 in float64; exact here.
if got := MustParse("0.1").Add(MustParse("0.2")).String(); got != "0.3" {
t.Fatalf("0.1 + 0.2 = %q, want 0.3", got)
}
}
func TestArithmetic(t *testing.T) {
if got := MustParse("1").Add(MustParse("0.00132")).String(); got != "1.00132" {
t.Errorf("add = %q", got)
}
if got := MustParse("6.60").Sub(MustParse("0.60")).String(); got != "6" {
t.Errorf("sub = %q", got)
}
// 200 tokens × $6.60/1M = 200 × 6.60 / 1e6 = 0.00132 exactly (the anti-cents-flooring case)
price := MustParse("6.60")
perM := MustParse("1000000")
if got := New(200, 0).Mul(price).Quo(perM, 18).String(); got != "0.00132" {
t.Fatalf("token cost = %q, want 0.00132", got)
}
if MustParse("5").Cmp(MustParse("5.0")) != 0 || MustParse("5").Cmp(MustParse("5.01")) != -1 {
t.Errorf("cmp")
}
}
func TestRounding(t *testing.T) {
if got := MustParse("1.455").Round(2).String(); got != "1.46" { // half-up
t.Errorf("round = %q", got)
}
if got := MustParse("-1.455").Round(2).String(); got != "-1.46" { // away from zero
t.Errorf("round neg = %q", got)
}
if got := MustParse("1.5").Round(0).String(); got != "2" {
t.Errorf("round to int = %q", got)
}
}
func TestBigPrecisionNoCeiling(t *testing.T) {
// The whole point: an 18-decimal on-chain balance that OVERFLOWS int64/uint64 money
// types. $100,003.01 in 18-dec wei = 100003010000000000000000 (24 digits).
wei := MustParse("100003.01").Rescale(18)
if got := wei.Coef().String(); got != "100003010000000000000000" {
t.Fatalf("18-dec coef = %s", got)
}
if got := wei.String(); got != "100003.01" {
t.Fatalf("round-trip = %q", got)
}
// arithmetic stays exact at that magnitude
sum := wei.Add(MustParse("0.000000000000000001")) // + 1 wei
if sum.String() != "100003.010000000000000001" {
t.Fatalf("wei add = %q", sum.String())
}
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/hanzoai/decimal
go 1.23