mirror of
https://github.com/hanzoai/money.git
synced 2026-09-30 22:56:51 +00:00
feat: exact Amount+Currency money on big.Int decimal (no precision ceiling)
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,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.
|
||||
@@ -0,0 +1,5 @@
|
||||
module github.com/hanzoai/money
|
||||
|
||||
go 1.23
|
||||
|
||||
require github.com/hanzoai/decimal v0.1.0
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user