add-exchange-rate (#3397)

* feat: add ExchangeRate class for currency conversion with tests * docs: add detailed comments to ExchangeRate class * refactor: rename `baseCurrency` to `base` in `ExchangeRate` * auto-reformat --------- Co-authored-by: Robert Malikowski <malikowskirobert@gmail.com> Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>

Konstantin Ullrich committed Jul 16, 2026 at 01:05 UTC d41fd23652d1861c6aebbba3ad9d3c1e7078826e
9 files changed +218 -31
cw_bitcoin/pubspec.lock
+5 -5
@@ -634,10 +634,10 @@ packages:
634 dependency: transitive
635 description:
636 name: meta
637 - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
637 + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
638 url: "https://pub.dev"
639 source: hosted
640 - version: "1.17.0"
640 + version: "1.18.0"
641 mime:
642 dependency: transitive
643 description:
@@ -1100,10 +1100,10 @@ packages:
1100 dependency: transitive
1101 description:
1102 name: test_api
1103 - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
1103 + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
1104 url: "https://pub.dev"
1105 source: hosted
1106 - version: "0.7.10"
1106 + version: "0.7.11"
1107 torch_dart:
1108 dependency: transitive
1109 description:
@@ -1322,5 +1322,5 @@ packages:
1322 source: hosted
1323 version: "2.2.2"
1324 sdks:
1325 - dart: ">=3.9.0 <4.0.0"
1325 + dart: ">=3.10.0-0 <4.0.0"
1326 flutter: ">=3.29.0"
cw_core/lib/amount/exchange_rate.dart new
+40
@@ -0,0 +1,40 @@
1 +import 'package:cw_core/amount/money.dart';
2 +import 'package:cw_core/currency.dart';
3 +
4 +class ExchangeRate {
5 + /// The currency being priced (e.g. BTC in a BTC/USD pair).
6 + final Currency base;
7 +
8 + /// The price of one whole unit of [base], in the quote currency
9 + /// (e.g. 45000 USD in a BTC/USD pair).
10 + final Money quote;
11 +
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.
17 + ///
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);
24 +
25 + if (amount.currency == base) {
26 + if (quote.isZero) return Money.zero(quote.currency);
27 +
28 + return Money(amount.amount * quote.amount ~/ scale, quote.currency);
29 + }
30 +
31 + if (amount.currency == quote.currency) {
32 + if (quote.isZero) return Money.zero(base);
33 +
34 + return Money(amount.amount * scale ~/ quote.amount, base);
35 + }
36 +
37 + throw ArgumentError(
38 + "Unable to convert ${amount.currency.symbol} in ${base.symbol}/${quote.currency.symbol} pair");
39 + }
40 +}
cw_core/pubspec.lock
+5 -5
@@ -394,10 +394,10 @@ packages:
394 dependency: transitive
395 description:
396 name: meta
397 - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
397 + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
398 url: "https://pub.dev"
399 source: hosted
400 - version: "1.17.0"
400 + version: "1.18.0"
401 mime:
402 dependency: transitive
403 description:
@@ -738,10 +738,10 @@ packages:
738 dependency: transitive
739 description:
740 name: test_api
741 - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
741 + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
742 url: "https://pub.dev"
743 source: hosted
744 - version: "0.7.10"
744 + version: "0.7.11"
745 torch_dart:
746 dependency: "direct main"
747 description:
@@ -838,5 +838,5 @@ packages:
838 source: hosted
839 version: "3.1.3"
840 sdks:
841 - dart: ">=3.9.0 <4.0.0"
841 + dart: ">=3.10.0-0 <4.0.0"
842 flutter: ">=3.27.0"
cw_core/test/amount/exchange_rate_test.dart new
+147
@@ -0,0 +1,147 @@
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';
5 +import 'package:flutter_test/flutter_test.dart';
6 +
7 +void main() {
8 + group('ExchangeRate', () {
9 + final rate = ExchangeRate(
10 + baseCurrency: CryptoCurrency.btc,
11 + quote: Money.parse("60000", EUR),
12 + );
13 +
14 + test('convert base currency to quote currency: 1 BTC to 60.000 EUR', () {
15 + final result = rate.convert(Money.parse('1', CryptoCurrency.btc));
16 + expect(result, Money.parse("60000.00", EUR));
17 + });
18 +
19 + test('convert base currency to quote currency: 0.5 BTC to 30.000 EUR', () {
20 + final result = rate.convert(Money.parse('0.5', CryptoCurrency.btc));
21 + expect(result, Money.parse("30000.00", EUR));
22 + });
23 +
24 + test('convert quote currency to base currency: 60.000 EUR to 1 BTC', () {
25 + final result = rate.convert(Money.parse("60000.00", EUR));
26 + expect(result, Money.parse('1', CryptoCurrency.btc));
27 + });
28 +
29 + test('convert quote currency to base currency: 30 EUR to 0.0005 BTC', () {
30 + final result = rate.convert(Money.parse("30.00", EUR));
31 + expect(result, Money.parse('0.0005', CryptoCurrency.btc));
32 + });
33 + });
34 +
35 + group('Edge Cases', () {
36 + final rate = ExchangeRate(
37 + baseCurrency: CryptoCurrency.btc,
38 + quote: Money.parse("60000", EUR),
39 + );
40 +
41 + test('Converting zero turns into zero', () {
42 + expect(rate.convert(Money.zero(CryptoCurrency.btc)), Money.zero(EUR));
43 + expect(rate.convert(Money.zero(EUR)), Money.zero(CryptoCurrency.btc));
44 + });
45 +
46 + test('truncate to smallest unit', () {
47 + final result = rate.convert(Money(BigInt.one, CryptoCurrency.btc)); // 1 sat
48 + expect(result, Money.zero(EUR));
49 + });
50 +
51 + test('Truncate base unit of base currency', () {
52 + final result = rate.convert(Money(BigInt.one, EUR));
53 + expect(result, Money.fromInt(16, CryptoCurrency.btc));
54 + });
55 +
56 + test('No int overflow with large numbers', () {
57 + final allBtc = Money.parse('21000000', CryptoCurrency.btc);
58 + final result = rate.convert(allBtc);
59 + expect(result, Money(BigInt.from(126000000000000), EUR));
60 + });
61 +
62 + test('truncation for negative amounts', () {
63 + final result = rate.convert(Money.fromInt(-1, EUR));
64 + expect(result, Money.fromInt(-16, CryptoCurrency.btc));
65 + });
66 +
67 + test('Base currency without decimals places: JPY', () {
68 + final rate = ExchangeRate(
69 + baseCurrency: JPY,
70 + quote: Money.fromInt(1, EUR),
71 + );
72 +
73 + expect(rate.convert(Money.fromInt(500, JPY)), Money.fromInt(500, EUR));
74 + expect(rate.convert(Money.fromInt(500, EUR)), Money.fromInt(500, JPY));
75 + });
76 +
77 + test('Base currency with 18 decimals places: ETH', () {
78 + final rate = ExchangeRate(
79 + baseCurrency: CryptoCurrency.eth,
80 + quote: Money.fromInt(300000, EUR),
81 + );
82 +
83 + expect(rate.convert(Money.parse('1', CryptoCurrency.eth)), Money.fromInt(300000, EUR));
84 + expect(rate.convert(Money.fromInt(3000, EUR)), Money.parse('0.01', CryptoCurrency.eth));
85 + });
86 +
87 + test('Smalles base unit is less than smalles base unit of quote', () {
88 + final rate = ExchangeRate(
89 + baseCurrency: CryptoCurrency.eth,
90 + quote: Money.fromInt(300000, EUR),
91 + );
92 + expect(rate.convert(Money(BigInt.one, CryptoCurrency.eth)), Money(BigInt.zero, EUR));
93 + });
94 +
95 + test('Cannot convert currency outside of pair', () {
96 + expect(() => rate.convert(Money.parse("60000", USD)), throwsArgumentError);
97 + });
98 +
99 + test('handle quote being 0 correctly', () {
100 + final zeroRate = ExchangeRate(
101 + baseCurrency: CryptoCurrency.btc,
102 + quote: Money(BigInt.zero, EUR),
103 + );
104 + expect(zeroRate.convert(Money.fromInt(100, CryptoCurrency.btc)), Money(BigInt.zero, EUR));
105 + expect(zeroRate.convert(Money.fromInt(100, EUR)), Money(BigInt.zero, CryptoCurrency.btc));
106 + });
107 + });
108 +}
109 +
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 + });
117 +
118 + final String countryCode;
119 +
120 + @override
121 + final String fullName;
122 +
123 + @override
124 + final int decimals;
125 +
126 + @override
127 + final String symbol;
128 +
129 + @override
130 + String? get iconPath => throw UnimplementedError();
131 +
132 + @override
133 + String get name => throw UnimplementedError();
134 +
135 + @override
136 + String? get tag => throw UnimplementedError();
137 +
138 + @override
139 + Money parseAmount(String value) => Money.parse(value, this);
140 +
141 + @override
142 + Money? tryParseAmount(String value) => Money.tryParse(value, this);
143 +}
144 +
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);
cw_decred/pubspec.lock
+4 -4
@@ -417,10 +417,10 @@ packages:
417 dependency: transitive
418 description:
419 name: meta
420 - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
420 + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
421 url: "https://pub.dev"
422 source: hosted
423 - version: "1.17.0"
423 + version: "1.18.0"
424 mime:
425 dependency: transitive
426 description:
@@ -769,10 +769,10 @@ packages:
769 dependency: transitive
770 description:
771 name: test_api
772 - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
772 + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
773 url: "https://pub.dev"
774 source: hosted
775 - version: "0.7.10"
775 + version: "0.7.11"
776 torch_dart:
777 dependency: transitive
778 description:
cw_monero/pubspec.lock
+5 -5
@@ -505,10 +505,10 @@ packages:
505 dependency: transitive
506 description:
507 name: meta
508 - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
508 + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
509 url: "https://pub.dev"
510 source: hosted
511 - version: "1.17.0"
511 + version: "1.18.0"
512 mime:
513 dependency: transitive
514 description:
@@ -906,10 +906,10 @@ packages:
906 dependency: transitive
907 description:
908 name: test_api
909 - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
909 + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
910 url: "https://pub.dev"
911 source: hosted
912 - version: "0.7.10"
912 + version: "0.7.11"
913 torch_dart:
914 dependency: transitive
915 description:
@@ -1048,5 +1048,5 @@ packages:
1048 source: hosted
1049 version: "3.1.3"
1050 sdks:
1051 - dart: ">=3.9.0 <4.0.0"
1051 + dart: ">=3.10.0-0 <4.0.0"
1052 flutter: ">=3.24.0"
cw_nano/pubspec.lock
+4 -4
@@ -462,10 +462,10 @@ packages:
462 dependency: transitive
463 description:
464 name: meta
465 - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
465 + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
466 url: "https://pub.dev"
467 source: hosted
468 - version: "1.17.0"
468 + version: "1.18.0"
469 mime:
470 dependency: transitive
471 description:
@@ -887,10 +887,10 @@ packages:
887 dependency: transitive
888 description:
889 name: test_api
890 - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
890 + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
891 url: "https://pub.dev"
892 source: hosted
893 - version: "0.7.10"
893 + version: "0.7.11"
894 torch_dart:
895 dependency: transitive
896 description:
cw_wownero/pubspec.lock
+4 -4
@@ -417,10 +417,10 @@ packages:
417 dependency: transitive
418 description:
419 name: meta
420 - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
420 + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
421 url: "https://pub.dev"
422 source: hosted
423 - version: "1.17.0"
423 + version: "1.18.0"
424 mime:
425 dependency: transitive
426 description:
@@ -786,10 +786,10 @@ packages:
786 dependency: transitive
787 description:
788 name: test_api
789 - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
789 + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
790 url: "https://pub.dev"
791 source: hosted
792 - version: "0.7.10"
792 + version: "0.7.11"
793 torch_dart:
794 dependency: transitive
795 description:
cw_zano/pubspec.lock
+4 -4
@@ -422,10 +422,10 @@ packages:
422 dependency: transitive
423 description:
424 name: meta
425 - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
425 + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
426 url: "https://pub.dev"
427 source: hosted
428 - version: "1.17.0"
428 + version: "1.18.0"
429 mime:
430 dependency: transitive
431 description:
@@ -783,10 +783,10 @@ packages:
783 dependency: transitive
784 description:
785 name: test_api
786 - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
786 + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
787 url: "https://pub.dev"
788 source: hosted
789 - version: "0.7.10"
789 + version: "0.7.11"
790 torch_dart:
791 dependency: transitive
792 description: