Files
zeekayandHanzo Dev 1baa22bdba read prices through Source, and the catalogue separately from the tables
collections.Source is the bank's single price source. Three call sites read
the reference tables directly instead, and all three are fixed.

handleCryptoPrices read collections.CryptoUSD. With DEX_URL set, the route
reported the reference constants while conversions used the venue's marks,
so the price displayed and the price charged differed. It reads Source now,
and omits an asset Source cannot price rather than reporting it at 0, which
would render as a free asset.

supportedAsset decided whether the bank offers a currency from the fiat rate
table, and IsCrypto decided whether an asset is crypto from the crypto price
table. Which assets the bank carries and what they are worth are separate
facts. The catalogue is now collections.FiatAssets and CryptoAssets, and the
tables are keyed by them. The IsCrypto case had a measurable consequence:
an asset in the catalogue with no table constant fell through to fiat's two
decimal places instead of six, making every minor-unit amount in that asset
wrong by a factor of 10^4.

Both tests were verified by reintroducing the defect they cover. Restoring
the table read reports LUX at 12.50 against a source giving 1.25; restoring
the table lookup drops an uncosted catalogue asset to two decimals.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-28 16:00:37 -07:00

133 lines
4.6 KiB
Go

package collections
import (
"math"
"slices"
"testing"
)
func TestIsCrypto(t *testing.T) {
for _, cur := range []string{"LUX", "BTC", "ETH", "DAI", "lux", "btc", "Eth"} {
if !IsCrypto(cur) {
t.Errorf("IsCrypto(%q) = false", cur)
}
}
for _, cur := range []string{"USD", "EUR", "JPY", "", "XXX", "BTCC"} {
if IsCrypto(cur) {
t.Errorf("IsCrypto(%q) = true", cur)
}
}
}
// A carried asset keeps its own precision whether or not the reference tables
// hold a constant for it.
//
// IsCrypto read CryptoUSD, so the catalogue and the price table had to agree.
// An asset in one but not the other took fiat's two decimal places instead of
// six, making every minor-unit amount in it wrong by a factor of 10^4.
func TestTheCatalogueIsNotThePriceTable(t *testing.T) {
was := CryptoAssets
CryptoAssets = append(slices.Clone(CryptoAssets), "ZOO")
t.Cleanup(func() { CryptoAssets = was })
if _, priced := CryptoUSD["ZOO"]; priced {
t.Fatal("the tables carry ZOO, so this proves nothing — pick an asset they do not")
}
if !IsCrypto("ZOO") {
t.Error("an asset in the catalogue is not crypto because no table prices it")
}
if got := DecimalsFor("ZOO"); got != CryptoDecimals {
t.Errorf("ZOO takes %d decimals, not %d", got, CryptoDecimals)
}
// And it is still unpriceable, which is the refusal the bank already makes.
if CanPrice("ZOO") {
t.Error("an asset no source prices reports a price")
}
}
func TestDecimalsFor(t *testing.T) {
for cur, want := range map[string]int{
"BTC": 6, "LUX": 6, "eth": 6, "DAI": 6, // crypto is fixed-point 6dp
"USD": 2, "EUR": 2, "gbp": 2, // ordinary fiat
"JPY": 0, "KRW": 0, "jpy": 0, // zero-decimal fiat
"XXX": 2, "": 2, // unknown falls to the common case
} {
if got := DecimalsFor(cur); got != want {
t.Errorf("DecimalsFor(%q) = %d, want %d", cur, got, want)
}
}
}
func TestUnitPriceUSD(t *testing.T) {
if got := UnitPriceUSD("BTC"); got != 64000.0 {
t.Errorf("UnitPriceUSD(BTC) = %v", got)
}
if got := UnitPriceUSD("usd"); got != 1.0 {
t.Errorf("UnitPriceUSD(usd) = %v, want 1", got)
}
// Fiat is quoted as units-per-USD, so the price of one unit is its inverse.
if got, want := UnitPriceUSD("EUR"), 1.0/0.92; math.Abs(got-want) > 1e-12 {
t.Errorf("UnitPriceUSD(EUR) = %v, want %v", got, want)
}
if got := UnitPriceUSD("XXX"); got != 0 {
t.Errorf("UnitPriceUSD(XXX) = %v, want 0", got)
}
}
func TestMinorToUSDAndCents(t *testing.T) {
// 1.00 USD in cents.
if got := MinorToUSD(100, "USD"); got != 1.0 {
t.Errorf("MinorToUSD(100, USD) = %v", got)
}
// 1 BTC is 1_000_000 minor units at 6dp.
if got := MinorToUSD(1_000_000, "BTC"); got != 64000.0 {
t.Errorf("MinorToUSD(1e6, BTC) = %v", got)
}
if got := USDCents(1_000_000, "BTC"); got != 6_400_000 {
t.Errorf("USDCents(1e6, BTC) = %d, want 6400000", got)
}
// JPY carries no decimals: 1000 JPY is 1000 yen, not 10.
if got, want := USDCents(1000, "JPY"), int64(math.Round(1000.0/157.0*100)); got != want {
t.Errorf("USDCents(1000, JPY) = %d, want %d", got, want)
}
if got := USDCents(0, "USD"); got != 0 {
t.Errorf("USDCents(0, USD) = %d", got)
}
}
// USDCents answers 0 for a currency it cannot price, and 0 clears every
// USD-denominated ceiling — below the AML threshold, never over a daily limit,
// and adding nothing to the running total it should have consumed. The currency
// field is an unconstrained three-letter string, so this was reachable by typing
// one: "KRW" is named by DecimalsFor while PerUSD carries no rate for it, and
// "ZZZ" is accepted just as readily.
//
// The number itself cannot carry the distinction, so CanPrice does. Callers
// enforcing a limit ask it first; hooks/accounts refuses the transaction and
// hooks/compliance screens it rather than valuing it at nothing.
func TestCanPriceSeparatesZeroFromUnpriceable(t *testing.T) {
for _, cur := range []string{"USD", "EUR", "JPY", "btc", "LUX", "dai"} {
if !CanPrice(cur) {
t.Errorf("CanPrice(%q) = false, but it has a reference price", cur)
}
}
for _, cur := range []string{"KRW", "ZZZ", "XXX", "", "krw"} {
if CanPrice(cur) {
t.Errorf("CanPrice(%q) = true, but nothing prices it", cur)
}
}
// The trap this exists to close: an unpriceable currency is worth 0 cents,
// and a genuine zero is worth 0 cents too. Only CanPrice tells them apart.
const huge = 1_000_000_000_000
if got := USDCents(huge, "KRW"); got != 0 {
t.Fatalf("USDCents(huge, KRW) = %d — the premise changed, revisit the gates", got)
}
if USDCents(0, "USD") != USDCents(huge, "KRW") {
t.Fatal("the two are meant to be indistinguishable by value alone")
}
if !CanPrice("USD") || CanPrice("KRW") {
t.Fatal("CanPrice must be what separates them")
}
}