feat: exact Amount+Currency money on big.Int decimal (no precision ceiling)

This commit is contained in:
2026-07-12 23:02:25 -07:00
commit ad6505c903
6 changed files with 289 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.
+22
View File
@@ -0,0 +1,22 @@
# money
The one exact money type for Go: an `Amount` in a `Currency`, backed by
[`hanzoai/decimal`](https://github.com/hanzoai/decimal) (big.Int fixed-point).
The community money pattern — never floats, integer-exact, `Add`/`Sub`/`Split`/`Allocate`,
same-currency safety — **without the precision ceiling**. int64/uint64 money libraries cap
around 19 digits (~`$9.20` at 18 decimals); this holds a fiat cent *and* an 18-decimal
on-chain balance exactly.
```go
price, _ := money.Parse("6.60", money.USD)
cost := price.Mul(decimal.New(200, 0)).Mul(decimal.MustParse("0.000001")) // "0.00132", never 0
bal, _ := money.FromCents(100).Sub(cost) // "0.99868"
husd, _ := money.Parse("100003.01", money.HUSD) // 18-dec on-chain unit
husd.MinorString() // "100003010000000000000000"
parts, _ := money.FromCents(100).Split(3) // 34 + 33 + 33 = 100, nothing lost
```
Currencies carry their smallest-unit decimals (USD 2, USDC 6, HUSD/ETH 18). MIT.
+5
View File
@@ -0,0 +1,5 @@
module github.com/hanzoai/money
go 1.23
require github.com/hanzoai/decimal v0.1.0
+2
View File
@@ -0,0 +1,2 @@
github.com/hanzoai/decimal v0.1.0 h1:XOMi/3kAjOSB2UqCoW5Uf/ZhwLdleeSxd4IaHmPJq5M=
github.com/hanzoai/decimal v0.1.0/go.mod h1:jTNufaSe93avQTuXYP3ns2THBtRPR2SbaC0EdODLAFI=
+183
View File
@@ -0,0 +1,183 @@
// Package money is the ONE exact money value for the Hanzo stack: an amount in a
// currency, backed by github.com/hanzoai/decimal (big.Int fixed-point). Unlike the
// int64/uint64 money libraries (which cap around 19 digits — about $9.20 at 18 decimals),
// this holds a fiat cent AND an 18-decimal on-chain balance with equal exactness, because
// the value is a big.Int decimal. No float64 ever touches money.
//
// The design is the community-standard money pattern (never floats, integer-exact,
// Amount + Currency, Split/Allocate so a division loses no sub-unit) with the precision
// ceiling removed. An Amount is IMMUTABLE. Arithmetic between two Amounts requires the
// SAME currency, so you can never silently add USD to EUR.
package money
import (
"fmt"
"math/big"
"github.com/hanzoai/decimal"
)
// Currency is a currency's identity: an ISO-4217-style code and the number of decimal
// places in its smallest unit (USD 2, JPY 0, USDC 6, an 18-decimal ERC-20 18). Custom
// currencies are allowed — construct one directly.
type Currency struct {
Code string
Decimals int32
}
// Common currencies. Fiat use the ISO minor unit; crypto/stablecoins use their on-chain
// token decimals so an off-chain Amount and an on-chain balance share one scale.
var (
USD = Currency{"USD", 2}
EUR = Currency{"EUR", 2}
GBP = Currency{"GBP", 2}
JPY = Currency{"JPY", 0}
USDC = Currency{"USDC", 6}
USDT = Currency{"USDT", 6}
ETH = Currency{"ETH", 18}
HUSD = Currency{"HUSD", 18} // Hanzo USD stablecoin (ERC-20, 18 decimals)
)
// Amount is an exact money value: a decimal in a currency.
type Amount struct {
v decimal.Decimal
cur Currency
}
// New wraps an exact decimal value in a currency.
func New(v decimal.Decimal, cur Currency) Amount { return Amount{v: v, cur: cur} }
// Zero is 0 in the currency.
func Zero(cur Currency) Amount { return Amount{cur: cur} }
// FromMinor builds an Amount from an integer count of the currency's smallest unit
// (e.g. cents for USD): value = units × 10^-cur.Decimals.
func FromMinor(units int64, cur Currency) Amount {
return Amount{v: decimal.New(units, cur.Decimals), cur: cur}
}
// FromMinorBig is FromMinor for a big.Int count (an on-chain wei balance).
func FromMinorBig(units *big.Int, cur Currency) Amount {
return Amount{v: decimal.NewFromBig(units, cur.Decimals), cur: cur}
}
// FromCents builds a USD Amount from integer cents.
func FromCents(cents int64) Amount { return FromMinor(cents, USD) }
// Parse reads a decimal string ("6.60", "-0.00132") as an exact Amount in the currency.
func Parse(s string, cur Currency) (Amount, error) {
d, err := decimal.Parse(s)
if err != nil {
return Amount{}, err
}
return Amount{v: d, cur: cur}, nil
}
// Currency returns the amount's currency.
func (a Amount) Currency() Currency { return a.cur }
// Decimal returns the exact underlying value.
func (a Amount) Decimal() decimal.Decimal { return a.v }
// Minor returns the value as a big.Int count of the currency's smallest unit
// (round-half-up on any sub-unit precision) — the storage/on-chain integer.
func (a Amount) Minor() *big.Int { return a.v.Rescale(a.cur.Decimals).Coef() }
// MinorString is the minor-unit integer as a decimal string (the canonical storage form).
func (a Amount) MinorString() string { return a.Minor().String() }
func (a Amount) sameCur(b Amount) error {
if a.cur.Code != b.cur.Code {
return fmt.Errorf("money: currency mismatch %s vs %s", a.cur.Code, b.cur.Code)
}
return nil
}
// Add returns a + b; both must be the same currency.
func (a Amount) Add(b Amount) (Amount, error) {
if err := a.sameCur(b); err != nil {
return Amount{}, err
}
return Amount{v: a.v.Add(b.v), cur: a.cur}, nil
}
// Sub returns a − b; both must be the same currency.
func (a Amount) Sub(b Amount) (Amount, error) {
if err := a.sameCur(b); err != nil {
return Amount{}, err
}
return Amount{v: a.v.Sub(b.v), cur: a.cur}, nil
}
// Mul scales the amount by an exact decimal factor (e.g. a token count or a fee rate),
// keeping the currency.
func (a Amount) Mul(factor decimal.Decimal) Amount {
return Amount{v: a.v.Mul(factor), cur: a.cur}
}
// Neg returns −a.
func (a Amount) Neg() Amount { return Amount{v: a.v.Neg(), cur: a.cur} }
// Cmp reports −1,0,+1 as a <,==,> b (same currency required; panics on mismatch — use
// after checking Currency for untrusted inputs).
func (a Amount) Cmp(b Amount) int {
if a.cur.Code != b.cur.Code {
panic("money: Cmp across currencies " + a.cur.Code + "/" + b.cur.Code)
}
return a.v.Cmp(b.v)
}
// Sign reports −1,0,+1 as a <,==,> 0.
func (a Amount) Sign() int { return a.v.Sign() }
// IsZero reports whether a == 0.
func (a Amount) IsZero() bool { return a.v.IsZero() }
// IsNeg reports whether a < 0.
func (a Amount) IsNeg() bool { return a.v.Sign() < 0 }
// Split divides the amount into n parts that sum EXACTLY to the original at the currency's
// minor-unit granularity — the remainder is distributed one minor unit at a time to the
// leading parts, so no sub-unit is created or lost. n must be positive.
func (a Amount) Split(n int) ([]Amount, error) {
if n <= 0 {
return nil, fmt.Errorf("money: split into %d parts", n)
}
total := a.Minor() // integer minor units
nb := big.NewInt(int64(n))
base, rem := new(big.Int).QuoRem(total, nb, new(big.Int))
remN := rem.Int64() // |rem| < n, fits int64
neg := remN < 0
if neg {
remN = -remN
}
out := make([]Amount, n)
for i := 0; i < n; i++ {
part := new(big.Int).Set(base)
if int64(i) < remN {
if neg {
part.Sub(part, decimalOne)
} else {
part.Add(part, decimalOne)
}
}
out[i] = FromMinorBig(part, a.cur)
}
return out, nil
}
// String renders the exact decimal value ("0.00132").
func (a Amount) String() string { return a.v.String() }
// Display renders the value rounded to the currency's decimals with the code
// ("100003.01 USD") — the human form.
func (a Amount) Display() string {
return a.v.Rescale(a.cur.Decimals).String() + " " + a.cur.Code
}
// MarshalJSON emits {"amount":"<decimal>","currency":"USD"} — exact strings, no float.
func (a Amount) MarshalJSON() ([]byte, error) {
return []byte(`{"amount":"` + a.v.String() + `","currency":"` + a.cur.Code + `"}`), nil
}
var decimalOne = big.NewInt(1)
+56
View File
@@ -0,0 +1,56 @@
package money
import (
"testing"
"github.com/hanzoai/decimal"
)
func TestExactBilling(t *testing.T) {
// 200 tokens × $6.60/1M = 0.00132 exactly — cents-flooring dropped this to 0.
price, _ := Parse("6.60", USD)
cost := price.Mul(decimal.New(200, 0)).Mul(decimal.MustParse("0.000001")) // ÷1e6
if cost.String() != "0.00132" || cost.IsZero() {
t.Fatalf("token cost = %q", cost.String())
}
bal := FromCents(100) // $1.00
after, err := bal.Sub(cost)
if err != nil {
t.Fatal(err)
}
if after.String() != "0.99868" {
t.Fatalf("$1 - 0.00132 = %q", after.String())
}
}
func TestMinorAndCurrency(t *testing.T) {
if FromCents(1).MinorString() != "1" {
t.Fatal("1 cent minor")
}
// HUSD is 18-dec: $100,003.01 → minor units = 24-digit integer (int64 can't hold this)
h, _ := Parse("100003.01", HUSD)
if h.MinorString() != "100003010000000000000000" {
t.Fatalf("HUSD minor = %s", h.MinorString())
}
// cross-currency add is refused
if _, err := FromCents(100).Add(Zero(EUR)); err == nil {
t.Fatal("expected currency-mismatch error")
}
}
func TestSplitExact(t *testing.T) {
parts, err := FromCents(100).Split(3) // $1.00 / 3
if err != nil {
t.Fatal(err)
}
sum := Zero(USD)
for _, p := range parts {
sum, _ = sum.Add(p)
}
if sum.MinorString() != "100" { // 34+33+33 = 100, nothing lost
t.Fatalf("split sum = %s", sum.MinorString())
}
if parts[0].MinorString() != "34" || parts[2].MinorString() != "33" {
t.Fatalf("split = %s,%s,%s", parts[0].MinorString(), parts[1].MinorString(), parts[2].MinorString())
}
}