mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-26 06:08:40 +01:00
Enhance Money class with precision handling and zero trimming (#3548)
* feat: enhance Money class with precision handling, scale alignment, and trailing zero trimming
This commit is contained in:
@@ -2,6 +2,8 @@ import 'package:cw_core/amount/money.dart';
|
||||
import 'package:cw_core/currency.dart';
|
||||
|
||||
class ExchangeRate {
|
||||
const ExchangeRate({required this.base, required this.quote});
|
||||
|
||||
/// The currency being priced (e.g. BTC in a BTC/USD pair).
|
||||
final Currency base;
|
||||
|
||||
@@ -9,8 +11,6 @@ class ExchangeRate {
|
||||
/// (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.
|
||||
@@ -18,23 +18,31 @@ class ExchangeRate {
|
||||
/// 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 (base == quote.currency && base == amount.currency) {
|
||||
return amount;
|
||||
}
|
||||
|
||||
if (amount.currency == base) {
|
||||
if (quote.isZero) return Money.zero(quote.currency);
|
||||
final scale = BigInt.from(10).pow(amount.decimals);
|
||||
|
||||
return Money(amount.amount * quote.amount ~/ scale, quote.currency);
|
||||
return quote.isZero
|
||||
? Money.zero(quote.currency)
|
||||
: Money(amount.amount * quote.amount ~/ scale, quote.currency, quote.decimals);
|
||||
}
|
||||
|
||||
if (amount.currency == quote.currency) {
|
||||
if (quote.isZero) return Money.zero(base);
|
||||
if (quote.isZero) {
|
||||
return Money.zero(base);
|
||||
}
|
||||
|
||||
return Money(amount.amount * scale ~/ quote.amount, base);
|
||||
final numerator = amount.amount * BigInt.from(10).pow(base.decimals + quote.decimals);
|
||||
final denominator = quote.amount * BigInt.from(10).pow(amount.decimals);
|
||||
|
||||
return Money(numerator ~/ denominator, base);
|
||||
}
|
||||
|
||||
throw ArgumentError(
|
||||
"Unable to convert ${amount.currency.symbol} in ${base.symbol}/${quote.currency.symbol} pair");
|
||||
"Unable to convert ${amount.currency.symbol} in ${base.symbol}/${quote.currency.symbol} pair",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+109
-48
@@ -1,4 +1,6 @@
|
||||
import "package:cw_core/crypto_amount_format.dart";
|
||||
import "dart:math";
|
||||
|
||||
import "package:cw_core/amount/utils.dart";
|
||||
import "package:cw_core/crypto_currency.dart";
|
||||
import "package:cw_core/currency.dart";
|
||||
import "package:cw_core/format_fixed.dart";
|
||||
@@ -7,39 +9,63 @@ import "package:cw_core/parse_fixed.dart";
|
||||
export "money_local.dart";
|
||||
|
||||
class Money implements Comparable<Money> {
|
||||
const Money(this.amount, this.currency);
|
||||
const Money(this.amount, this.currency, [int? overrideDecimals])
|
||||
: _overrideDecimals = overrideDecimals;
|
||||
|
||||
factory Money.zero(Currency currency) => Money(BigInt.zero, currency);
|
||||
|
||||
factory Money.fromInt(int amount, Currency currency) => Money(BigInt.from(amount), currency);
|
||||
|
||||
/// Parse the [source] and turn it into [Money]
|
||||
/// Parse the [source] and turn it into [Money] trimming trailing 0s
|
||||
///
|
||||
/// Throws a [FormatException] if the [source] is not a valid decimal or
|
||||
/// not in canonical representation or if it is a decimal when [isBaseUnit]
|
||||
factory Money.parse(source, Currency currency, {bool isBaseUnit = false}) {
|
||||
final amount = isBaseUnit
|
||||
? BigInt.parse(source.toString())
|
||||
: parseFixed(source.toString(), currency.decimals);
|
||||
factory Money.parse(
|
||||
String source,
|
||||
Currency currency, {
|
||||
bool isBaseUnit = false,
|
||||
bool strictParsing = true,
|
||||
}) {
|
||||
if (!isBaseUnit) {
|
||||
source = trimTrailingFractionZeros(source);
|
||||
}
|
||||
final decimals = strictParsing ? currency.decimals : _getActualDecimals(source, currency);
|
||||
final amount = isBaseUnit ? BigInt.parse(source) : parseFixed(source, decimals);
|
||||
|
||||
return Money(amount, currency);
|
||||
return Money(amount, currency, decimals);
|
||||
}
|
||||
|
||||
/// Parse the [source] and turn it into [Money] if possible
|
||||
/// Parse the [source] and turn it into [Money] if possible trimming trailing 0s
|
||||
///
|
||||
/// As [parse] except that this method returns `null` if the input is not
|
||||
/// valid or if it is a decimal when [isBaseUnit]
|
||||
static Money? tryParse(source, Currency currency, {bool isBaseUnit = false}) {
|
||||
final amount = isBaseUnit
|
||||
? BigInt.tryParse(source.toString())
|
||||
: tryParseFixed(source.toString(), currency.decimals);
|
||||
static Money? tryParse(
|
||||
String source,
|
||||
Currency currency, {
|
||||
bool isBaseUnit = false,
|
||||
bool strictParsing = true,
|
||||
}) {
|
||||
try {
|
||||
if (!isBaseUnit) {
|
||||
source = trimTrailingFractionZeros(source);
|
||||
}
|
||||
final decimals = strictParsing ? currency.decimals : _getActualDecimals(source, currency);
|
||||
final amount = isBaseUnit ? BigInt.tryParse(source) : tryParseFixed(source, decimals);
|
||||
|
||||
return amount != null ? Money(amount, currency) : null;
|
||||
return amount != null ? Money(amount, currency, decimals) : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final BigInt amount;
|
||||
final Currency currency;
|
||||
|
||||
final int? _overrideDecimals;
|
||||
|
||||
/// Returns the amount of decimals of [currency]
|
||||
int get decimals => _overrideDecimals ?? currency.decimals;
|
||||
|
||||
/// Returns the sign of this [BigInt] amount.
|
||||
/// Returns 0 for zero, -1 for values less than zero and +1 for values
|
||||
/// greater than zero.
|
||||
@@ -59,15 +85,25 @@ class Money implements Comparable<Money> {
|
||||
int compareTo(Money other) {
|
||||
_assertSameCurrency(other);
|
||||
|
||||
return amount.compareTo(other.amount);
|
||||
final aligned = _align(other);
|
||||
return aligned.a.compareTo(aligned.b);
|
||||
}
|
||||
|
||||
/// Returns `true` if [other] is the same amount of money in
|
||||
/// the same currency.
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is Money && other.currency == currency && other.amount == amount);
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (other is! Money || other.currency != currency) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final aligned = _align(other);
|
||||
return aligned.a == aligned.b;
|
||||
}
|
||||
|
||||
/// Returns `true` when this money is less than [other].
|
||||
///
|
||||
@@ -76,7 +112,8 @@ class Money implements Comparable<Money> {
|
||||
bool operator <(Money other) {
|
||||
_assertSameCurrency(other, "Cannot compare money in different currencies.");
|
||||
|
||||
return amount < other.amount;
|
||||
final aligned = _align(other);
|
||||
return aligned.a < aligned.b;
|
||||
}
|
||||
|
||||
/// Returns `true` when this money is less than or equal to [other].
|
||||
@@ -86,7 +123,8 @@ class Money implements Comparable<Money> {
|
||||
bool operator <=(Money other) {
|
||||
_assertSameCurrency(other, "Cannot compare money in different currencies.");
|
||||
|
||||
return amount <= other.amount;
|
||||
final aligned = _align(other);
|
||||
return aligned.a <= aligned.b;
|
||||
}
|
||||
|
||||
/// Returns `true` when this money is greater than [other].
|
||||
@@ -96,7 +134,8 @@ class Money implements Comparable<Money> {
|
||||
bool operator >(Money other) {
|
||||
_assertSameCurrency(other, "Cannot compare money in different currencies.");
|
||||
|
||||
return amount > other.amount;
|
||||
final aligned = _align(other);
|
||||
return aligned.a > aligned.b;
|
||||
}
|
||||
|
||||
/// Returns `true` when this money is greater than or equal to [other].
|
||||
@@ -106,7 +145,8 @@ class Money implements Comparable<Money> {
|
||||
bool operator >=(Money other) {
|
||||
_assertSameCurrency(other, "Cannot compare money in different currencies.");
|
||||
|
||||
return amount >= other.amount;
|
||||
final aligned = _align(other);
|
||||
return aligned.a >= aligned.b;
|
||||
}
|
||||
|
||||
/// Adds the amount of [other] to this amount.
|
||||
@@ -116,11 +156,12 @@ class Money implements Comparable<Money> {
|
||||
Money operator +(Money other) {
|
||||
_assertSameCurrency(other);
|
||||
|
||||
return _withAmount(amount + other.amount);
|
||||
final aligned = _align(other);
|
||||
return copyWith(amount: aligned.a + aligned.b, decimals: aligned.decimals);
|
||||
}
|
||||
|
||||
/// unary minus operator.
|
||||
Money operator -() => _withAmount(-amount);
|
||||
Money operator -() => copyWith(amount: -amount);
|
||||
|
||||
/// Subtracts the amount of [other] from this amount.
|
||||
///
|
||||
@@ -129,13 +170,14 @@ class Money implements Comparable<Money> {
|
||||
Money operator -(Money other) {
|
||||
_assertSameCurrency(other);
|
||||
|
||||
return _withAmount(amount - other.amount);
|
||||
final aligned = _align(other);
|
||||
return copyWith(amount: aligned.a - aligned.b, decimals: aligned.decimals);
|
||||
}
|
||||
|
||||
/// Returns [Money] multiplied by [other].
|
||||
///
|
||||
/// The result is again [Money].
|
||||
Money operator *(BigInt other) => _withAmount(amount * other);
|
||||
Money operator *(BigInt other) => copyWith(amount: amount * other);
|
||||
|
||||
/// Returns [Money] divided by [other].
|
||||
///
|
||||
@@ -156,7 +198,7 @@ class Money implements Comparable<Money> {
|
||||
final twiceR = r << 1;
|
||||
final mag = (twiceR >= B) ? q + BigInt.one : q;
|
||||
|
||||
return _withAmount(neg ? -mag : mag);
|
||||
return copyWith(amount: neg ? -mag : mag);
|
||||
}
|
||||
|
||||
/// Creates a copy of this [Money] object with optional new values
|
||||
@@ -164,46 +206,63 @@ class Money implements Comparable<Money> {
|
||||
///
|
||||
/// If just [currency] is provided and the decimals missmatch
|
||||
/// the amount will be transformed to keep its canonical representation
|
||||
Money copyWith({BigInt? amount, Currency? currency}) {
|
||||
if (currency != null && amount == null && currency.decimals != this.currency.decimals) {
|
||||
Money copyWith({BigInt? amount, Currency? currency, int? decimals}) {
|
||||
if (currency != null && amount == null && decimals == null &&
|
||||
currency.decimals != this.decimals) {
|
||||
return Money(
|
||||
_transformAmount(this.amount, this.currency.decimals, currency.decimals),
|
||||
_transformAmount(this.amount, this.decimals, currency.decimals),
|
||||
currency,
|
||||
currency.decimals,
|
||||
);
|
||||
}
|
||||
|
||||
return Money(amount ?? this.amount, currency ?? this.currency);
|
||||
return Money(amount ?? this.amount, currency ?? this.currency, decimals ?? this.decimals);
|
||||
}
|
||||
|
||||
/// Creates new instance with the same currency and given [amount].
|
||||
Money _withAmount(BigInt amount) => Money(amount, currency);
|
||||
|
||||
void _assertSameCurrency(Money other, [String? message]) {
|
||||
if (currency != other.currency) {
|
||||
throw ArgumentError(message ?? "Cannot operate with money values in different currencies.");
|
||||
}
|
||||
}
|
||||
|
||||
BigInt _transformAmount(BigInt source, int sourceDecimals, int targetDecimals) {
|
||||
if (sourceDecimals == targetDecimals) {
|
||||
return source;
|
||||
static BigInt _transformAmount(BigInt source, int sourceDecimals, int targetDecimals) {
|
||||
final diff = targetDecimals - sourceDecimals;
|
||||
|
||||
return diff == 0
|
||||
? source
|
||||
: diff > 0
|
||||
? source * BigInt.from(10).pow(diff)
|
||||
: source ~/ BigInt.from(10).pow(-diff);
|
||||
}
|
||||
|
||||
({BigInt a, BigInt b, int decimals}) _align(Money other) {
|
||||
final decimals = max(this.decimals, other.decimals);
|
||||
return (
|
||||
a: _transformAmount(amount, this.decimals, decimals),
|
||||
b: _transformAmount(other.amount, other.decimals, decimals),
|
||||
decimals: decimals,
|
||||
);
|
||||
}
|
||||
|
||||
static int _getActualDecimals(String value, Currency currency) {
|
||||
final comps = value.split(".");
|
||||
if (comps.length > 2) {
|
||||
throw FormatException("Money._getActualDecimals: too many decimal points, value, $value");
|
||||
}
|
||||
|
||||
if (sourceDecimals > targetDecimals) {
|
||||
return parseFixed(
|
||||
formatFixed(source, sourceDecimals).withMaxDecimals(targetDecimals),
|
||||
targetDecimals,
|
||||
);
|
||||
} else {
|
||||
return parseFixed(formatFixed(source, sourceDecimals), targetDecimals);
|
||||
}
|
||||
return max(comps.length == 2 ? comps[1].length : 0, currency.decimals);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => amount.hashCode ^ currency.hashCode;
|
||||
int get hashCode {
|
||||
final value = formatFixed(amount, this.decimals, trimZeros: true);
|
||||
final decimals = _getActualDecimals(value, currency);
|
||||
|
||||
return Object.hash(value, decimals, currency);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => formatFixed(amount, currency.decimals);
|
||||
String toString() => formatFixed(amount, decimals);
|
||||
|
||||
String toStringWithSymbol({
|
||||
int? fractionalDigits,
|
||||
@@ -227,7 +286,9 @@ class Money implements Comparable<Money> {
|
||||
bool useBaseUnit = false,
|
||||
}) =>
|
||||
formatFixed(
|
||||
amount,
|
||||
decimals != currency.decimals
|
||||
? _transformAmount(amount, decimals, currency.decimals)
|
||||
: amount,
|
||||
useBaseUnit ? 0 : currency.decimals,
|
||||
fractionalDigits: fractionalDigits,
|
||||
trimZeros: trimZeros,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import "dart:math";
|
||||
|
||||
import "package:cw_core/amount/money.dart";
|
||||
import "package:cw_core/amount/utils.dart";
|
||||
import "package:cw_core/currency.dart";
|
||||
|
||||
/// Turn a double representation of a currency amount to a proper Money representation
|
||||
/// truncating currencies with more than 20 decimals because double can not handle more
|
||||
extension ToMoney on double {
|
||||
/// Turn a double representation of a currency amount to a proper Money representation
|
||||
/// truncating currencies with more than 20 decimals because double can not handle more
|
||||
Money? tryToMoney(Currency currency) =>
|
||||
Money.tryParse(toStringAsFixed(min(currency.decimals, 20)), currency);
|
||||
Money? tryToMoney(Currency currency) => Money.tryParse(_toSafeString(currency), currency);
|
||||
|
||||
Money toMoney(Currency currency) =>
|
||||
Money.parse(toStringAsFixed(min(currency.decimals, 20)), currency);
|
||||
Money toMoney(Currency currency) => Money.parse(_toSafeString(currency), currency);
|
||||
|
||||
String _toSafeString(Currency currency) =>
|
||||
trimTrailingFractionZeros(toStringAsFixed(min(currency.decimals, 20)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
String trimTrailingFractionZeros(String value) {
|
||||
if (!value.contains('.')) {
|
||||
return value;
|
||||
}
|
||||
|
||||
var end = value.length;
|
||||
while (end > 0 && value[end - 1] == "0") {
|
||||
end--;
|
||||
}
|
||||
|
||||
final trimmed = end == value.length ? value : value.substring(0, end);
|
||||
return trimmed.endsWith('.') ? trimmed.substring(0, trimmed.length - 1) : trimmed;
|
||||
}
|
||||
@@ -55,7 +55,7 @@ BigInt parseFixed(String value, int decimals) {
|
||||
}
|
||||
|
||||
final whole = comps.isNotEmpty ? comps[0] : "0";
|
||||
final fraction = (comps.length == 2 ? comps[1] : "0").padRight(decimals, "0");
|
||||
final fraction = (comps.length == 2 ? comps[1] : "").padRight(decimals, "0");
|
||||
|
||||
if (fraction.length > multiplier.length - 1) {
|
||||
throw FormatException(
|
||||
@@ -64,7 +64,7 @@ BigInt parseFixed(String value, int decimals) {
|
||||
}
|
||||
|
||||
final wholeValue = BigInt.parse(whole);
|
||||
final fractionValue = BigInt.parse(fraction);
|
||||
final fractionValue = fraction.isEmpty ? BigInt.zero : BigInt.parse(fraction);
|
||||
final multiplierValue = BigInt.parse(multiplier);
|
||||
|
||||
var wei = (wholeValue * multiplierValue) + fractionValue;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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';
|
||||
|
||||
import 'utils.dart';
|
||||
|
||||
void main() {
|
||||
group('ExchangeRate', () {
|
||||
final rate = ExchangeRate(
|
||||
@@ -99,49 +100,216 @@ void main() {
|
||||
test('handle quote being 0 correctly', () {
|
||||
final zeroRate = ExchangeRate(
|
||||
base: CryptoCurrency.btc,
|
||||
quote: Money(BigInt.zero, EUR),
|
||||
quote: Money(BigInt.zero, EUR, EUR.decimals),
|
||||
);
|
||||
expect(zeroRate.convert(Money.fromInt(100, CryptoCurrency.btc)), Money(BigInt.zero, EUR));
|
||||
expect(zeroRate.convert(Money.fromInt(100, EUR)), Money(BigInt.zero, CryptoCurrency.btc));
|
||||
});
|
||||
|
||||
test('handle super low quote correctly', () {
|
||||
final zeroRate = ExchangeRate(
|
||||
base: CryptoCurrency.shib,
|
||||
quote: Money.parse("0.00000444", EUR, strictParsing: false),
|
||||
);
|
||||
|
||||
expect(
|
||||
zeroRate.convert(Money.parse("1", CryptoCurrency.shib)),
|
||||
Money.parse("0.00000444", EUR, strictParsing: false),
|
||||
);
|
||||
expect(
|
||||
zeroRate.convert(Money.fromInt(10000, EUR)),
|
||||
Money.parse("22522522.522522522522522522", CryptoCurrency.shib),
|
||||
);
|
||||
|
||||
expect(
|
||||
zeroRate.convert(Money.parse("200", CryptoCurrency.shib)),
|
||||
Money.parse("0.000888", EUR, strictParsing: false),
|
||||
);
|
||||
});
|
||||
|
||||
group('Precision and scale', () {
|
||||
final rate = ExchangeRate(base: CryptoCurrency.btc, quote: Money.parse("60000", EUR));
|
||||
|
||||
test('convert is insensitive to the input scale (forward)', () {
|
||||
// Same value, three different internal scales.
|
||||
final coarse = Money(BigInt.one, CryptoCurrency.btc, 0);
|
||||
final normal = Money.parse("1", CryptoCurrency.btc);
|
||||
final fine = Money.parse("1.000000000000000000", CryptoCurrency.btc, strictParsing: false);
|
||||
|
||||
expect(rate.convert(coarse), Money.parse("60000.00", EUR));
|
||||
expect(rate.convert(normal), Money.parse("60000.00", EUR));
|
||||
expect(rate.convert(fine), Money.parse("60000.00", EUR));
|
||||
});
|
||||
|
||||
test('convert is insensitive to the input scale (reverse)', () {
|
||||
expect(
|
||||
rate.convert(Money(BigInt.one, EUR, 0)), // €1 at scale 0
|
||||
rate.convert(Money.parse("1", EUR)), // €1 at scale 2
|
||||
);
|
||||
});
|
||||
|
||||
test('sub-unit precision in the input does not leak into the result', () {
|
||||
// ad = 18 here, well past BTC's 8. The divisor must cancel the *amount's*
|
||||
// scale, not the base currency's.
|
||||
final overPrecise =
|
||||
Money.parse("1.000000000000000001", CryptoCurrency.btc, strictParsing: false);
|
||||
|
||||
expect(rate.convert(overPrecise), Money.parse("60000.00", EUR));
|
||||
});
|
||||
|
||||
test('the result carries the quote scale, not the currency default', () {
|
||||
final preciseRate = ExchangeRate(
|
||||
base: CryptoCurrency.btc,
|
||||
quote: Money.parse("60000.123456", EUR, strictParsing: false),
|
||||
);
|
||||
|
||||
final result = preciseRate.convert(Money.parse("1", CryptoCurrency.btc));
|
||||
|
||||
expect(result.decimals, 6);
|
||||
expect(result, Money.parse("60000.123456", EUR, strictParsing: false));
|
||||
});
|
||||
|
||||
test('a quote scale finer than the currency survives the round trip', () {
|
||||
final preciseRate = ExchangeRate(
|
||||
base: CryptoCurrency.btc,
|
||||
quote: Money.parse("60000.50", EUR, strictParsing: false),
|
||||
);
|
||||
|
||||
expect(
|
||||
preciseRate.convert(preciseRate.convert(Money.parse("1", CryptoCurrency.btc))),
|
||||
Money.parse("1", CryptoCurrency.btc),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('Truncation', () {
|
||||
final rate = ExchangeRate(base: CryptoCurrency.btc, quote: Money.parse("60000", EUR));
|
||||
|
||||
test('truncates rather than rounding, even past the halfway point', () {
|
||||
// 9 sats = 0.54 cents. Rounding would give 1.
|
||||
expect(rate.convert(Money(BigInt.from(9), CryptoCurrency.btc)), Money.zero(EUR));
|
||||
// 1 cent = 16.66 sats. Rounding would give 17.
|
||||
expect(rate.convert(Money(BigInt.one, EUR)), Money.fromInt(16, CryptoCurrency.btc));
|
||||
});
|
||||
|
||||
test('truncates toward zero for negatives, not downward', () {
|
||||
// -0.54 cents must be 0, not -1.
|
||||
expect(rate.convert(Money(BigInt.from(-9), CryptoCurrency.btc)), Money.zero(EUR));
|
||||
expect(rate.convert(Money(BigInt.from(-1), EUR)), Money.fromInt(-16, CryptoCurrency.btc));
|
||||
});
|
||||
|
||||
test('truncation is symmetric around zero', () {
|
||||
final positive = rate.convert(Money(BigInt.from(7), EUR));
|
||||
final negative = rate.convert(Money(BigInt.from(-7), EUR));
|
||||
|
||||
expect(negative, -positive);
|
||||
});
|
||||
|
||||
test('round trip loses less than one quote base unit', () {
|
||||
final original = Money.parse("0.12345678", CryptoCurrency.btc);
|
||||
final back = rate.convert(rate.convert(original));
|
||||
final oneQuoteUnitInBaseUnits =
|
||||
BigInt.from(10).pow(CryptoCurrency.btc.decimals) ~/ rate.quote.amount;
|
||||
|
||||
expect(
|
||||
(original - back).amount.abs(),
|
||||
lessThanOrEqualTo(oneQuoteUnitInBaseUnits + BigInt.one),
|
||||
);
|
||||
});
|
||||
|
||||
test('round trip never inflates the amount', () {
|
||||
// Both truncations round toward zero, so the result can only shrink.
|
||||
for (final source in ["0.12345678", "1", "0.5", "21000000"]) {
|
||||
final original = Money.parse(source, CryptoCurrency.btc);
|
||||
expect(
|
||||
rate.convert(rate.convert(original)) <= original,
|
||||
isTrue,
|
||||
reason: "round trip grew $source",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a value below one base unit of the quote collapses to zero', () {
|
||||
final back = rate.convert(rate.convert(Money(BigInt.one, CryptoCurrency.btc)));
|
||||
|
||||
expect(back.isZero, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('Extreme decimal spreads', () {
|
||||
test('30 decimals against 0 decimals: NANO/JPY', () {
|
||||
final rate = ExchangeRate(base: CryptoCurrency.nano, quote: Money.parse("150", JPY));
|
||||
|
||||
expect(rate.convert(Money.parse("1", CryptoCurrency.nano)), Money.fromInt(150, JPY));
|
||||
expect(rate.convert(Money.fromInt(150, JPY)), Money.parse("1", CryptoCurrency.nano));
|
||||
expect(rate.convert(Money.fromInt(1, JPY)).decimals, 30);
|
||||
});
|
||||
|
||||
test('0 decimals against 18 decimals: JPY/SHIB', () {
|
||||
final rate = ExchangeRate(base: JPY, quote: Money.parse("2500", CryptoCurrency.shib));
|
||||
|
||||
expect(rate.convert(Money.fromInt(2, JPY)), Money.parse("5000", CryptoCurrency.shib));
|
||||
expect(rate.convert(Money.parse("5000", CryptoCurrency.shib)), Money.fromInt(2, JPY));
|
||||
});
|
||||
|
||||
test('no overflow at the extremes', () {
|
||||
final rate = ExchangeRate(
|
||||
base: CryptoCurrency.shib,
|
||||
quote: Money.parse("0.00000444", EUR, strictParsing: false),
|
||||
);
|
||||
final totalSupply = Money.parse("589000000000000", CryptoCurrency.shib);
|
||||
|
||||
expect(rate.convert(totalSupply).isNegative, isFalse);
|
||||
expect(rate.convert(rate.convert(totalSupply)) <= totalSupply, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('Degenerate pairs', () {
|
||||
test('identity pair returns the amount untouched', () {
|
||||
final identity = ExchangeRate(base: EUR, quote: Money.parse("1", EUR));
|
||||
|
||||
expect(identity.convert(Money.parse("42.42", EUR)), Money.parse("42.42", EUR));
|
||||
expect(identity.convert(Money.zero(EUR)), Money.zero(EUR));
|
||||
});
|
||||
|
||||
test('a same-currency pair silently ignores its own rate', () {
|
||||
final bogus = ExchangeRate(base: EUR, quote: Money.parse("2", EUR));
|
||||
expect(bogus.convert(Money.parse("10", EUR)), Money.parse("10", EUR));
|
||||
});
|
||||
|
||||
test('a rate of exactly one is a no-op in both directions', () {
|
||||
final rate = ExchangeRate(base: JPY, quote: Money.fromInt(100, EUR));
|
||||
|
||||
expect(rate.convert(Money.fromInt(7, JPY)), Money.fromInt(700, EUR));
|
||||
expect(rate.convert(Money.fromInt(700, EUR)), Money.fromInt(7, JPY));
|
||||
});
|
||||
|
||||
test('negative quotes propagate their sign', () {
|
||||
final inverted = ExchangeRate(base: CryptoCurrency.btc, quote: Money.parse("-60000", EUR));
|
||||
|
||||
expect(
|
||||
inverted.convert(Money.parse("1", CryptoCurrency.btc)),
|
||||
Money.parse("-60000.00", EUR),
|
||||
);
|
||||
expect(
|
||||
inverted.convert(Money.parse("-60000.00", EUR)),
|
||||
Money.parse("1", CryptoCurrency.btc),
|
||||
);
|
||||
});
|
||||
|
||||
test('zero amount with a zero quote', () {
|
||||
final zeroRate = ExchangeRate(base: CryptoCurrency.btc, quote: Money.zero(EUR));
|
||||
|
||||
expect(zeroRate.convert(Money.zero(CryptoCurrency.btc)), Money.zero(EUR));
|
||||
expect(zeroRate.convert(Money.zero(EUR)), Money.zero(CryptoCurrency.btc));
|
||||
});
|
||||
|
||||
test('rejects a foreign currency in both slots', () {
|
||||
final rate = ExchangeRate(base: CryptoCurrency.btc, quote: Money.parse("60000", EUR));
|
||||
|
||||
expect(() => rate.convert(Money.parse("1", CryptoCurrency.xmr)), throwsArgumentError);
|
||||
expect(() => rate.convert(Money.parse("1", USD)), throwsArgumentError);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -2,6 +2,8 @@ import "package:cw_core/amount/money.dart";
|
||||
import "package:cw_core/crypto_currency.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
|
||||
import "utils.dart";
|
||||
|
||||
void main() {
|
||||
group("Money", () {
|
||||
test("parse", () {
|
||||
@@ -27,7 +29,7 @@ void main() {
|
||||
expect(() => Money.parse("1,11", CryptoCurrency.btc), throwsFormatException);
|
||||
|
||||
// To many decimals
|
||||
expect(() => Money.parse("-1.000000000000000", CryptoCurrency.btc), throwsFormatException);
|
||||
expect(() => Money.parse("-1.0000000000000001", CryptoCurrency.btc), throwsFormatException);
|
||||
|
||||
money = Money.parse("1", CryptoCurrency.btc, isBaseUnit: true);
|
||||
expect(money.amount, BigInt.from(1));
|
||||
@@ -66,7 +68,7 @@ void main() {
|
||||
expect(money?.amount, isNull);
|
||||
|
||||
// To many decimals
|
||||
money = Money.tryParse("-1.000000000000000", CryptoCurrency.btc);
|
||||
money = Money.tryParse("-1.0000000000000001", CryptoCurrency.btc);
|
||||
expect(money?.amount, isNull);
|
||||
|
||||
money = Money.tryParse("1", CryptoCurrency.btc, isBaseUnit: true);
|
||||
@@ -373,5 +375,122 @@ void main() {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group("different scales", () {
|
||||
final coarseOne = Money(BigInt.one, CryptoCurrency.btc, 0);
|
||||
final fineOne = Money(BigInt.from(100000000), CryptoCurrency.btc, 8);
|
||||
|
||||
test("== across scales", () {
|
||||
expect(coarseOne, equals(fineOne));
|
||||
expect(coarseOne, isNot(equals(Money(BigInt.two, CryptoCurrency.btc, 0))));
|
||||
});
|
||||
|
||||
test("equal values across scales hash equally", () {
|
||||
expect(coarseOne.hashCode, equals(fineOne.hashCode));
|
||||
|
||||
expect(
|
||||
Money(BigInt.from(-11), CryptoCurrency.btc, 1).hashCode,
|
||||
equals(Money(BigInt.from(-110000000), CryptoCurrency.btc, 8).hashCode),
|
||||
);
|
||||
});
|
||||
|
||||
test("zero hashes equally at every scale", () {
|
||||
final hashes = [0, 1, 8, 18, 30]
|
||||
.map((s) => Money(BigInt.zero, CryptoCurrency.btc, s).hashCode)
|
||||
.toSet();
|
||||
|
||||
expect(hashes, hasLength(1));
|
||||
});
|
||||
|
||||
test("whole units are not confused with their digits", () {
|
||||
// (100, scale 0) is one hundred, not one: stripping must stop at 0.
|
||||
final oneHundred = Money(BigInt.from(100), CryptoCurrency.btc, 0);
|
||||
|
||||
expect(oneHundred, isNot(equals(coarseOne)));
|
||||
expect(oneHundred.hashCode, isNot(equals(coarseOne.hashCode)));
|
||||
});
|
||||
|
||||
test("Set deduplicates across scales", () {
|
||||
expect(
|
||||
{
|
||||
Money(BigInt.from(11), CryptoCurrency.btc, 1),
|
||||
Money(BigInt.from(110000000), CryptoCurrency.btc, 8),
|
||||
Money(BigInt.from(1100), CryptoCurrency.btc, 3),
|
||||
},
|
||||
hasLength(1),
|
||||
);
|
||||
});
|
||||
|
||||
test("+ aligns operands", () {
|
||||
expect(coarseOne + fineOne, equals(Money(BigInt.two, CryptoCurrency.btc, 0)));
|
||||
expect(coarseOne + fineOne, equals(fineOne + coarseOne));
|
||||
});
|
||||
|
||||
test("- aligns operands", () {
|
||||
// Regression: this used to subtract the raw amounts, i.e. 1 - 100000000.
|
||||
expect((coarseOne - fineOne).isZero, isTrue);
|
||||
expect((fineOne - coarseOne).isZero, isTrue);
|
||||
|
||||
final threeCoarse = Money(BigInt.from(3), CryptoCurrency.btc, 0);
|
||||
expect(threeCoarse - fineOne, equals(Money(BigInt.two, CryptoCurrency.btc, 0)));
|
||||
expect(threeCoarse - fineOne, equals(-(fineOne - threeCoarse)));
|
||||
});
|
||||
|
||||
test("the result keeps the finer scale", () {
|
||||
expect((coarseOne + fineOne).decimals, greaterThanOrEqualTo(fineOne.decimals));
|
||||
});
|
||||
|
||||
test("sub-unit precision survives a coarse operand", () {
|
||||
final oneSatoshi = Money(BigInt.one, CryptoCurrency.btc, 8);
|
||||
|
||||
expect(
|
||||
coarseOne + oneSatoshi,
|
||||
equals(Money(BigInt.from(100000001), CryptoCurrency.btc, 8)),
|
||||
);
|
||||
});
|
||||
|
||||
test("comparison operators align", () {
|
||||
expect(coarseOne < fineOne, isFalse);
|
||||
expect(coarseOne <= fineOne, isTrue);
|
||||
expect(coarseOne >= fineOne, isTrue);
|
||||
expect(coarseOne > fineOne, isFalse);
|
||||
expect(Money(BigInt.two, CryptoCurrency.btc, 0) > fineOne, isTrue);
|
||||
});
|
||||
|
||||
test("compareTo aligns and agrees with ==", () {
|
||||
expect(coarseOne.compareTo(fineOne), isZero);
|
||||
expect(coarseOne.compareTo(fineOne) == 0, equals(coarseOne == fineOne));
|
||||
expect(Money(BigInt.two, CryptoCurrency.btc, 0).compareTo(fineOne), isPositive);
|
||||
expect(Money(BigInt.zero, CryptoCurrency.btc, 0).compareTo(fineOne), isNegative);
|
||||
});
|
||||
|
||||
test("toStringWithPrecision", () {
|
||||
expect(
|
||||
Money.parse("60000.123456", USD, strictParsing: false).toStringWithPrecision(),
|
||||
"60000.12",
|
||||
);
|
||||
expect(
|
||||
Money.parse("1.23456789", USD, strictParsing: false).toStringWithPrecision(),
|
||||
"1.23",
|
||||
);
|
||||
expect(Money(BigInt.one, CryptoCurrency.btc, 0).toStringWithPrecision(), "1");
|
||||
expect(
|
||||
Money(BigInt.one, CryptoCurrency.btc, 0).toStringWithPrecision(useBaseUnit: true),
|
||||
"100000000",
|
||||
);
|
||||
});
|
||||
|
||||
test("toString", () {
|
||||
expect(
|
||||
Money.parse("60000.123456", CryptoCurrency.btc, strictParsing: false).toString(),
|
||||
"60000.123456",
|
||||
);
|
||||
expect(
|
||||
Money.parse("1.23456789", CryptoCurrency.btc, strictParsing: false).toString(),
|
||||
"1.23456789",
|
||||
);
|
||||
expect(Money(BigInt.one, CryptoCurrency.btc, 0).toString(), "1");
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:cw_core/amount/money.dart';
|
||||
import 'package:cw_core/currency.dart';
|
||||
|
||||
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);
|
||||
Reference in New Issue
Block a user