mirror of
https://github.com/hanzoai/decimal.git
synced 2026-09-30 21:40:38 +00:00
feat: exact big.Int fixed-point decimal (no precision ceiling, no float)
This commit is contained in:
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user