mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-26 06:08:40 +01:00
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>
This commit is contained in:
co-authored by
Robert Malikowski
Omar Hatem
parent
6fc27dd3a0
commit
d41fd23652
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user