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);