dev
dart 315 lines 12 KB
Raw
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:flutter_test/flutter_test.dart';
5
6 import 'utils.dart';
7
8 void main() {
9 group('ExchangeRate', () {
10 final rate = ExchangeRate(
11 base: CryptoCurrency.btc,
12 quote: Money.parse("60000", EUR),
13 );
14
15 test('convert base currency to quote currency: 1 BTC to 60.000 EUR', () {
16 final result = rate.convert(Money.parse('1', CryptoCurrency.btc));
17 expect(result, Money.parse("60000.00", EUR));
18 });
19
20 test('convert base currency to quote currency: 0.5 BTC to 30.000 EUR', () {
21 final result = rate.convert(Money.parse('0.5', CryptoCurrency.btc));
22 expect(result, Money.parse("30000.00", EUR));
23 });
24
25 test('convert quote currency to base currency: 60.000 EUR to 1 BTC', () {
26 final result = rate.convert(Money.parse("60000.00", EUR));
27 expect(result, Money.parse('1', CryptoCurrency.btc));
28 });
29
30 test('convert quote currency to base currency: 30 EUR to 0.0005 BTC', () {
31 final result = rate.convert(Money.parse("30.00", EUR));
32 expect(result, Money.parse('0.0005', CryptoCurrency.btc));
33 });
34 });
35
36 group('Edge Cases', () {
37 final rate = ExchangeRate(
38 base: CryptoCurrency.btc,
39 quote: Money.parse("60000", EUR),
40 );
41
42 test('Converting zero turns into zero', () {
43 expect(rate.convert(Money.zero(CryptoCurrency.btc)), Money.zero(EUR));
44 expect(rate.convert(Money.zero(EUR)), Money.zero(CryptoCurrency.btc));
45 });
46
47 test('truncate to smallest unit', () {
48 final result = rate.convert(Money(BigInt.one, CryptoCurrency.btc)); // 1 sat
49 expect(result, Money.zero(EUR));
50 });
51
52 test('Truncate base unit of base currency', () {
53 final result = rate.convert(Money(BigInt.one, EUR));
54 expect(result, Money.fromInt(16, CryptoCurrency.btc));
55 });
56
57 test('No int overflow with large numbers', () {
58 final allBtc = Money.parse('21000000', CryptoCurrency.btc);
59 final result = rate.convert(allBtc);
60 expect(result, Money(BigInt.from(126000000000000), EUR));
61 });
62
63 test('truncation for negative amounts', () {
64 final result = rate.convert(Money.fromInt(-1, EUR));
65 expect(result, Money.fromInt(-16, CryptoCurrency.btc));
66 });
67
68 test('Base currency without decimals places: JPY', () {
69 final rate = ExchangeRate(
70 base: JPY,
71 quote: Money.fromInt(1, EUR),
72 );
73
74 expect(rate.convert(Money.fromInt(500, JPY)), Money.fromInt(500, EUR));
75 expect(rate.convert(Money.fromInt(500, EUR)), Money.fromInt(500, JPY));
76 });
77
78 test('Base currency with 18 decimals places: ETH', () {
79 final rate = ExchangeRate(
80 base: CryptoCurrency.eth,
81 quote: Money.fromInt(300000, EUR),
82 );
83
84 expect(rate.convert(Money.parse('1', CryptoCurrency.eth)), Money.fromInt(300000, EUR));
85 expect(rate.convert(Money.fromInt(3000, EUR)), Money.parse('0.01', CryptoCurrency.eth));
86 });
87
88 test('Smalles base unit is less than smalles base unit of quote', () {
89 final rate = ExchangeRate(
90 base: CryptoCurrency.eth,
91 quote: Money.fromInt(300000, EUR),
92 );
93 expect(rate.convert(Money(BigInt.one, CryptoCurrency.eth)), Money(BigInt.zero, EUR));
94 });
95
96 test('Cannot convert currency outside of pair', () {
97 expect(() => rate.convert(Money.parse("60000", USD)), throwsArgumentError);
98 });
99
100 test('handle quote being 0 correctly', () {
101 final zeroRate = ExchangeRate(
102 base: CryptoCurrency.btc,
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 });
108
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
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
130 group('Precision and scale', () {
131 final rate = ExchangeRate(base: CryptoCurrency.btc, quote: Money.parse("60000", EUR));
132
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
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
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
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
157 expect(rate.convert(overPrecise), Money.parse("60000.00", EUR));
158 });
159
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
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
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 }