Enhance Money class with precision handling and zero trimming (#3548)

* feat: enhance Money class with precision handling, scale alignment, and trailing zero trimming

Konstantin Ullrich committed Aug 24, 2026 at 14:48 UTC c8380129791e9f59ed0106872fec849d1eeec39e
8 files changed +514 -101
cw_core/lib/amount/exchange_rate.dart
+18 -10
@@ -2,6 +2,8 @@ import 'package:cw_core/amount/money.dart';
2 import 'package:cw_core/currency.dart';
3
4 class ExchangeRate {
5 + const ExchangeRate({required this.base, required this.quote});
6 +
7 /// The currency being priced (e.g. BTC in a BTC/USD pair).
8 final Currency base;
9
@@ -9,8 +11,6 @@ class ExchangeRate {
11 /// (e.g. 45000 USD in a BTC/USD pair).
12 final Money quote;
13
12 - const ExchangeRate({required this.base, required this.quote});
13 -
14 /// Converts [amount] between the base and quote currencies.
15 ///
16 /// Results are truncated toward zero.
@@ -18,23 +18,31 @@ class ExchangeRate {
18 /// Throws an [ArgumentError] if [amount]'s currency is not part of
19 /// this pair.
20 Money convert(Money amount) {
21 - if (base == quote.currency && base == amount.currency) return amount;
22 -
23 - final scale = BigInt.from(10).pow(base.decimals);
21 + if (base == quote.currency && base == amount.currency) {
22 + return amount;
23 + }
24
25 if (amount.currency == base) {
26 - if (quote.isZero) return Money.zero(quote.currency);
26 + final scale = BigInt.from(10).pow(amount.decimals);
27
28 - return Money(amount.amount * quote.amount ~/ scale, quote.currency);
28 + return quote.isZero
29 + ? Money.zero(quote.currency)
30 + : Money(amount.amount * quote.amount ~/ scale, quote.currency, quote.decimals);
31 }
32
33 if (amount.currency == quote.currency) {
32 - if (quote.isZero) return Money.zero(base);
34 + if (quote.isZero) {
35 + return Money.zero(base);
36 + }
37 +
38 + final numerator = amount.amount * BigInt.from(10).pow(base.decimals + quote.decimals);
39 + final denominator = quote.amount * BigInt.from(10).pow(amount.decimals);
40
34 - return Money(amount.amount * scale ~/ quote.amount, base);
41 + return Money(numerator ~/ denominator, base);
42 }
43
44 throw ArgumentError(
38 - "Unable to convert ${amount.currency.symbol} in ${base.symbol}/${quote.currency.symbol} pair");
45 + "Unable to convert ${amount.currency.symbol} in ${base.symbol}/${quote.currency.symbol} pair",
46 + );
47 }
48 }
cw_core/lib/amount/money.dart
+110 -49
@@ -1,4 +1,6 @@
1 -import "package:cw_core/crypto_amount_format.dart";
1 +import "dart:math";
2 +
3 +import "package:cw_core/amount/utils.dart";
4 import "package:cw_core/crypto_currency.dart";
5 import "package:cw_core/currency.dart";
6 import "package:cw_core/format_fixed.dart";
@@ -7,39 +9,63 @@ import "package:cw_core/parse_fixed.dart";
9 export "money_local.dart";
10
11 class Money implements Comparable<Money> {
10 - const Money(this.amount, this.currency);
12 + const Money(this.amount, this.currency, [int? overrideDecimals])
13 + : _overrideDecimals = overrideDecimals;
14
15 factory Money.zero(Currency currency) => Money(BigInt.zero, currency);
16
17 factory Money.fromInt(int amount, Currency currency) => Money(BigInt.from(amount), currency);
18
16 - /// Parse the [source] and turn it into [Money]
19 + /// Parse the [source] and turn it into [Money] trimming trailing 0s
20 ///
21 /// Throws a [FormatException] if the [source] is not a valid decimal or
22 /// not in canonical representation or if it is a decimal when [isBaseUnit]
20 - factory Money.parse(source, Currency currency, {bool isBaseUnit = false}) {
21 - final amount = isBaseUnit
22 - ? BigInt.parse(source.toString())
23 - : parseFixed(source.toString(), currency.decimals);
23 + factory Money.parse(
24 + String source,
25 + Currency currency, {
26 + bool isBaseUnit = false,
27 + bool strictParsing = true,
28 + }) {
29 + if (!isBaseUnit) {
30 + source = trimTrailingFractionZeros(source);
31 + }
32 + final decimals = strictParsing ? currency.decimals : _getActualDecimals(source, currency);
33 + final amount = isBaseUnit ? BigInt.parse(source) : parseFixed(source, decimals);
34
25 - return Money(amount, currency);
35 + return Money(amount, currency, decimals);
36 }
37
28 - /// Parse the [source] and turn it into [Money] if possible
38 + /// Parse the [source] and turn it into [Money] if possible trimming trailing 0s
39 ///
40 /// As [parse] except that this method returns `null` if the input is not
41 /// valid or if it is a decimal when [isBaseUnit]
32 - static Money? tryParse(source, Currency currency, {bool isBaseUnit = false}) {
33 - final amount = isBaseUnit
34 - ? BigInt.tryParse(source.toString())
35 - : tryParseFixed(source.toString(), currency.decimals);
36 -
37 - return amount != null ? Money(amount, currency) : null;
42 + static Money? tryParse(
43 + String source,
44 + Currency currency, {
45 + bool isBaseUnit = false,
46 + bool strictParsing = true,
47 + }) {
48 + try {
49 + if (!isBaseUnit) {
50 + source = trimTrailingFractionZeros(source);
51 + }
52 + final decimals = strictParsing ? currency.decimals : _getActualDecimals(source, currency);
53 + final amount = isBaseUnit ? BigInt.tryParse(source) : tryParseFixed(source, decimals);
54 +
55 + return amount != null ? Money(amount, currency, decimals) : null;
56 + } catch (_) {
57 + return null;
58 + }
59 }
60
61 final BigInt amount;
62 final Currency currency;
63
64 + final int? _overrideDecimals;
65 +
66 + /// Returns the amount of decimals of [currency]
67 + int get decimals => _overrideDecimals ?? currency.decimals;
68 +
69 /// Returns the sign of this [BigInt] amount.
70 /// Returns 0 for zero, -1 for values less than zero and +1 for values
71 /// greater than zero.
@@ -59,15 +85,25 @@ class Money implements Comparable<Money> {
85 int compareTo(Money other) {
86 _assertSameCurrency(other);
87
62 - return amount.compareTo(other.amount);
88 + final aligned = _align(other);
89 + return aligned.a.compareTo(aligned.b);
90 }
91
92 /// Returns `true` if [other] is the same amount of money in
93 /// the same currency.
94 @override
68 - bool operator ==(Object other) =>
69 - identical(this, other) ||
70 - (other is Money && other.currency == currency && other.amount == amount);
95 + bool operator ==(Object other) {
96 + if (identical(this, other)) {
97 + return true;
98 + }
99 +
100 + if (other is! Money || other.currency != currency) {
101 + return false;
102 + }
103 +
104 + final aligned = _align(other);
105 + return aligned.a == aligned.b;
106 + }
107
108 /// Returns `true` when this money is less than [other].
109 ///
@@ -76,7 +112,8 @@ class Money implements Comparable<Money> {
112 bool operator <(Money other) {
113 _assertSameCurrency(other, "Cannot compare money in different currencies.");
114
79 - return amount < other.amount;
115 + final aligned = _align(other);
116 + return aligned.a < aligned.b;
117 }
118
119 /// Returns `true` when this money is less than or equal to [other].
@@ -86,7 +123,8 @@ class Money implements Comparable<Money> {
123 bool operator <=(Money other) {
124 _assertSameCurrency(other, "Cannot compare money in different currencies.");
125
89 - return amount <= other.amount;
126 + final aligned = _align(other);
127 + return aligned.a <= aligned.b;
128 }
129
130 /// Returns `true` when this money is greater than [other].
@@ -96,7 +134,8 @@ class Money implements Comparable<Money> {
134 bool operator >(Money other) {
135 _assertSameCurrency(other, "Cannot compare money in different currencies.");
136
99 - return amount > other.amount;
137 + final aligned = _align(other);
138 + return aligned.a > aligned.b;
139 }
140
141 /// Returns `true` when this money is greater than or equal to [other].
@@ -106,7 +145,8 @@ class Money implements Comparable<Money> {
145 bool operator >=(Money other) {
146 _assertSameCurrency(other, "Cannot compare money in different currencies.");
147
109 - return amount >= other.amount;
148 + final aligned = _align(other);
149 + return aligned.a >= aligned.b;
150 }
151
152 /// Adds the amount of [other] to this amount.
@@ -116,11 +156,12 @@ class Money implements Comparable<Money> {
156 Money operator +(Money other) {
157 _assertSameCurrency(other);
158
119 - return _withAmount(amount + other.amount);
159 + final aligned = _align(other);
160 + return copyWith(amount: aligned.a + aligned.b, decimals: aligned.decimals);
161 }
162
163 /// unary minus operator.
123 - Money operator -() => _withAmount(-amount);
164 + Money operator -() => copyWith(amount: -amount);
165
166 /// Subtracts the amount of [other] from this amount.
167 ///
@@ -129,13 +170,14 @@ class Money implements Comparable<Money> {
170 Money operator -(Money other) {
171 _assertSameCurrency(other);
172
132 - return _withAmount(amount - other.amount);
173 + final aligned = _align(other);
174 + return copyWith(amount: aligned.a - aligned.b, decimals: aligned.decimals);
175 }
176
177 /// Returns [Money] multiplied by [other].
178 ///
179 /// The result is again [Money].
138 - Money operator *(BigInt other) => _withAmount(amount * other);
180 + Money operator *(BigInt other) => copyWith(amount: amount * other);
181
182 /// Returns [Money] divided by [other].
183 ///
@@ -156,7 +198,7 @@ class Money implements Comparable<Money> {
198 final twiceR = r << 1;
199 final mag = (twiceR >= B) ? q + BigInt.one : q;
200
159 - return _withAmount(neg ? -mag : mag);
201 + return copyWith(amount: neg ? -mag : mag);
202 }
203
204 /// Creates a copy of this [Money] object with optional new values
@@ -164,46 +206,63 @@ class Money implements Comparable<Money> {
206 ///
207 /// If just [currency] is provided and the decimals missmatch
208 /// the amount will be transformed to keep its canonical representation
167 - Money copyWith({BigInt? amount, Currency? currency}) {
168 - if (currency != null && amount == null && currency.decimals != this.currency.decimals) {
209 + Money copyWith({BigInt? amount, Currency? currency, int? decimals}) {
210 + if (currency != null && amount == null && decimals == null &&
211 + currency.decimals != this.decimals) {
212 return Money(
170 - _transformAmount(this.amount, this.currency.decimals, currency.decimals),
213 + _transformAmount(this.amount, this.decimals, currency.decimals),
214 currency,
215 + currency.decimals,
216 );
217 }
218
175 - return Money(amount ?? this.amount, currency ?? this.currency);
219 + return Money(amount ?? this.amount, currency ?? this.currency, decimals ?? this.decimals);
220 }
221
178 - /// Creates new instance with the same currency and given [amount].
179 - Money _withAmount(BigInt amount) => Money(amount, currency);
180 -
222 void _assertSameCurrency(Money other, [String? message]) {
223 if (currency != other.currency) {
224 throw ArgumentError(message ?? "Cannot operate with money values in different currencies.");
225 }
226 }
227
187 - BigInt _transformAmount(BigInt source, int sourceDecimals, int targetDecimals) {
188 - if (sourceDecimals == targetDecimals) {
189 - return source;
190 - }
228 + static BigInt _transformAmount(BigInt source, int sourceDecimals, int targetDecimals) {
229 + final diff = targetDecimals - sourceDecimals;
230
192 - if (sourceDecimals > targetDecimals) {
193 - return parseFixed(
194 - formatFixed(source, sourceDecimals).withMaxDecimals(targetDecimals),
195 - targetDecimals,
196 - );
197 - } else {
198 - return parseFixed(formatFixed(source, sourceDecimals), targetDecimals);
231 + return diff == 0
232 + ? source
233 + : diff > 0
234 + ? source * BigInt.from(10).pow(diff)
235 + : source ~/ BigInt.from(10).pow(-diff);
236 + }
237 +
238 + ({BigInt a, BigInt b, int decimals}) _align(Money other) {
239 + final decimals = max(this.decimals, other.decimals);
240 + return (
241 + a: _transformAmount(amount, this.decimals, decimals),
242 + b: _transformAmount(other.amount, other.decimals, decimals),
243 + decimals: decimals,
244 + );
245 + }
246 +
247 + static int _getActualDecimals(String value, Currency currency) {
248 + final comps = value.split(".");
249 + if (comps.length > 2) {
250 + throw FormatException("Money._getActualDecimals: too many decimal points, value, $value");
251 }
252 +
253 + return max(comps.length == 2 ? comps[1].length : 0, currency.decimals);
254 }
255
256 @override
203 - int get hashCode => amount.hashCode ^ currency.hashCode;
257 + int get hashCode {
258 + final value = formatFixed(amount, this.decimals, trimZeros: true);
259 + final decimals = _getActualDecimals(value, currency);
260 +
261 + return Object.hash(value, decimals, currency);
262 + }
263
264 @override
206 - String toString() => formatFixed(amount, currency.decimals);
265 + String toString() => formatFixed(amount, decimals);
266
267 String toStringWithSymbol({
268 int? fractionalDigits,
@@ -227,7 +286,9 @@ class Money implements Comparable<Money> {
286 bool useBaseUnit = false,
287 }) =>
288 formatFixed(
230 - amount,
289 + decimals != currency.decimals
290 + ? _transformAmount(amount, decimals, currency.decimals)
291 + : amount,
292 useBaseUnit ? 0 : currency.decimals,
293 fractionalDigits: fractionalDigits,
294 trimZeros: trimZeros,
cw_core/lib/amount/money_double.dart
+9 -6
@@ -1,13 +1,16 @@
1 import "dart:math";
2 +
3 import "package:cw_core/amount/money.dart";
4 +import "package:cw_core/amount/utils.dart";
5 import "package:cw_core/currency.dart";
6
7 +/// Turn a double representation of a currency amount to a proper Money representation
8 +/// truncating currencies with more than 20 decimals because double can not handle more
9 extension ToMoney on double {
6 - /// Turn a double representation of a currency amount to a proper Money representation
7 - /// truncating currencies with more than 20 decimals because double can not handle more
8 - Money? tryToMoney(Currency currency) =>
9 - Money.tryParse(toStringAsFixed(min(currency.decimals, 20)), currency);
10 + Money? tryToMoney(Currency currency) => Money.tryParse(_toSafeString(currency), currency);
11 +
12 + Money toMoney(Currency currency) => Money.parse(_toSafeString(currency), currency);
13
11 - Money toMoney(Currency currency) =>
12 - Money.parse(toStringAsFixed(min(currency.decimals, 20)), currency);
14 + String _toSafeString(Currency currency) =>
15 + trimTrailingFractionZeros(toStringAsFixed(min(currency.decimals, 20)));
16 }
cw_core/lib/amount/utils.dart new
+13
@@ -0,0 +1,13 @@
1 +String trimTrailingFractionZeros(String value) {
2 + if (!value.contains('.')) {
3 + return value;
4 + }
5 +
6 + var end = value.length;
7 + while (end > 0 && value[end - 1] == "0") {
8 + end--;
9 + }
10 +
11 + final trimmed = end == value.length ? value : value.substring(0, end);
12 + return trimmed.endsWith('.') ? trimmed.substring(0, trimmed.length - 1) : trimmed;
13 +}
cw_core/lib/parse_fixed.dart
+2 -2
@@ -55,7 +55,7 @@ BigInt parseFixed(String value, int decimals) {
55 }
56
57 final whole = comps.isNotEmpty ? comps[0] : "0";
58 - final fraction = (comps.length == 2 ? comps[1] : "0").padRight(decimals, "0");
58 + final fraction = (comps.length == 2 ? comps[1] : "").padRight(decimals, "0");
59
60 if (fraction.length > multiplier.length - 1) {
61 throw FormatException(
@@ -64,7 +64,7 @@ BigInt parseFixed(String value, int decimals) {
64 }
65
66 final wholeValue = BigInt.parse(whole);
67 - final fractionValue = BigInt.parse(fraction);
67 + final fractionValue = fraction.isEmpty ? BigInt.zero : BigInt.parse(fraction);
68 final multiplierValue = BigInt.parse(multiplier);
69
70 var wei = (wholeValue * multiplierValue) + fractionValue;
cw_core/test/amount/exchange_rate_test.dart
+200 -32
@@ -1,9 +1,10 @@
1 import 'package:cw_core/amount/exchange_rate.dart';
2 import 'package:cw_core/amount/money.dart';
3 import 'package:cw_core/crypto_currency.dart';
4 -import 'package:cw_core/currency.dart';
4 import 'package:flutter_test/flutter_test.dart';
5
6 +import 'utils.dart';
7 +
8 void main() {
9 group('ExchangeRate', () {
10 final rate = ExchangeRate(
@@ -99,49 +100,216 @@ void main() {
100 test('handle quote being 0 correctly', () {
101 final zeroRate = ExchangeRate(
102 base: CryptoCurrency.btc,
102 - quote: Money(BigInt.zero, EUR),
103 + quote: Money(BigInt.zero, EUR, EUR.decimals),
104 );
105 expect(zeroRate.convert(Money.fromInt(100, CryptoCurrency.btc)), Money(BigInt.zero, EUR));
106 expect(zeroRate.convert(Money.fromInt(100, EUR)), Money(BigInt.zero, CryptoCurrency.btc));
107 });
107 - });
108 -}
108
110 -class FiatCurrency implements Currency {
111 - const FiatCurrency({
112 - required this.symbol,
113 - required this.countryCode,
114 - required this.fullName,
115 - this.decimals = 2,
116 - });
109 + test('handle super low quote correctly', () {
110 + final zeroRate = ExchangeRate(
111 + base: CryptoCurrency.shib,
112 + quote: Money.parse("0.00000444", EUR, strictParsing: false),
113 + );
114
118 - final String countryCode;
115 + expect(
116 + zeroRate.convert(Money.parse("1", CryptoCurrency.shib)),
117 + Money.parse("0.00000444", EUR, strictParsing: false),
118 + );
119 + expect(
120 + zeroRate.convert(Money.fromInt(10000, EUR)),
121 + Money.parse("22522522.522522522522522522", CryptoCurrency.shib),
122 + );
123 +
124 + expect(
125 + zeroRate.convert(Money.parse("200", CryptoCurrency.shib)),
126 + Money.parse("0.000888", EUR, strictParsing: false),
127 + );
128 + });
129
120 - @override
121 - final String fullName;
130 + group('Precision and scale', () {
131 + final rate = ExchangeRate(base: CryptoCurrency.btc, quote: Money.parse("60000", EUR));
132
123 - @override
124 - final int decimals;
133 + test('convert is insensitive to the input scale (forward)', () {
134 + // Same value, three different internal scales.
135 + final coarse = Money(BigInt.one, CryptoCurrency.btc, 0);
136 + final normal = Money.parse("1", CryptoCurrency.btc);
137 + final fine = Money.parse("1.000000000000000000", CryptoCurrency.btc, strictParsing: false);
138
126 - @override
127 - final String symbol;
139 + expect(rate.convert(coarse), Money.parse("60000.00", EUR));
140 + expect(rate.convert(normal), Money.parse("60000.00", EUR));
141 + expect(rate.convert(fine), Money.parse("60000.00", EUR));
142 + });
143
129 - @override
130 - String? get iconPath => throw UnimplementedError();
144 + test('convert is insensitive to the input scale (reverse)', () {
145 + expect(
146 + rate.convert(Money(BigInt.one, EUR, 0)), // €1 at scale 0
147 + rate.convert(Money.parse("1", EUR)), // €1 at scale 2
148 + );
149 + });
150
132 - @override
133 - String get name => throw UnimplementedError();
151 + test('sub-unit precision in the input does not leak into the result', () {
152 + // ad = 18 here, well past BTC's 8. The divisor must cancel the *amount's*
153 + // scale, not the base currency's.
154 + final overPrecise =
155 + Money.parse("1.000000000000000001", CryptoCurrency.btc, strictParsing: false);
156
135 - @override
136 - String? get tag => throw UnimplementedError();
157 + expect(rate.convert(overPrecise), Money.parse("60000.00", EUR));
158 + });
159
138 - @override
139 - Money parseAmount(String value) => Money.parse(value, this);
160 + test('the result carries the quote scale, not the currency default', () {
161 + final preciseRate = ExchangeRate(
162 + base: CryptoCurrency.btc,
163 + quote: Money.parse("60000.123456", EUR, strictParsing: false),
164 + );
165
141 - @override
142 - Money? tryParseAmount(String value) => Money.tryParse(value, this);
143 -}
166 + final result = preciseRate.convert(Money.parse("1", CryptoCurrency.btc));
167 +
168 + expect(result.decimals, 6);
169 + expect(result, Money.parse("60000.123456", EUR, strictParsing: false));
170 + });
171 +
172 + test('a quote scale finer than the currency survives the round trip', () {
173 + final preciseRate = ExchangeRate(
174 + base: CryptoCurrency.btc,
175 + quote: Money.parse("60000.50", EUR, strictParsing: false),
176 + );
177 +
178 + expect(
179 + preciseRate.convert(preciseRate.convert(Money.parse("1", CryptoCurrency.btc))),
180 + Money.parse("1", CryptoCurrency.btc),
181 + );
182 + });
183 + });
184 +
185 + group('Truncation', () {
186 + final rate = ExchangeRate(base: CryptoCurrency.btc, quote: Money.parse("60000", EUR));
187 +
188 + test('truncates rather than rounding, even past the halfway point', () {
189 + // 9 sats = 0.54 cents. Rounding would give 1.
190 + expect(rate.convert(Money(BigInt.from(9), CryptoCurrency.btc)), Money.zero(EUR));
191 + // 1 cent = 16.66 sats. Rounding would give 17.
192 + expect(rate.convert(Money(BigInt.one, EUR)), Money.fromInt(16, CryptoCurrency.btc));
193 + });
194 +
195 + test('truncates toward zero for negatives, not downward', () {
196 + // -0.54 cents must be 0, not -1.
197 + expect(rate.convert(Money(BigInt.from(-9), CryptoCurrency.btc)), Money.zero(EUR));
198 + expect(rate.convert(Money(BigInt.from(-1), EUR)), Money.fromInt(-16, CryptoCurrency.btc));
199 + });
200 +
201 + test('truncation is symmetric around zero', () {
202 + final positive = rate.convert(Money(BigInt.from(7), EUR));
203 + final negative = rate.convert(Money(BigInt.from(-7), EUR));
204 +
205 + expect(negative, -positive);
206 + });
207 +
208 + test('round trip loses less than one quote base unit', () {
209 + final original = Money.parse("0.12345678", CryptoCurrency.btc);
210 + final back = rate.convert(rate.convert(original));
211 + final oneQuoteUnitInBaseUnits =
212 + BigInt.from(10).pow(CryptoCurrency.btc.decimals) ~/ rate.quote.amount;
213 +
214 + expect(
215 + (original - back).amount.abs(),
216 + lessThanOrEqualTo(oneQuoteUnitInBaseUnits + BigInt.one),
217 + );
218 + });
219 +
220 + test('round trip never inflates the amount', () {
221 + // Both truncations round toward zero, so the result can only shrink.
222 + for (final source in ["0.12345678", "1", "0.5", "21000000"]) {
223 + final original = Money.parse(source, CryptoCurrency.btc);
224 + expect(
225 + rate.convert(rate.convert(original)) <= original,
226 + isTrue,
227 + reason: "round trip grew $source",
228 + );
229 + }
230 + });
231
145 -const EUR = FiatCurrency(symbol: 'EUR', countryCode: "eur", fullName: "Euro");
146 -const USD = FiatCurrency(symbol: 'USD', countryCode: "usd", fullName: "US Dollar");
147 -const JPY = FiatCurrency(symbol: 'JPY', countryCode: "jpn", fullName: "Japanese Yen", decimals: 0);
232 + test('a value below one base unit of the quote collapses to zero', () {
233 + final back = rate.convert(rate.convert(Money(BigInt.one, CryptoCurrency.btc)));
234 +
235 + expect(back.isZero, isTrue);
236 + });
237 + });
238 +
239 + group('Extreme decimal spreads', () {
240 + test('30 decimals against 0 decimals: NANO/JPY', () {
241 + final rate = ExchangeRate(base: CryptoCurrency.nano, quote: Money.parse("150", JPY));
242 +
243 + expect(rate.convert(Money.parse("1", CryptoCurrency.nano)), Money.fromInt(150, JPY));
244 + expect(rate.convert(Money.fromInt(150, JPY)), Money.parse("1", CryptoCurrency.nano));
245 + expect(rate.convert(Money.fromInt(1, JPY)).decimals, 30);
246 + });
247 +
248 + test('0 decimals against 18 decimals: JPY/SHIB', () {
249 + final rate = ExchangeRate(base: JPY, quote: Money.parse("2500", CryptoCurrency.shib));
250 +
251 + expect(rate.convert(Money.fromInt(2, JPY)), Money.parse("5000", CryptoCurrency.shib));
252 + expect(rate.convert(Money.parse("5000", CryptoCurrency.shib)), Money.fromInt(2, JPY));
253 + });
254 +
255 + test('no overflow at the extremes', () {
256 + final rate = ExchangeRate(
257 + base: CryptoCurrency.shib,
258 + quote: Money.parse("0.00000444", EUR, strictParsing: false),
259 + );
260 + final totalSupply = Money.parse("589000000000000", CryptoCurrency.shib);
261 +
262 + expect(rate.convert(totalSupply).isNegative, isFalse);
263 + expect(rate.convert(rate.convert(totalSupply)) <= totalSupply, isTrue);
264 + });
265 + });
266 +
267 + group('Degenerate pairs', () {
268 + test('identity pair returns the amount untouched', () {
269 + final identity = ExchangeRate(base: EUR, quote: Money.parse("1", EUR));
270 +
271 + expect(identity.convert(Money.parse("42.42", EUR)), Money.parse("42.42", EUR));
272 + expect(identity.convert(Money.zero(EUR)), Money.zero(EUR));
273 + });
274 +
275 + test('a same-currency pair silently ignores its own rate', () {
276 + final bogus = ExchangeRate(base: EUR, quote: Money.parse("2", EUR));
277 + expect(bogus.convert(Money.parse("10", EUR)), Money.parse("10", EUR));
278 + });
279 +
280 + test('a rate of exactly one is a no-op in both directions', () {
281 + final rate = ExchangeRate(base: JPY, quote: Money.fromInt(100, EUR));
282 +
283 + expect(rate.convert(Money.fromInt(7, JPY)), Money.fromInt(700, EUR));
284 + expect(rate.convert(Money.fromInt(700, EUR)), Money.fromInt(7, JPY));
285 + });
286 +
287 + test('negative quotes propagate their sign', () {
288 + final inverted = ExchangeRate(base: CryptoCurrency.btc, quote: Money.parse("-60000", EUR));
289 +
290 + expect(
291 + inverted.convert(Money.parse("1", CryptoCurrency.btc)),
292 + Money.parse("-60000.00", EUR),
293 + );
294 + expect(
295 + inverted.convert(Money.parse("-60000.00", EUR)),
296 + Money.parse("1", CryptoCurrency.btc),
297 + );
298 + });
299 +
300 + test('zero amount with a zero quote', () {
301 + final zeroRate = ExchangeRate(base: CryptoCurrency.btc, quote: Money.zero(EUR));
302 +
303 + expect(zeroRate.convert(Money.zero(CryptoCurrency.btc)), Money.zero(EUR));
304 + expect(zeroRate.convert(Money.zero(EUR)), Money.zero(CryptoCurrency.btc));
305 + });
306 +
307 + test('rejects a foreign currency in both slots', () {
308 + final rate = ExchangeRate(base: CryptoCurrency.btc, quote: Money.parse("60000", EUR));
309 +
310 + expect(() => rate.convert(Money.parse("1", CryptoCurrency.xmr)), throwsArgumentError);
311 + expect(() => rate.convert(Money.parse("1", USD)), throwsArgumentError);
312 + });
313 + });
314 + });
315 +}
cw_core/test/amount/money_test.dart
+121 -2
@@ -2,6 +2,8 @@ import "package:cw_core/amount/money.dart";
2 import "package:cw_core/crypto_currency.dart";
3 import "package:flutter_test/flutter_test.dart";
4
5 +import "utils.dart";
6 +
7 void main() {
8 group("Money", () {
9 test("parse", () {
@@ -27,7 +29,7 @@ void main() {
29 expect(() => Money.parse("1,11", CryptoCurrency.btc), throwsFormatException);
30
31 // To many decimals
30 - expect(() => Money.parse("-1.000000000000000", CryptoCurrency.btc), throwsFormatException);
32 + expect(() => Money.parse("-1.0000000000000001", CryptoCurrency.btc), throwsFormatException);
33
34 money = Money.parse("1", CryptoCurrency.btc, isBaseUnit: true);
35 expect(money.amount, BigInt.from(1));
@@ -66,7 +68,7 @@ void main() {
68 expect(money?.amount, isNull);
69
70 // To many decimals
69 - money = Money.tryParse("-1.000000000000000", CryptoCurrency.btc);
71 + money = Money.tryParse("-1.0000000000000001", CryptoCurrency.btc);
72 expect(money?.amount, isNull);
73
74 money = Money.tryParse("1", CryptoCurrency.btc, isBaseUnit: true);
@@ -373,5 +375,122 @@ void main() {
375 });
376 });
377 });
378 +
379 + group("different scales", () {
380 + final coarseOne = Money(BigInt.one, CryptoCurrency.btc, 0);
381 + final fineOne = Money(BigInt.from(100000000), CryptoCurrency.btc, 8);
382 +
383 + test("== across scales", () {
384 + expect(coarseOne, equals(fineOne));
385 + expect(coarseOne, isNot(equals(Money(BigInt.two, CryptoCurrency.btc, 0))));
386 + });
387 +
388 + test("equal values across scales hash equally", () {
389 + expect(coarseOne.hashCode, equals(fineOne.hashCode));
390 +
391 + expect(
392 + Money(BigInt.from(-11), CryptoCurrency.btc, 1).hashCode,
393 + equals(Money(BigInt.from(-110000000), CryptoCurrency.btc, 8).hashCode),
394 + );
395 + });
396 +
397 + test("zero hashes equally at every scale", () {
398 + final hashes = [0, 1, 8, 18, 30]
399 + .map((s) => Money(BigInt.zero, CryptoCurrency.btc, s).hashCode)
400 + .toSet();
401 +
402 + expect(hashes, hasLength(1));
403 + });
404 +
405 + test("whole units are not confused with their digits", () {
406 + // (100, scale 0) is one hundred, not one: stripping must stop at 0.
407 + final oneHundred = Money(BigInt.from(100), CryptoCurrency.btc, 0);
408 +
409 + expect(oneHundred, isNot(equals(coarseOne)));
410 + expect(oneHundred.hashCode, isNot(equals(coarseOne.hashCode)));
411 + });
412 +
413 + test("Set deduplicates across scales", () {
414 + expect(
415 + {
416 + Money(BigInt.from(11), CryptoCurrency.btc, 1),
417 + Money(BigInt.from(110000000), CryptoCurrency.btc, 8),
418 + Money(BigInt.from(1100), CryptoCurrency.btc, 3),
419 + },
420 + hasLength(1),
421 + );
422 + });
423 +
424 + test("+ aligns operands", () {
425 + expect(coarseOne + fineOne, equals(Money(BigInt.two, CryptoCurrency.btc, 0)));
426 + expect(coarseOne + fineOne, equals(fineOne + coarseOne));
427 + });
428 +
429 + test("- aligns operands", () {
430 + // Regression: this used to subtract the raw amounts, i.e. 1 - 100000000.
431 + expect((coarseOne - fineOne).isZero, isTrue);
432 + expect((fineOne - coarseOne).isZero, isTrue);
433 +
434 + final threeCoarse = Money(BigInt.from(3), CryptoCurrency.btc, 0);
435 + expect(threeCoarse - fineOne, equals(Money(BigInt.two, CryptoCurrency.btc, 0)));
436 + expect(threeCoarse - fineOne, equals(-(fineOne - threeCoarse)));
437 + });
438 +
439 + test("the result keeps the finer scale", () {
440 + expect((coarseOne + fineOne).decimals, greaterThanOrEqualTo(fineOne.decimals));
441 + });
442 +
443 + test("sub-unit precision survives a coarse operand", () {
444 + final oneSatoshi = Money(BigInt.one, CryptoCurrency.btc, 8);
445 +
446 + expect(
447 + coarseOne + oneSatoshi,
448 + equals(Money(BigInt.from(100000001), CryptoCurrency.btc, 8)),
449 + );
450 + });
451 +
452 + test("comparison operators align", () {
453 + expect(coarseOne < fineOne, isFalse);
454 + expect(coarseOne <= fineOne, isTrue);
455 + expect(coarseOne >= fineOne, isTrue);
456 + expect(coarseOne > fineOne, isFalse);
457 + expect(Money(BigInt.two, CryptoCurrency.btc, 0) > fineOne, isTrue);
458 + });
459 +
460 + test("compareTo aligns and agrees with ==", () {
461 + expect(coarseOne.compareTo(fineOne), isZero);
462 + expect(coarseOne.compareTo(fineOne) == 0, equals(coarseOne == fineOne));
463 + expect(Money(BigInt.two, CryptoCurrency.btc, 0).compareTo(fineOne), isPositive);
464 + expect(Money(BigInt.zero, CryptoCurrency.btc, 0).compareTo(fineOne), isNegative);
465 + });
466 +
467 + test("toStringWithPrecision", () {
468 + expect(
469 + Money.parse("60000.123456", USD, strictParsing: false).toStringWithPrecision(),
470 + "60000.12",
471 + );
472 + expect(
473 + Money.parse("1.23456789", USD, strictParsing: false).toStringWithPrecision(),
474 + "1.23",
475 + );
476 + expect(Money(BigInt.one, CryptoCurrency.btc, 0).toStringWithPrecision(), "1");
477 + expect(
478 + Money(BigInt.one, CryptoCurrency.btc, 0).toStringWithPrecision(useBaseUnit: true),
479 + "100000000",
480 + );
481 + });
482 +
483 + test("toString", () {
484 + expect(
485 + Money.parse("60000.123456", CryptoCurrency.btc, strictParsing: false).toString(),
486 + "60000.123456",
487 + );
488 + expect(
489 + Money.parse("1.23456789", CryptoCurrency.btc, strictParsing: false).toString(),
490 + "1.23456789",
491 + );
492 + expect(Money(BigInt.one, CryptoCurrency.btc, 0).toString(), "1");
493 + });
494 + });
495 });
496 }
cw_core/test/amount/utils.dart new
+41
@@ -0,0 +1,41 @@
1 +import 'package:cw_core/amount/money.dart';
2 +import 'package:cw_core/currency.dart';
3 +
4 +class FiatCurrency implements Currency {
5 + const FiatCurrency({
6 + required this.symbol,
7 + required this.countryCode,
8 + required this.fullName,
9 + this.decimals = 2,
10 + });
11 +
12 + final String countryCode;
13 +
14 + @override
15 + final String fullName;
16 +
17 + @override
18 + final int decimals;
19 +
20 + @override
21 + final String symbol;
22 +
23 + @override
24 + String? get iconPath => throw UnimplementedError();
25 +
26 + @override
27 + String get name => throw UnimplementedError();
28 +
29 + @override
30 + String? get tag => throw UnimplementedError();
31 +
32 + @override
33 + Money parseAmount(String value) => Money.parse(value, this);
34 +
35 + @override
36 + Money? tryParseAmount(String value) => Money.tryParse(value, this);
37 +}
38 +
39 +const EUR = FiatCurrency(symbol: 'EUR', countryCode: "eur", fullName: "Euro");
40 +const USD = FiatCurrency(symbol: 'USD', countryCode: "usd", fullName: "US Dollar");
41 +const JPY = FiatCurrency(symbol: 'JPY', countryCode: "jpn", fullName: "Japanese Yen", decimals: 0);