From c8380129791e9f59ed0106872fec849d1eeec39e Mon Sep 17 00:00:00 2001 From: Konstantin Ullrich Date: Mon, 24 Aug 2026 14:48:32 +0200 Subject: [PATCH] Enhance Money class with precision handling and zero trimming (#3548) * feat: enhance Money class with precision handling, scale alignment, and trailing zero trimming --- cw_core/lib/amount/exchange_rate.dart | 28 ++- cw_core/lib/amount/money.dart | 157 ++++++++---- cw_core/lib/amount/money_double.dart | 15 +- cw_core/lib/amount/utils.dart | 13 + cw_core/lib/parse_fixed.dart | 4 +- cw_core/test/amount/exchange_rate_test.dart | 250 ++++++++++++++++---- cw_core/test/amount/money_test.dart | 123 +++++++++- cw_core/test/amount/utils.dart | 41 ++++ 8 files changed, 522 insertions(+), 109 deletions(-) create mode 100644 cw_core/lib/amount/utils.dart create mode 100644 cw_core/test/amount/utils.dart diff --git a/cw_core/lib/amount/exchange_rate.dart b/cw_core/lib/amount/exchange_rate.dart index efcb06abd..ce3f31325 100644 --- a/cw_core/lib/amount/exchange_rate.dart +++ b/cw_core/lib/amount/exchange_rate.dart @@ -2,6 +2,8 @@ import 'package:cw_core/amount/money.dart'; import 'package:cw_core/currency.dart'; class ExchangeRate { + const ExchangeRate({required this.base, required this.quote}); + /// The currency being priced (e.g. BTC in a BTC/USD pair). final Currency base; @@ -9,8 +11,6 @@ class ExchangeRate { /// (e.g. 45000 USD in a BTC/USD pair). final Money quote; - const ExchangeRate({required this.base, required this.quote}); - /// Converts [amount] between the base and quote currencies. /// /// Results are truncated toward zero. @@ -18,23 +18,31 @@ class ExchangeRate { /// Throws an [ArgumentError] if [amount]'s currency is not part of /// this pair. Money convert(Money amount) { - if (base == quote.currency && base == amount.currency) return amount; - - final scale = BigInt.from(10).pow(base.decimals); + if (base == quote.currency && base == amount.currency) { + return amount; + } if (amount.currency == base) { - if (quote.isZero) return Money.zero(quote.currency); + final scale = BigInt.from(10).pow(amount.decimals); - return Money(amount.amount * quote.amount ~/ scale, quote.currency); + return quote.isZero + ? Money.zero(quote.currency) + : Money(amount.amount * quote.amount ~/ scale, quote.currency, quote.decimals); } if (amount.currency == quote.currency) { - if (quote.isZero) return Money.zero(base); + if (quote.isZero) { + return Money.zero(base); + } - return Money(amount.amount * scale ~/ quote.amount, base); + final numerator = amount.amount * BigInt.from(10).pow(base.decimals + quote.decimals); + final denominator = quote.amount * BigInt.from(10).pow(amount.decimals); + + return Money(numerator ~/ denominator, base); } throw ArgumentError( - "Unable to convert ${amount.currency.symbol} in ${base.symbol}/${quote.currency.symbol} pair"); + "Unable to convert ${amount.currency.symbol} in ${base.symbol}/${quote.currency.symbol} pair", + ); } } diff --git a/cw_core/lib/amount/money.dart b/cw_core/lib/amount/money.dart index de5483e47..3997da501 100644 --- a/cw_core/lib/amount/money.dart +++ b/cw_core/lib/amount/money.dart @@ -1,4 +1,6 @@ -import "package:cw_core/crypto_amount_format.dart"; +import "dart:math"; + +import "package:cw_core/amount/utils.dart"; import "package:cw_core/crypto_currency.dart"; import "package:cw_core/currency.dart"; import "package:cw_core/format_fixed.dart"; @@ -7,39 +9,63 @@ import "package:cw_core/parse_fixed.dart"; export "money_local.dart"; class Money implements Comparable { - const Money(this.amount, this.currency); + const Money(this.amount, this.currency, [int? overrideDecimals]) + : _overrideDecimals = overrideDecimals; factory Money.zero(Currency currency) => Money(BigInt.zero, currency); factory Money.fromInt(int amount, Currency currency) => Money(BigInt.from(amount), currency); - /// Parse the [source] and turn it into [Money] + /// Parse the [source] and turn it into [Money] trimming trailing 0s /// /// Throws a [FormatException] if the [source] is not a valid decimal or /// not in canonical representation or if it is a decimal when [isBaseUnit] - factory Money.parse(source, Currency currency, {bool isBaseUnit = false}) { - final amount = isBaseUnit - ? BigInt.parse(source.toString()) - : parseFixed(source.toString(), currency.decimals); + factory Money.parse( + String source, + Currency currency, { + bool isBaseUnit = false, + bool strictParsing = true, + }) { + if (!isBaseUnit) { + source = trimTrailingFractionZeros(source); + } + final decimals = strictParsing ? currency.decimals : _getActualDecimals(source, currency); + final amount = isBaseUnit ? BigInt.parse(source) : parseFixed(source, decimals); - return Money(amount, currency); + return Money(amount, currency, decimals); } - /// Parse the [source] and turn it into [Money] if possible + /// Parse the [source] and turn it into [Money] if possible trimming trailing 0s /// /// As [parse] except that this method returns `null` if the input is not /// valid or if it is a decimal when [isBaseUnit] - static Money? tryParse(source, Currency currency, {bool isBaseUnit = false}) { - final amount = isBaseUnit - ? BigInt.tryParse(source.toString()) - : tryParseFixed(source.toString(), currency.decimals); + static Money? tryParse( + String source, + Currency currency, { + bool isBaseUnit = false, + bool strictParsing = true, + }) { + try { + if (!isBaseUnit) { + source = trimTrailingFractionZeros(source); + } + final decimals = strictParsing ? currency.decimals : _getActualDecimals(source, currency); + final amount = isBaseUnit ? BigInt.tryParse(source) : tryParseFixed(source, decimals); - return amount != null ? Money(amount, currency) : null; + return amount != null ? Money(amount, currency, decimals) : null; + } catch (_) { + return null; + } } final BigInt amount; final Currency currency; + final int? _overrideDecimals; + + /// Returns the amount of decimals of [currency] + int get decimals => _overrideDecimals ?? currency.decimals; + /// Returns the sign of this [BigInt] amount. /// Returns 0 for zero, -1 for values less than zero and +1 for values /// greater than zero. @@ -59,15 +85,25 @@ class Money implements Comparable { int compareTo(Money other) { _assertSameCurrency(other); - return amount.compareTo(other.amount); + final aligned = _align(other); + return aligned.a.compareTo(aligned.b); } /// Returns `true` if [other] is the same amount of money in /// the same currency. @override - bool operator ==(Object other) => - identical(this, other) || - (other is Money && other.currency == currency && other.amount == amount); + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other is! Money || other.currency != currency) { + return false; + } + + final aligned = _align(other); + return aligned.a == aligned.b; + } /// Returns `true` when this money is less than [other]. /// @@ -76,7 +112,8 @@ class Money implements Comparable { bool operator <(Money other) { _assertSameCurrency(other, "Cannot compare money in different currencies."); - return amount < other.amount; + final aligned = _align(other); + return aligned.a < aligned.b; } /// Returns `true` when this money is less than or equal to [other]. @@ -86,7 +123,8 @@ class Money implements Comparable { bool operator <=(Money other) { _assertSameCurrency(other, "Cannot compare money in different currencies."); - return amount <= other.amount; + final aligned = _align(other); + return aligned.a <= aligned.b; } /// Returns `true` when this money is greater than [other]. @@ -96,7 +134,8 @@ class Money implements Comparable { bool operator >(Money other) { _assertSameCurrency(other, "Cannot compare money in different currencies."); - return amount > other.amount; + final aligned = _align(other); + return aligned.a > aligned.b; } /// Returns `true` when this money is greater than or equal to [other]. @@ -106,7 +145,8 @@ class Money implements Comparable { bool operator >=(Money other) { _assertSameCurrency(other, "Cannot compare money in different currencies."); - return amount >= other.amount; + final aligned = _align(other); + return aligned.a >= aligned.b; } /// Adds the amount of [other] to this amount. @@ -116,11 +156,12 @@ class Money implements Comparable { Money operator +(Money other) { _assertSameCurrency(other); - return _withAmount(amount + other.amount); + final aligned = _align(other); + return copyWith(amount: aligned.a + aligned.b, decimals: aligned.decimals); } /// unary minus operator. - Money operator -() => _withAmount(-amount); + Money operator -() => copyWith(amount: -amount); /// Subtracts the amount of [other] from this amount. /// @@ -129,13 +170,14 @@ class Money implements Comparable { Money operator -(Money other) { _assertSameCurrency(other); - return _withAmount(amount - other.amount); + final aligned = _align(other); + return copyWith(amount: aligned.a - aligned.b, decimals: aligned.decimals); } /// Returns [Money] multiplied by [other]. /// /// The result is again [Money]. - Money operator *(BigInt other) => _withAmount(amount * other); + Money operator *(BigInt other) => copyWith(amount: amount * other); /// Returns [Money] divided by [other]. /// @@ -156,7 +198,7 @@ class Money implements Comparable { final twiceR = r << 1; final mag = (twiceR >= B) ? q + BigInt.one : q; - return _withAmount(neg ? -mag : mag); + return copyWith(amount: neg ? -mag : mag); } /// Creates a copy of this [Money] object with optional new values @@ -164,46 +206,63 @@ class Money implements Comparable { /// /// If just [currency] is provided and the decimals missmatch /// the amount will be transformed to keep its canonical representation - Money copyWith({BigInt? amount, Currency? currency}) { - if (currency != null && amount == null && currency.decimals != this.currency.decimals) { + Money copyWith({BigInt? amount, Currency? currency, int? decimals}) { + if (currency != null && amount == null && decimals == null && + currency.decimals != this.decimals) { return Money( - _transformAmount(this.amount, this.currency.decimals, currency.decimals), + _transformAmount(this.amount, this.decimals, currency.decimals), currency, + currency.decimals, ); } - return Money(amount ?? this.amount, currency ?? this.currency); + return Money(amount ?? this.amount, currency ?? this.currency, decimals ?? this.decimals); } - /// Creates new instance with the same currency and given [amount]. - Money _withAmount(BigInt amount) => Money(amount, currency); - void _assertSameCurrency(Money other, [String? message]) { if (currency != other.currency) { throw ArgumentError(message ?? "Cannot operate with money values in different currencies."); } } - BigInt _transformAmount(BigInt source, int sourceDecimals, int targetDecimals) { - if (sourceDecimals == targetDecimals) { - return source; + static BigInt _transformAmount(BigInt source, int sourceDecimals, int targetDecimals) { + final diff = targetDecimals - sourceDecimals; + + return diff == 0 + ? source + : diff > 0 + ? source * BigInt.from(10).pow(diff) + : source ~/ BigInt.from(10).pow(-diff); + } + + ({BigInt a, BigInt b, int decimals}) _align(Money other) { + final decimals = max(this.decimals, other.decimals); + return ( + a: _transformAmount(amount, this.decimals, decimals), + b: _transformAmount(other.amount, other.decimals, decimals), + decimals: decimals, + ); + } + + static int _getActualDecimals(String value, Currency currency) { + final comps = value.split("."); + if (comps.length > 2) { + throw FormatException("Money._getActualDecimals: too many decimal points, value, $value"); } - if (sourceDecimals > targetDecimals) { - return parseFixed( - formatFixed(source, sourceDecimals).withMaxDecimals(targetDecimals), - targetDecimals, - ); - } else { - return parseFixed(formatFixed(source, sourceDecimals), targetDecimals); - } + return max(comps.length == 2 ? comps[1].length : 0, currency.decimals); } @override - int get hashCode => amount.hashCode ^ currency.hashCode; + int get hashCode { + final value = formatFixed(amount, this.decimals, trimZeros: true); + final decimals = _getActualDecimals(value, currency); + + return Object.hash(value, decimals, currency); + } @override - String toString() => formatFixed(amount, currency.decimals); + String toString() => formatFixed(amount, decimals); String toStringWithSymbol({ int? fractionalDigits, @@ -227,7 +286,9 @@ class Money implements Comparable { bool useBaseUnit = false, }) => formatFixed( - amount, + decimals != currency.decimals + ? _transformAmount(amount, decimals, currency.decimals) + : amount, useBaseUnit ? 0 : currency.decimals, fractionalDigits: fractionalDigits, trimZeros: trimZeros, diff --git a/cw_core/lib/amount/money_double.dart b/cw_core/lib/amount/money_double.dart index 8dc44c4e3..6c8aa766c 100644 --- a/cw_core/lib/amount/money_double.dart +++ b/cw_core/lib/amount/money_double.dart @@ -1,13 +1,16 @@ import "dart:math"; + import "package:cw_core/amount/money.dart"; +import "package:cw_core/amount/utils.dart"; import "package:cw_core/currency.dart"; +/// Turn a double representation of a currency amount to a proper Money representation +/// truncating currencies with more than 20 decimals because double can not handle more extension ToMoney on double { - /// Turn a double representation of a currency amount to a proper Money representation - /// truncating currencies with more than 20 decimals because double can not handle more - Money? tryToMoney(Currency currency) => - Money.tryParse(toStringAsFixed(min(currency.decimals, 20)), currency); + Money? tryToMoney(Currency currency) => Money.tryParse(_toSafeString(currency), currency); - Money toMoney(Currency currency) => - Money.parse(toStringAsFixed(min(currency.decimals, 20)), currency); + Money toMoney(Currency currency) => Money.parse(_toSafeString(currency), currency); + + String _toSafeString(Currency currency) => + trimTrailingFractionZeros(toStringAsFixed(min(currency.decimals, 20))); } diff --git a/cw_core/lib/amount/utils.dart b/cw_core/lib/amount/utils.dart new file mode 100644 index 000000000..28c61bb83 --- /dev/null +++ b/cw_core/lib/amount/utils.dart @@ -0,0 +1,13 @@ +String trimTrailingFractionZeros(String value) { + if (!value.contains('.')) { + return value; + } + + var end = value.length; + while (end > 0 && value[end - 1] == "0") { + end--; + } + + final trimmed = end == value.length ? value : value.substring(0, end); + return trimmed.endsWith('.') ? trimmed.substring(0, trimmed.length - 1) : trimmed; +} diff --git a/cw_core/lib/parse_fixed.dart b/cw_core/lib/parse_fixed.dart index a5a6d7b82..35d2e263c 100644 --- a/cw_core/lib/parse_fixed.dart +++ b/cw_core/lib/parse_fixed.dart @@ -55,7 +55,7 @@ BigInt parseFixed(String value, int decimals) { } final whole = comps.isNotEmpty ? comps[0] : "0"; - final fraction = (comps.length == 2 ? comps[1] : "0").padRight(decimals, "0"); + final fraction = (comps.length == 2 ? comps[1] : "").padRight(decimals, "0"); if (fraction.length > multiplier.length - 1) { throw FormatException( @@ -64,7 +64,7 @@ BigInt parseFixed(String value, int decimals) { } final wholeValue = BigInt.parse(whole); - final fractionValue = BigInt.parse(fraction); + final fractionValue = fraction.isEmpty ? BigInt.zero : BigInt.parse(fraction); final multiplierValue = BigInt.parse(multiplier); var wei = (wholeValue * multiplierValue) + fractionValue; diff --git a/cw_core/test/amount/exchange_rate_test.dart b/cw_core/test/amount/exchange_rate_test.dart index 3873e349f..e7932d8d5 100644 --- a/cw_core/test/amount/exchange_rate_test.dart +++ b/cw_core/test/amount/exchange_rate_test.dart @@ -1,9 +1,10 @@ import 'package:cw_core/amount/exchange_rate.dart'; import 'package:cw_core/amount/money.dart'; import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/currency.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'utils.dart'; + void main() { group('ExchangeRate', () { final rate = ExchangeRate( @@ -99,49 +100,216 @@ void main() { test('handle quote being 0 correctly', () { final zeroRate = ExchangeRate( base: CryptoCurrency.btc, - quote: Money(BigInt.zero, EUR), + quote: Money(BigInt.zero, EUR, EUR.decimals), ); expect(zeroRate.convert(Money.fromInt(100, CryptoCurrency.btc)), Money(BigInt.zero, EUR)); expect(zeroRate.convert(Money.fromInt(100, EUR)), Money(BigInt.zero, CryptoCurrency.btc)); }); + + test('handle super low quote correctly', () { + final zeroRate = ExchangeRate( + base: CryptoCurrency.shib, + quote: Money.parse("0.00000444", EUR, strictParsing: false), + ); + + expect( + zeroRate.convert(Money.parse("1", CryptoCurrency.shib)), + Money.parse("0.00000444", EUR, strictParsing: false), + ); + expect( + zeroRate.convert(Money.fromInt(10000, EUR)), + Money.parse("22522522.522522522522522522", CryptoCurrency.shib), + ); + + expect( + zeroRate.convert(Money.parse("200", CryptoCurrency.shib)), + Money.parse("0.000888", EUR, strictParsing: false), + ); + }); + + group('Precision and scale', () { + final rate = ExchangeRate(base: CryptoCurrency.btc, quote: Money.parse("60000", EUR)); + + test('convert is insensitive to the input scale (forward)', () { + // Same value, three different internal scales. + final coarse = Money(BigInt.one, CryptoCurrency.btc, 0); + final normal = Money.parse("1", CryptoCurrency.btc); + final fine = Money.parse("1.000000000000000000", CryptoCurrency.btc, strictParsing: false); + + expect(rate.convert(coarse), Money.parse("60000.00", EUR)); + expect(rate.convert(normal), Money.parse("60000.00", EUR)); + expect(rate.convert(fine), Money.parse("60000.00", EUR)); + }); + + test('convert is insensitive to the input scale (reverse)', () { + expect( + rate.convert(Money(BigInt.one, EUR, 0)), // €1 at scale 0 + rate.convert(Money.parse("1", EUR)), // €1 at scale 2 + ); + }); + + test('sub-unit precision in the input does not leak into the result', () { + // ad = 18 here, well past BTC's 8. The divisor must cancel the *amount's* + // scale, not the base currency's. + final overPrecise = + Money.parse("1.000000000000000001", CryptoCurrency.btc, strictParsing: false); + + expect(rate.convert(overPrecise), Money.parse("60000.00", EUR)); + }); + + test('the result carries the quote scale, not the currency default', () { + final preciseRate = ExchangeRate( + base: CryptoCurrency.btc, + quote: Money.parse("60000.123456", EUR, strictParsing: false), + ); + + final result = preciseRate.convert(Money.parse("1", CryptoCurrency.btc)); + + expect(result.decimals, 6); + expect(result, Money.parse("60000.123456", EUR, strictParsing: false)); + }); + + test('a quote scale finer than the currency survives the round trip', () { + final preciseRate = ExchangeRate( + base: CryptoCurrency.btc, + quote: Money.parse("60000.50", EUR, strictParsing: false), + ); + + expect( + preciseRate.convert(preciseRate.convert(Money.parse("1", CryptoCurrency.btc))), + Money.parse("1", CryptoCurrency.btc), + ); + }); + }); + + group('Truncation', () { + final rate = ExchangeRate(base: CryptoCurrency.btc, quote: Money.parse("60000", EUR)); + + test('truncates rather than rounding, even past the halfway point', () { + // 9 sats = 0.54 cents. Rounding would give 1. + expect(rate.convert(Money(BigInt.from(9), CryptoCurrency.btc)), Money.zero(EUR)); + // 1 cent = 16.66 sats. Rounding would give 17. + expect(rate.convert(Money(BigInt.one, EUR)), Money.fromInt(16, CryptoCurrency.btc)); + }); + + test('truncates toward zero for negatives, not downward', () { + // -0.54 cents must be 0, not -1. + expect(rate.convert(Money(BigInt.from(-9), CryptoCurrency.btc)), Money.zero(EUR)); + expect(rate.convert(Money(BigInt.from(-1), EUR)), Money.fromInt(-16, CryptoCurrency.btc)); + }); + + test('truncation is symmetric around zero', () { + final positive = rate.convert(Money(BigInt.from(7), EUR)); + final negative = rate.convert(Money(BigInt.from(-7), EUR)); + + expect(negative, -positive); + }); + + test('round trip loses less than one quote base unit', () { + final original = Money.parse("0.12345678", CryptoCurrency.btc); + final back = rate.convert(rate.convert(original)); + final oneQuoteUnitInBaseUnits = + BigInt.from(10).pow(CryptoCurrency.btc.decimals) ~/ rate.quote.amount; + + expect( + (original - back).amount.abs(), + lessThanOrEqualTo(oneQuoteUnitInBaseUnits + BigInt.one), + ); + }); + + test('round trip never inflates the amount', () { + // Both truncations round toward zero, so the result can only shrink. + for (final source in ["0.12345678", "1", "0.5", "21000000"]) { + final original = Money.parse(source, CryptoCurrency.btc); + expect( + rate.convert(rate.convert(original)) <= original, + isTrue, + reason: "round trip grew $source", + ); + } + }); + + test('a value below one base unit of the quote collapses to zero', () { + final back = rate.convert(rate.convert(Money(BigInt.one, CryptoCurrency.btc))); + + expect(back.isZero, isTrue); + }); + }); + + group('Extreme decimal spreads', () { + test('30 decimals against 0 decimals: NANO/JPY', () { + final rate = ExchangeRate(base: CryptoCurrency.nano, quote: Money.parse("150", JPY)); + + expect(rate.convert(Money.parse("1", CryptoCurrency.nano)), Money.fromInt(150, JPY)); + expect(rate.convert(Money.fromInt(150, JPY)), Money.parse("1", CryptoCurrency.nano)); + expect(rate.convert(Money.fromInt(1, JPY)).decimals, 30); + }); + + test('0 decimals against 18 decimals: JPY/SHIB', () { + final rate = ExchangeRate(base: JPY, quote: Money.parse("2500", CryptoCurrency.shib)); + + expect(rate.convert(Money.fromInt(2, JPY)), Money.parse("5000", CryptoCurrency.shib)); + expect(rate.convert(Money.parse("5000", CryptoCurrency.shib)), Money.fromInt(2, JPY)); + }); + + test('no overflow at the extremes', () { + final rate = ExchangeRate( + base: CryptoCurrency.shib, + quote: Money.parse("0.00000444", EUR, strictParsing: false), + ); + final totalSupply = Money.parse("589000000000000", CryptoCurrency.shib); + + expect(rate.convert(totalSupply).isNegative, isFalse); + expect(rate.convert(rate.convert(totalSupply)) <= totalSupply, isTrue); + }); + }); + + group('Degenerate pairs', () { + test('identity pair returns the amount untouched', () { + final identity = ExchangeRate(base: EUR, quote: Money.parse("1", EUR)); + + expect(identity.convert(Money.parse("42.42", EUR)), Money.parse("42.42", EUR)); + expect(identity.convert(Money.zero(EUR)), Money.zero(EUR)); + }); + + test('a same-currency pair silently ignores its own rate', () { + final bogus = ExchangeRate(base: EUR, quote: Money.parse("2", EUR)); + expect(bogus.convert(Money.parse("10", EUR)), Money.parse("10", EUR)); + }); + + test('a rate of exactly one is a no-op in both directions', () { + final rate = ExchangeRate(base: JPY, quote: Money.fromInt(100, EUR)); + + expect(rate.convert(Money.fromInt(7, JPY)), Money.fromInt(700, EUR)); + expect(rate.convert(Money.fromInt(700, EUR)), Money.fromInt(7, JPY)); + }); + + test('negative quotes propagate their sign', () { + final inverted = ExchangeRate(base: CryptoCurrency.btc, quote: Money.parse("-60000", EUR)); + + expect( + inverted.convert(Money.parse("1", CryptoCurrency.btc)), + Money.parse("-60000.00", EUR), + ); + expect( + inverted.convert(Money.parse("-60000.00", EUR)), + Money.parse("1", CryptoCurrency.btc), + ); + }); + + test('zero amount with a zero quote', () { + final zeroRate = ExchangeRate(base: CryptoCurrency.btc, quote: Money.zero(EUR)); + + expect(zeroRate.convert(Money.zero(CryptoCurrency.btc)), Money.zero(EUR)); + expect(zeroRate.convert(Money.zero(EUR)), Money.zero(CryptoCurrency.btc)); + }); + + test('rejects a foreign currency in both slots', () { + final rate = ExchangeRate(base: CryptoCurrency.btc, quote: Money.parse("60000", EUR)); + + expect(() => rate.convert(Money.parse("1", CryptoCurrency.xmr)), throwsArgumentError); + expect(() => rate.convert(Money.parse("1", USD)), throwsArgumentError); + }); + }); }); } - -class FiatCurrency implements Currency { - const FiatCurrency({ - required this.symbol, - required this.countryCode, - required this.fullName, - this.decimals = 2, - }); - - final String countryCode; - - @override - final String fullName; - - @override - final int decimals; - - @override - final String symbol; - - @override - String? get iconPath => throw UnimplementedError(); - - @override - String get name => throw UnimplementedError(); - - @override - String? get tag => throw UnimplementedError(); - - @override - Money parseAmount(String value) => Money.parse(value, this); - - @override - Money? tryParseAmount(String value) => Money.tryParse(value, this); -} - -const EUR = FiatCurrency(symbol: 'EUR', countryCode: "eur", fullName: "Euro"); -const USD = FiatCurrency(symbol: 'USD', countryCode: "usd", fullName: "US Dollar"); -const JPY = FiatCurrency(symbol: 'JPY', countryCode: "jpn", fullName: "Japanese Yen", decimals: 0); diff --git a/cw_core/test/amount/money_test.dart b/cw_core/test/amount/money_test.dart index 8f045bdcb..6a91f206f 100644 --- a/cw_core/test/amount/money_test.dart +++ b/cw_core/test/amount/money_test.dart @@ -2,6 +2,8 @@ import "package:cw_core/amount/money.dart"; import "package:cw_core/crypto_currency.dart"; import "package:flutter_test/flutter_test.dart"; +import "utils.dart"; + void main() { group("Money", () { test("parse", () { @@ -27,7 +29,7 @@ void main() { expect(() => Money.parse("1,11", CryptoCurrency.btc), throwsFormatException); // To many decimals - expect(() => Money.parse("-1.000000000000000", CryptoCurrency.btc), throwsFormatException); + expect(() => Money.parse("-1.0000000000000001", CryptoCurrency.btc), throwsFormatException); money = Money.parse("1", CryptoCurrency.btc, isBaseUnit: true); expect(money.amount, BigInt.from(1)); @@ -66,7 +68,7 @@ void main() { expect(money?.amount, isNull); // To many decimals - money = Money.tryParse("-1.000000000000000", CryptoCurrency.btc); + money = Money.tryParse("-1.0000000000000001", CryptoCurrency.btc); expect(money?.amount, isNull); money = Money.tryParse("1", CryptoCurrency.btc, isBaseUnit: true); @@ -373,5 +375,122 @@ void main() { }); }); }); + + group("different scales", () { + final coarseOne = Money(BigInt.one, CryptoCurrency.btc, 0); + final fineOne = Money(BigInt.from(100000000), CryptoCurrency.btc, 8); + + test("== across scales", () { + expect(coarseOne, equals(fineOne)); + expect(coarseOne, isNot(equals(Money(BigInt.two, CryptoCurrency.btc, 0)))); + }); + + test("equal values across scales hash equally", () { + expect(coarseOne.hashCode, equals(fineOne.hashCode)); + + expect( + Money(BigInt.from(-11), CryptoCurrency.btc, 1).hashCode, + equals(Money(BigInt.from(-110000000), CryptoCurrency.btc, 8).hashCode), + ); + }); + + test("zero hashes equally at every scale", () { + final hashes = [0, 1, 8, 18, 30] + .map((s) => Money(BigInt.zero, CryptoCurrency.btc, s).hashCode) + .toSet(); + + expect(hashes, hasLength(1)); + }); + + test("whole units are not confused with their digits", () { + // (100, scale 0) is one hundred, not one: stripping must stop at 0. + final oneHundred = Money(BigInt.from(100), CryptoCurrency.btc, 0); + + expect(oneHundred, isNot(equals(coarseOne))); + expect(oneHundred.hashCode, isNot(equals(coarseOne.hashCode))); + }); + + test("Set deduplicates across scales", () { + expect( + { + Money(BigInt.from(11), CryptoCurrency.btc, 1), + Money(BigInt.from(110000000), CryptoCurrency.btc, 8), + Money(BigInt.from(1100), CryptoCurrency.btc, 3), + }, + hasLength(1), + ); + }); + + test("+ aligns operands", () { + expect(coarseOne + fineOne, equals(Money(BigInt.two, CryptoCurrency.btc, 0))); + expect(coarseOne + fineOne, equals(fineOne + coarseOne)); + }); + + test("- aligns operands", () { + // Regression: this used to subtract the raw amounts, i.e. 1 - 100000000. + expect((coarseOne - fineOne).isZero, isTrue); + expect((fineOne - coarseOne).isZero, isTrue); + + final threeCoarse = Money(BigInt.from(3), CryptoCurrency.btc, 0); + expect(threeCoarse - fineOne, equals(Money(BigInt.two, CryptoCurrency.btc, 0))); + expect(threeCoarse - fineOne, equals(-(fineOne - threeCoarse))); + }); + + test("the result keeps the finer scale", () { + expect((coarseOne + fineOne).decimals, greaterThanOrEqualTo(fineOne.decimals)); + }); + + test("sub-unit precision survives a coarse operand", () { + final oneSatoshi = Money(BigInt.one, CryptoCurrency.btc, 8); + + expect( + coarseOne + oneSatoshi, + equals(Money(BigInt.from(100000001), CryptoCurrency.btc, 8)), + ); + }); + + test("comparison operators align", () { + expect(coarseOne < fineOne, isFalse); + expect(coarseOne <= fineOne, isTrue); + expect(coarseOne >= fineOne, isTrue); + expect(coarseOne > fineOne, isFalse); + expect(Money(BigInt.two, CryptoCurrency.btc, 0) > fineOne, isTrue); + }); + + test("compareTo aligns and agrees with ==", () { + expect(coarseOne.compareTo(fineOne), isZero); + expect(coarseOne.compareTo(fineOne) == 0, equals(coarseOne == fineOne)); + expect(Money(BigInt.two, CryptoCurrency.btc, 0).compareTo(fineOne), isPositive); + expect(Money(BigInt.zero, CryptoCurrency.btc, 0).compareTo(fineOne), isNegative); + }); + + test("toStringWithPrecision", () { + expect( + Money.parse("60000.123456", USD, strictParsing: false).toStringWithPrecision(), + "60000.12", + ); + expect( + Money.parse("1.23456789", USD, strictParsing: false).toStringWithPrecision(), + "1.23", + ); + expect(Money(BigInt.one, CryptoCurrency.btc, 0).toStringWithPrecision(), "1"); + expect( + Money(BigInt.one, CryptoCurrency.btc, 0).toStringWithPrecision(useBaseUnit: true), + "100000000", + ); + }); + + test("toString", () { + expect( + Money.parse("60000.123456", CryptoCurrency.btc, strictParsing: false).toString(), + "60000.123456", + ); + expect( + Money.parse("1.23456789", CryptoCurrency.btc, strictParsing: false).toString(), + "1.23456789", + ); + expect(Money(BigInt.one, CryptoCurrency.btc, 0).toString(), "1"); + }); + }); }); } diff --git a/cw_core/test/amount/utils.dart b/cw_core/test/amount/utils.dart new file mode 100644 index 000000000..70081e62d --- /dev/null +++ b/cw_core/test/amount/utils.dart @@ -0,0 +1,41 @@ +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/currency.dart'; + +class FiatCurrency implements Currency { + const FiatCurrency({ + required this.symbol, + required this.countryCode, + required this.fullName, + this.decimals = 2, + }); + + final String countryCode; + + @override + final String fullName; + + @override + final int decimals; + + @override + final String symbol; + + @override + String? get iconPath => throw UnimplementedError(); + + @override + String get name => throw UnimplementedError(); + + @override + String? get tag => throw UnimplementedError(); + + @override + Money parseAmount(String value) => Money.parse(value, this); + + @override + Money? tryParseAmount(String value) => Money.tryParse(value, this); +} + +const EUR = FiatCurrency(symbol: 'EUR', countryCode: "eur", fullName: "Euro"); +const USD = FiatCurrency(symbol: 'USD', countryCode: "usd", fullName: "US Dollar"); +const JPY = FiatCurrency(symbol: 'JPY', countryCode: "jpn", fullName: "Japanese Yen", decimals: 0);