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: