From d41fd23652d1861c6aebbba3ad9d3c1e7078826e Mon Sep 17 00:00:00 2001 From: Konstantin Ullrich Date: Thu, 16 Jul 2026 01:05:11 +0200 Subject: [PATCH] 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 Co-authored-by: Omar Hatem --- cw_bitcoin/pubspec.lock | 10 +- cw_core/lib/amount/exchange_rate.dart | 40 ++++++ cw_core/pubspec.lock | 10 +- cw_core/test/amount/exchange_rate_test.dart | 147 ++++++++++++++++++++ cw_decred/pubspec.lock | 8 +- cw_monero/pubspec.lock | 10 +- cw_nano/pubspec.lock | 8 +- cw_wownero/pubspec.lock | 8 +- cw_zano/pubspec.lock | 8 +- 9 files changed, 218 insertions(+), 31 deletions(-) create mode 100644 cw_core/lib/amount/exchange_rate.dart create mode 100644 cw_core/test/amount/exchange_rate_test.dart diff --git a/cw_bitcoin/pubspec.lock b/cw_bitcoin/pubspec.lock index b35b2bf09..6c15a9ad1 100644 --- a/cw_bitcoin/pubspec.lock +++ b/cw_bitcoin/pubspec.lock @@ -634,10 +634,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -1100,10 +1100,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" torch_dart: dependency: transitive description: @@ -1322,5 +1322,5 @@ packages: source: hosted version: "2.2.2" sdks: - dart: ">=3.9.0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.29.0" diff --git a/cw_core/lib/amount/exchange_rate.dart b/cw_core/lib/amount/exchange_rate.dart new file mode 100644 index 000000000..efcb06abd --- /dev/null +++ b/cw_core/lib/amount/exchange_rate.dart @@ -0,0 +1,40 @@ +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/currency.dart'; + +class ExchangeRate { + /// The currency being priced (e.g. BTC in a BTC/USD pair). + final Currency base; + + /// The price of one whole unit of [base], in the quote currency + /// (e.g. 45000 USD in a BTC/USD pair). + final Money quote; + + const ExchangeRate({required this.base, required this.quote}); + + /// Converts [amount] between the base and quote currencies. + /// + /// Results are truncated toward zero. + /// + /// Throws an [ArgumentError] if [amount]'s currency is not part of + /// this pair. + Money convert(Money amount) { + if (base == quote.currency && base == amount.currency) return amount; + + final scale = BigInt.from(10).pow(base.decimals); + + if (amount.currency == base) { + if (quote.isZero) return Money.zero(quote.currency); + + return Money(amount.amount * quote.amount ~/ scale, quote.currency); + } + + if (amount.currency == quote.currency) { + if (quote.isZero) return Money.zero(base); + + return Money(amount.amount * scale ~/ quote.amount, base); + } + + throw ArgumentError( + "Unable to convert ${amount.currency.symbol} in ${base.symbol}/${quote.currency.symbol} pair"); + } +} diff --git a/cw_core/pubspec.lock b/cw_core/pubspec.lock index 8eff1d338..908829bcb 100644 --- a/cw_core/pubspec.lock +++ b/cw_core/pubspec.lock @@ -394,10 +394,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -738,10 +738,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" torch_dart: dependency: "direct main" description: @@ -838,5 +838,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.27.0" diff --git a/cw_core/test/amount/exchange_rate_test.dart b/cw_core/test/amount/exchange_rate_test.dart new file mode 100644 index 000000000..2cd26a3a7 --- /dev/null +++ b/cw_core/test/amount/exchange_rate_test.dart @@ -0,0 +1,147 @@ +import 'package:cw_core/amount/exchange_rate.dart'; +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/currency.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ExchangeRate', () { + final rate = ExchangeRate( + baseCurrency: CryptoCurrency.btc, + quote: Money.parse("60000", EUR), + ); + + test('convert base currency to quote currency: 1 BTC to 60.000 EUR', () { + final result = rate.convert(Money.parse('1', CryptoCurrency.btc)); + expect(result, Money.parse("60000.00", EUR)); + }); + + test('convert base currency to quote currency: 0.5 BTC to 30.000 EUR', () { + final result = rate.convert(Money.parse('0.5', CryptoCurrency.btc)); + expect(result, Money.parse("30000.00", EUR)); + }); + + test('convert quote currency to base currency: 60.000 EUR to 1 BTC', () { + final result = rate.convert(Money.parse("60000.00", EUR)); + expect(result, Money.parse('1', CryptoCurrency.btc)); + }); + + test('convert quote currency to base currency: 30 EUR to 0.0005 BTC', () { + final result = rate.convert(Money.parse("30.00", EUR)); + expect(result, Money.parse('0.0005', CryptoCurrency.btc)); + }); + }); + + group('Edge Cases', () { + final rate = ExchangeRate( + baseCurrency: CryptoCurrency.btc, + quote: Money.parse("60000", EUR), + ); + + test('Converting zero turns into zero', () { + expect(rate.convert(Money.zero(CryptoCurrency.btc)), Money.zero(EUR)); + expect(rate.convert(Money.zero(EUR)), Money.zero(CryptoCurrency.btc)); + }); + + test('truncate to smallest unit', () { + final result = rate.convert(Money(BigInt.one, CryptoCurrency.btc)); // 1 sat + expect(result, Money.zero(EUR)); + }); + + test('Truncate base unit of base currency', () { + final result = rate.convert(Money(BigInt.one, EUR)); + expect(result, Money.fromInt(16, CryptoCurrency.btc)); + }); + + test('No int overflow with large numbers', () { + final allBtc = Money.parse('21000000', CryptoCurrency.btc); + final result = rate.convert(allBtc); + expect(result, Money(BigInt.from(126000000000000), EUR)); + }); + + test('truncation for negative amounts', () { + final result = rate.convert(Money.fromInt(-1, EUR)); + expect(result, Money.fromInt(-16, CryptoCurrency.btc)); + }); + + test('Base currency without decimals places: JPY', () { + final rate = ExchangeRate( + baseCurrency: JPY, + quote: Money.fromInt(1, EUR), + ); + + expect(rate.convert(Money.fromInt(500, JPY)), Money.fromInt(500, EUR)); + expect(rate.convert(Money.fromInt(500, EUR)), Money.fromInt(500, JPY)); + }); + + test('Base currency with 18 decimals places: ETH', () { + final rate = ExchangeRate( + baseCurrency: CryptoCurrency.eth, + quote: Money.fromInt(300000, EUR), + ); + + expect(rate.convert(Money.parse('1', CryptoCurrency.eth)), Money.fromInt(300000, EUR)); + expect(rate.convert(Money.fromInt(3000, EUR)), Money.parse('0.01', CryptoCurrency.eth)); + }); + + test('Smalles base unit is less than smalles base unit of quote', () { + final rate = ExchangeRate( + baseCurrency: CryptoCurrency.eth, + quote: Money.fromInt(300000, EUR), + ); + expect(rate.convert(Money(BigInt.one, CryptoCurrency.eth)), Money(BigInt.zero, EUR)); + }); + + test('Cannot convert currency outside of pair', () { + expect(() => rate.convert(Money.parse("60000", USD)), throwsArgumentError); + }); + + test('handle quote being 0 correctly', () { + final zeroRate = ExchangeRate( + baseCurrency: CryptoCurrency.btc, + quote: Money(BigInt.zero, EUR), + ); + expect(zeroRate.convert(Money.fromInt(100, CryptoCurrency.btc)), Money(BigInt.zero, EUR)); + expect(zeroRate.convert(Money.fromInt(100, EUR)), Money(BigInt.zero, CryptoCurrency.btc)); + }); + }); +} + +class FiatCurrency implements Currency { + const FiatCurrency({ + required this.symbol, + required this.countryCode, + required this.fullName, + this.decimals = 2, + }); + + final String countryCode; + + @override + final String fullName; + + @override + final int decimals; + + @override + final String symbol; + + @override + String? get iconPath => throw UnimplementedError(); + + @override + String get name => throw UnimplementedError(); + + @override + String? get tag => throw UnimplementedError(); + + @override + Money parseAmount(String value) => Money.parse(value, this); + + @override + Money? tryParseAmount(String value) => Money.tryParse(value, this); +} + +const EUR = FiatCurrency(symbol: 'EUR', countryCode: "eur", fullName: "Euro"); +const USD = FiatCurrency(symbol: 'USD', countryCode: "usd", fullName: "US Dollar"); +const JPY = FiatCurrency(symbol: 'JPY', countryCode: "jpn", fullName: "Japanese Yen", decimals: 0); diff --git a/cw_decred/pubspec.lock b/cw_decred/pubspec.lock index 9a568daa7..0b460e9f9 100644 --- a/cw_decred/pubspec.lock +++ b/cw_decred/pubspec.lock @@ -417,10 +417,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -769,10 +769,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" torch_dart: dependency: transitive description: diff --git a/cw_monero/pubspec.lock b/cw_monero/pubspec.lock index 79da36944..b32f56c5b 100644 --- a/cw_monero/pubspec.lock +++ b/cw_monero/pubspec.lock @@ -505,10 +505,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -906,10 +906,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" torch_dart: dependency: transitive description: @@ -1048,5 +1048,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.24.0" diff --git a/cw_nano/pubspec.lock b/cw_nano/pubspec.lock index 509d74498..2dd436985 100644 --- a/cw_nano/pubspec.lock +++ b/cw_nano/pubspec.lock @@ -462,10 +462,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -887,10 +887,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" torch_dart: dependency: transitive description: diff --git a/cw_wownero/pubspec.lock b/cw_wownero/pubspec.lock index 4b622b350..7fd7eac2d 100644 --- a/cw_wownero/pubspec.lock +++ b/cw_wownero/pubspec.lock @@ -417,10 +417,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -786,10 +786,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" torch_dart: dependency: transitive description: diff --git a/cw_zano/pubspec.lock b/cw_zano/pubspec.lock index c4602f532..a4a6c5381 100644 --- a/cw_zano/pubspec.lock +++ b/cw_zano/pubspec.lock @@ -422,10 +422,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -783,10 +783,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" torch_dart: dependency: transitive description: