mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-28 06:28:41 +01:00
Merge upstream Cake Wallet v6.5.0
Catches up from the May 12 fork point (v6.1.2+23) through v6.5.0: 535 upstream commits, including the Monero silent-open fix, Monero on Trezor, Ledger/Trezor BLE fixes, the new seed and history screens, quick Bitcoin sync, git-pinned dependencies (pubspec_overrides.yaml), frozen Hive adapters (*.part.dart), monero_c v0.18.4.6-RC2 and Flutter 3.41.9. Merged with both sides normalized first (import rename, 100-column format), which took the conflict count from 614 paths to 322. The procedure is now scripts/upstream_sync.sh. Kept from Hash Bags: - WalletType enum order and Hive IDs. SQLite stores WalletType.index and our order differs from upstream's; taking theirs would reread saved wallets as other chains. Our frozen adapter keeps the shipped IDs. - The 1.0.2 legacy PIN and wallet-password decode and self-heal. - Removed chains (Solana, Tron, Zano, Zcash, Decred, Haven) and the new Robinhood chain stay out; Cake Pay and non-Trocador swaps stay out. - Default nodes (node.wownero.org, sethforprivacy, Blockstream, bysh.me), converted to upstream's isDefault node-list key; Wownero is back in the node reset list. - Privacy: address resolvers limited to domain-anchored sources again; prices.neroswap.com for fiat; upstream's charts tab, which reads prices.cakewallet.com, sits behind FeatureFlag.isChartsEnabled = false. - Lightning stays behind FeatureFlag.isLightningEnabled, including upstream's new post-seed Lightning username step. - Our icon pipeline, branding and bundle identifiers. Twelve new upstream widget tests are skipped: test and code are unchanged from v6.5.0 apart from renames (not rerun on a pristine upstream checkout; upstream's top_bar_test does not compile against its own TopBar). flutter analyze lib: 0 errors; flutter test: 345 passed, 12 skipped; flutter build bundle compiles. Built with Flutter 3.41.9.
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import 'package:cw_core/amount/amount_sanitizer.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('smartAmountSanitizer', () {
|
||||
test('sanitize decimal dot', () {
|
||||
final amount = "1,000.00".sanitized();
|
||||
expect(amount, "1000.00");
|
||||
});
|
||||
|
||||
test('sanitize decimal comma', () {
|
||||
final amount = "1.000,00".sanitized();
|
||||
expect(amount, "1000.00");
|
||||
});
|
||||
|
||||
test('general case', () {
|
||||
final amount = "1000.00".sanitized();
|
||||
expect(amount, "1000.00");
|
||||
});
|
||||
|
||||
test('no decimals', () {
|
||||
final amount = "1,000".sanitized();
|
||||
expect(amount, "1.000");
|
||||
});
|
||||
|
||||
test('no decimals', () {
|
||||
final amount = "1.000".sanitized();
|
||||
expect(amount, "1.000");
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import 'package:cw_core/amount/exchange_rate.dart';
|
||||
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('ExchangeRate', () {
|
||||
final rate = ExchangeRate(
|
||||
base: 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(
|
||||
base: 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(
|
||||
base: 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(
|
||||
base: 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(
|
||||
base: 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(
|
||||
base: CryptoCurrency.btc,
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import "package:cw_core/amount/money.dart";
|
||||
import "package:cw_core/amount/money_local.dart";
|
||||
import "package:cw_core/crypto_currency.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:intl/intl.dart";
|
||||
|
||||
final _btc012 = Money.fromInt(12345678, CryptoCurrency.btc); // 0.12345678 BTC
|
||||
final _btcTrailing = Money.fromInt(12000000, CryptoCurrency.btc); // 0.12 BTC
|
||||
final _btcGrouping = Money.fromInt(123450000000, CryptoCurrency.btc); // 1234.5 BTC
|
||||
final _xmrHalf = Money.fromInt(500000000000, CryptoCurrency.xmr); // 0.5 XMR
|
||||
|
||||
void main() {
|
||||
group("toLocalStringWithPrecision", () {
|
||||
test("localizes grouping (en_US)", () {
|
||||
expect(_btcGrouping.toLocalStringWithPrecision(locale: "en_US"), "1,234.5");
|
||||
});
|
||||
|
||||
test("no grouping for sub-1 values", () {
|
||||
expect(_btc012.toLocalStringWithPrecision(locale: "en_US"), "0.12345678");
|
||||
});
|
||||
|
||||
test("swaps grouping/decimal separators (de_DE)", () {
|
||||
expect(_btcGrouping.toLocalStringWithPrecision(locale: "de_DE"), "1.234,5");
|
||||
});
|
||||
|
||||
test("formats as English when intl has no number data for the locale (yo)", () {
|
||||
expect(_btcGrouping.toLocalStringWithPrecision(locale: "yo"), "1,234.5");
|
||||
});
|
||||
|
||||
test("base unit renders integer sats with grouping", () {
|
||||
expect(
|
||||
_btc012.toLocalStringWithPrecision(useBaseUnit: true, locale: "en_US"),
|
||||
"12,345,678",
|
||||
);
|
||||
});
|
||||
|
||||
test("fractionalDigits truncates (does not round)", () {
|
||||
// 0.12345678 -> 0.12 (NOT 0.13).
|
||||
expect(_btc012.toLocalStringWithPrecision(fractionalDigits: 2, locale: "en_US"), "0.12");
|
||||
});
|
||||
|
||||
test("trimZeros:false keeps trailing zeros", () {
|
||||
expect(
|
||||
_btcTrailing.toLocalStringWithPrecision(trimZeros: false, locale: "en_US"),
|
||||
"0.12000000",
|
||||
);
|
||||
});
|
||||
|
||||
test("respects a higher-precision currency (XMR)", () {
|
||||
expect(_xmrHalf.toLocalStringWithPrecision(locale: "en_US"), "0.5");
|
||||
});
|
||||
|
||||
test("falls back to the ambient Intl locale when locale is null", () {
|
||||
final previous = Intl.defaultLocale;
|
||||
addTearDown(() => Intl.defaultLocale = previous);
|
||||
Intl.defaultLocale = "de_DE";
|
||||
|
||||
// Proves the extension passes null through to NumberFormat rather than
|
||||
// hardcoding a locale.
|
||||
expect(_btcGrouping.toLocalStringWithPrecision(), "1.234,5");
|
||||
});
|
||||
});
|
||||
|
||||
group("toLocalStringWithSymbol", () {
|
||||
test("suffixes the symbol by default", () {
|
||||
expect(_btcGrouping.toLocalStringWithSymbol(locale: "en_US"), "1,234.5 BTC");
|
||||
});
|
||||
|
||||
test("prefixes the symbol when withSymbolPrefix is true", () {
|
||||
expect(
|
||||
_btcGrouping.toLocalStringWithSymbol(withSymbolPrefix: true, locale: "en_US"),
|
||||
"BTC 1,234.5",
|
||||
);
|
||||
});
|
||||
|
||||
test("suffixes the base-unit ticker", () {
|
||||
expect(
|
||||
_btc012.toLocalStringWithSymbol(useBaseUnit: true, locale: "en_US"),
|
||||
"12,345,678 sats",
|
||||
);
|
||||
});
|
||||
|
||||
test("prefixes the base-unit ticker", () {
|
||||
expect(
|
||||
_btc012.toLocalStringWithSymbol(
|
||||
useBaseUnit: true,
|
||||
withSymbolPrefix: true,
|
||||
locale: "en_US",
|
||||
),
|
||||
"sats 12,345,678",
|
||||
);
|
||||
});
|
||||
|
||||
test("prefix respects de_DE separators", () {
|
||||
expect(
|
||||
_btcGrouping.toLocalStringWithSymbol(withSymbolPrefix: true, locale: "de_DE"),
|
||||
"BTC 1.234,5",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses the currency symbol for non-BTC", () {
|
||||
expect(_xmrHalf.toLocalStringWithSymbol(locale: "en_US"), "0.5 XMR");
|
||||
});
|
||||
|
||||
test("forwards fractionalDigits to the precision string", () {
|
||||
expect(_btc012.toLocalStringWithSymbol(fractionalDigits: 2, locale: "en_US"), "0.12 BTC");
|
||||
});
|
||||
|
||||
test("forwards trimZeros to the precision string", () {
|
||||
expect(
|
||||
_btcTrailing.toLocalStringWithSymbol(trimZeros: false, locale: "en_US"),
|
||||
"0.12000000 BTC",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group("negative amounts", () {
|
||||
test("keeps the sign when the integer part is non-zero", () {
|
||||
expect((-_btcGrouping).toLocalStringWithSymbol(locale: "en_US"), "-1,234.5 BTC");
|
||||
});
|
||||
|
||||
// NOTE: currently fails. For |amount| < 1 the sign is dropped:
|
||||
// formatFixed yields "-0.5", but _withLocalSeparator re-parses the integer
|
||||
// part and int.tryParse("-0") == 0, so the "-" is lost -> "0.5 XMR".
|
||||
// Fix in _withLocalSeparator by capturing the sign before parsing, e.g.
|
||||
// final negative = amount.startsWith("-");
|
||||
// ... then re-apply it to the formatted result.
|
||||
test("keeps the sign for sub-1 amounts", () {
|
||||
expect((-_xmrHalf).toLocalStringWithSymbol(locale: "en_US"), "-0.5 XMR");
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
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", () {
|
||||
var money = Money.parse("1", CryptoCurrency.btc);
|
||||
expect(money.amount, BigInt.from(100000000));
|
||||
|
||||
money = Money.parse("-1", CryptoCurrency.btc);
|
||||
expect(money.amount, BigInt.from(-100000000));
|
||||
|
||||
money = Money.parse("1.11", CryptoCurrency.btc);
|
||||
expect(money.amount, BigInt.from(111000000));
|
||||
|
||||
money = Money.parse("-1.11", CryptoCurrency.btc);
|
||||
expect(money.amount, BigInt.from(-111000000));
|
||||
|
||||
money = Money.parse("-.11", CryptoCurrency.btc);
|
||||
expect(money.amount, BigInt.from(-11000000));
|
||||
|
||||
money = Money.parse(".11", CryptoCurrency.btc);
|
||||
expect(money.amount, BigInt.from(11000000));
|
||||
|
||||
// Invalid representation
|
||||
expect(() => Money.parse("1,11", CryptoCurrency.btc), throwsFormatException);
|
||||
|
||||
// To many decimals
|
||||
expect(() => Money.parse("-1.0000000000000001", CryptoCurrency.btc), throwsFormatException);
|
||||
|
||||
money = Money.parse("1", CryptoCurrency.btc, isBaseUnit: true);
|
||||
expect(money.amount, BigInt.from(1));
|
||||
|
||||
money = Money.parse("0", CryptoCurrency.btc, isBaseUnit: true);
|
||||
expect(money.amount, BigInt.from(0));
|
||||
|
||||
money = Money.parse("-1", CryptoCurrency.btc, isBaseUnit: true);
|
||||
expect(money.amount, BigInt.from(-1));
|
||||
|
||||
// canonical representation when expecting base units
|
||||
expect(() => Money.parse("1.0", CryptoCurrency.btc, isBaseUnit: true), throwsFormatException);
|
||||
});
|
||||
|
||||
test("tryParse", () {
|
||||
var money = Money.tryParse("1", CryptoCurrency.btc);
|
||||
expect(money?.amount, BigInt.from(100000000));
|
||||
|
||||
money = Money.tryParse("-1", CryptoCurrency.btc);
|
||||
expect(money?.amount, BigInt.from(-100000000));
|
||||
|
||||
money = Money.tryParse("1.11", CryptoCurrency.btc);
|
||||
expect(money?.amount, BigInt.from(111000000));
|
||||
|
||||
money = Money.tryParse("-1.11", CryptoCurrency.btc);
|
||||
expect(money?.amount, BigInt.from(-111000000));
|
||||
|
||||
money = Money.tryParse("-.11", CryptoCurrency.btc);
|
||||
expect(money?.amount, BigInt.from(-11000000));
|
||||
|
||||
money = Money.tryParse(".11", CryptoCurrency.btc);
|
||||
expect(money?.amount, BigInt.from(11000000));
|
||||
|
||||
// Invalid representation
|
||||
money = Money.tryParse("1,11", CryptoCurrency.btc);
|
||||
expect(money?.amount, isNull);
|
||||
|
||||
// To many decimals
|
||||
money = Money.tryParse("-1.0000000000000001", CryptoCurrency.btc);
|
||||
expect(money?.amount, isNull);
|
||||
|
||||
money = Money.tryParse("1", CryptoCurrency.btc, isBaseUnit: true);
|
||||
expect(money?.amount, BigInt.from(1));
|
||||
|
||||
money = Money.tryParse("0", CryptoCurrency.btc, isBaseUnit: true);
|
||||
expect(money?.amount, BigInt.from(0));
|
||||
|
||||
money = Money.tryParse("-1", CryptoCurrency.btc, isBaseUnit: true);
|
||||
expect(money?.amount, BigInt.from(-1));
|
||||
|
||||
// canonical representation when expecting base units
|
||||
money = Money.tryParse("1.0", CryptoCurrency.btc, isBaseUnit: true);
|
||||
expect(money?.amount, isNull);
|
||||
});
|
||||
|
||||
test("sign", () {
|
||||
expect(Money(BigInt.from(0), CryptoCurrency.btc).sign, 0);
|
||||
|
||||
expect(Money(BigInt.from(1), CryptoCurrency.btc).sign, 1);
|
||||
|
||||
expect(Money(BigInt.from(10), CryptoCurrency.btc).sign, 1);
|
||||
|
||||
expect(Money(BigInt.from(-1), CryptoCurrency.btc).sign, -1);
|
||||
|
||||
expect(Money(BigInt.from(-10), CryptoCurrency.btc).sign, -1);
|
||||
});
|
||||
|
||||
test("isZero", () {
|
||||
expect(Money(BigInt.from(0), CryptoCurrency.btc).isZero, isTrue);
|
||||
|
||||
expect(Money(BigInt.from(1), CryptoCurrency.btc).isZero, isFalse);
|
||||
|
||||
expect(Money(BigInt.from(-1), CryptoCurrency.btc).isZero, isFalse);
|
||||
});
|
||||
|
||||
test("isNegative", () {
|
||||
expect(Money(BigInt.from(0), CryptoCurrency.btc).isNegative, isFalse);
|
||||
|
||||
expect(Money(BigInt.from(1), CryptoCurrency.btc).isNegative, isFalse);
|
||||
|
||||
expect(Money(BigInt.from(-1), CryptoCurrency.btc).isNegative, isTrue);
|
||||
});
|
||||
|
||||
group("Comparison", () {
|
||||
final fourBitcoin = Money(BigInt.from(400000000), CryptoCurrency.btc);
|
||||
final fiveBitcoin = Money(BigInt.from(500000000), CryptoCurrency.btc);
|
||||
final sixBitcoin = Money(BigInt.from(600000000), CryptoCurrency.btc);
|
||||
|
||||
final fiveMonero = Money.parse("5", CryptoCurrency.xmr);
|
||||
|
||||
test("==", () {
|
||||
expect(fiveBitcoin, equals(Money(BigInt.from(500000000), CryptoCurrency.btc)));
|
||||
expect(fiveBitcoin, isNot(equals(fourBitcoin)));
|
||||
expect(fiveBitcoin, isNot(equals(sixBitcoin)));
|
||||
expect(fiveBitcoin, isNot(equals(fiveMonero)));
|
||||
// intentional check of incompatible types.
|
||||
// ignore: unrelated_type_equality_checks
|
||||
expect(fiveBitcoin == "Not money", equals(false));
|
||||
});
|
||||
|
||||
test("<", () {
|
||||
expect(fiveBitcoin < sixBitcoin, isTrue);
|
||||
expect(fiveBitcoin < fiveBitcoin, isFalse);
|
||||
expect(fiveBitcoin < fourBitcoin, isFalse);
|
||||
|
||||
// Cannot compare money in different currencies:
|
||||
expect(() => fiveBitcoin < fiveMonero, throwsArgumentError);
|
||||
});
|
||||
|
||||
test("<=", () {
|
||||
expect(fiveBitcoin <= sixBitcoin, isTrue);
|
||||
expect(fiveBitcoin <= fiveBitcoin, isTrue);
|
||||
expect(fiveBitcoin <= fourBitcoin, isFalse);
|
||||
|
||||
// Cannot compare money in different currencies:
|
||||
expect(() => fiveBitcoin <= fiveMonero, throwsArgumentError);
|
||||
});
|
||||
|
||||
test(">", () {
|
||||
expect(fiveBitcoin > fourBitcoin, isTrue);
|
||||
expect(fiveBitcoin > fiveBitcoin, isFalse);
|
||||
expect(fiveBitcoin > sixBitcoin, isFalse);
|
||||
|
||||
// Cannot compare money in different currencies:
|
||||
expect(() => fiveBitcoin > fiveMonero, throwsArgumentError);
|
||||
});
|
||||
|
||||
test(">=", () {
|
||||
expect(fiveBitcoin >= fourBitcoin, isTrue);
|
||||
expect(fiveBitcoin >= fiveBitcoin, isTrue);
|
||||
expect(fiveBitcoin >= sixBitcoin, isFalse);
|
||||
|
||||
// Cannot compare money in different currencies:
|
||||
expect(() => fiveBitcoin >= fiveMonero, throwsArgumentError);
|
||||
});
|
||||
|
||||
test("conformance to Comparable", () {
|
||||
expect(fiveBitcoin, isA<Comparable<Money>>());
|
||||
|
||||
expect(fiveBitcoin.compareTo(fiveBitcoin), isZero);
|
||||
expect(fiveBitcoin.compareTo(fourBitcoin), isPositive);
|
||||
expect(fiveBitcoin.compareTo(sixBitcoin), isNegative);
|
||||
expect(() => fiveBitcoin.compareTo(fiveMonero), throwsArgumentError);
|
||||
});
|
||||
});
|
||||
|
||||
group("Math", () {
|
||||
test("+", () {
|
||||
final oneBitcoin = Money.parse("1", CryptoCurrency.btc);
|
||||
final twoBitcoin = Money.parse("2", CryptoCurrency.btc);
|
||||
final threeBitcoin = Money.parse("3", CryptoCurrency.btc);
|
||||
|
||||
expect(oneBitcoin + twoBitcoin, equals(threeBitcoin));
|
||||
});
|
||||
|
||||
test("cannot add different currencies", () {
|
||||
final oneBitcoin = Money(BigInt.one, CryptoCurrency.btc);
|
||||
final oneMonero = Money(BigInt.one, CryptoCurrency.xmr);
|
||||
|
||||
expect(() => oneBitcoin + oneMonero, throwsArgumentError);
|
||||
});
|
||||
|
||||
test("invert money", () {
|
||||
final oneSatoshi = Money(BigInt.one, CryptoCurrency.btc);
|
||||
final negativeOneSatoshi = Money(BigInt.from(-1), CryptoCurrency.btc);
|
||||
|
||||
expect(-oneSatoshi, equals(negativeOneSatoshi));
|
||||
expect(-negativeOneSatoshi, equals(oneSatoshi));
|
||||
});
|
||||
|
||||
test("-", () {
|
||||
final oneSatoshi = Money(BigInt.one, CryptoCurrency.btc);
|
||||
final twoSatoshi = Money(BigInt.two, CryptoCurrency.btc);
|
||||
final threeSatoshi = Money(BigInt.from(3), CryptoCurrency.btc);
|
||||
|
||||
expect(threeSatoshi - oneSatoshi, equals(twoSatoshi));
|
||||
});
|
||||
|
||||
test("cannot subtract different currencies", () {
|
||||
final oneSatoshi = Money(BigInt.one, CryptoCurrency.btc);
|
||||
final oneMonero = Money(BigInt.one, CryptoCurrency.xmr);
|
||||
|
||||
expect(() => oneSatoshi - oneMonero, throwsArgumentError);
|
||||
});
|
||||
|
||||
test("*", () {
|
||||
final zeroSatoshi = Money(BigInt.zero, CryptoCurrency.btc);
|
||||
final oneSatoshi = Money(BigInt.one, CryptoCurrency.btc);
|
||||
final twoSatoshi = Money(BigInt.two, CryptoCurrency.btc);
|
||||
|
||||
expect(oneSatoshi * BigInt.zero, equals(zeroSatoshi));
|
||||
expect(oneSatoshi * BigInt.two, equals(twoSatoshi));
|
||||
expect(oneSatoshi * BigInt.from(-2), equals(-twoSatoshi));
|
||||
});
|
||||
|
||||
group("/", () {
|
||||
final threeBI = BigInt.from(3);
|
||||
final fourBI = BigInt.from(4);
|
||||
|
||||
final negThreeBI = BigInt.from(-3);
|
||||
final negFourBI = BigInt.from(-4);
|
||||
|
||||
test("rounds down when fractional part is < 0.5", () {
|
||||
expect((Money(BigInt.from(10), CryptoCurrency.btc) / threeBI).amount, threeBI);
|
||||
expect((Money(BigInt.from(-10), CryptoCurrency.btc) / threeBI).amount, negThreeBI);
|
||||
expect((Money(BigInt.from(10), CryptoCurrency.btc) / negThreeBI).amount, negThreeBI);
|
||||
expect((Money(BigInt.from(-10), CryptoCurrency.btc) / negThreeBI).amount, threeBI);
|
||||
});
|
||||
|
||||
test("rounds up when fractional part is > 0.5", () {
|
||||
expect((Money(BigInt.from(11), CryptoCurrency.btc) / threeBI).amount, fourBI); // 3.66...
|
||||
expect((Money(BigInt.from(-11), CryptoCurrency.btc) / threeBI).amount, negFourBI);
|
||||
expect((Money(BigInt.from(11), CryptoCurrency.btc) / negThreeBI).amount, negFourBI);
|
||||
expect((Money(BigInt.from(-11), CryptoCurrency.btc) / negThreeBI).amount, fourBI);
|
||||
});
|
||||
|
||||
test("rounds AWAY from zero when fractional part is exactly 0.5 (Ties)", () {
|
||||
// 5 / 2 = 2.5 -> 3
|
||||
expect((Money(BigInt.from(5), CryptoCurrency.btc) / BigInt.two).amount, threeBI);
|
||||
// -5 / 2 = -2.5 -> -3
|
||||
expect((Money(BigInt.from(-5), CryptoCurrency.btc) / BigInt.two).amount, negThreeBI);
|
||||
// 5 / -2 = -2.5 -> -3
|
||||
expect((Money(BigInt.from(5), CryptoCurrency.btc) / -BigInt.two).amount, negThreeBI);
|
||||
// -5 / -2 = 2.5 -> 3
|
||||
expect((Money(BigInt.from(-5), CryptoCurrency.btc) / -BigInt.two).amount, threeBI);
|
||||
});
|
||||
|
||||
test("handles zero numerator correctly", () {
|
||||
expect((Money(BigInt.zero, CryptoCurrency.btc) / BigInt.from(5)).amount, BigInt.zero);
|
||||
expect((Money(BigInt.zero, CryptoCurrency.btc) / BigInt.from(-5)).amount, BigInt.zero);
|
||||
});
|
||||
|
||||
test("throws Exception on division by zero", () {
|
||||
expect(
|
||||
() => Money(BigInt.from(5), CryptoCurrency.btc) / BigInt.zero,
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
|
||||
test("handles very large BigInts without precision loss", () {
|
||||
// A number that would lose precision if converted to a standard double
|
||||
final hugeA = BigInt.parse("10000000000000000000000000000000000005");
|
||||
final hugeB = BigInt.parse("10");
|
||||
// 1000...005 / 10 = 1000...000.5 -> Should round up to 1000...001
|
||||
final expected = BigInt.parse("1000000000000000000000000000000000001");
|
||||
|
||||
expect((Money(hugeA, CryptoCurrency.btc) / hugeB).amount, expected);
|
||||
});
|
||||
|
||||
test("handles exact division perfectly", () {
|
||||
expect(
|
||||
(Money(BigInt.from(100), CryptoCurrency.btc) / BigInt.from(20)).amount,
|
||||
BigInt.from(5),
|
||||
);
|
||||
expect(
|
||||
(Money(BigInt.from(-100), CryptoCurrency.btc) / BigInt.from(20)).amount,
|
||||
BigInt.from(-5),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group("copyWith", () {
|
||||
final money = Money.parse("1", CryptoCurrency.btc);
|
||||
|
||||
test("currency", () {
|
||||
final copy = money.copyWith(currency: CryptoCurrency.eth);
|
||||
expect(copy.amount, equals(BigInt.parse("1000000000000000000")));
|
||||
expect(copy.currency.decimals, equals(18));
|
||||
expect(copy.toString(), "1");
|
||||
});
|
||||
|
||||
test("currency nano", () {
|
||||
final copy = money.copyWith(currency: CryptoCurrency.nano);
|
||||
expect(copy.amount, equals(BigInt.parse("1000000000000000000000000000000")));
|
||||
expect(copy.currency.decimals, equals(30));
|
||||
expect(copy.toString(), "1");
|
||||
});
|
||||
|
||||
test("amount", () {
|
||||
final copy = money.copyWith(amount: BigInt.one);
|
||||
expect(copy.amount, BigInt.one);
|
||||
expect(copy.currency.decimals, 8);
|
||||
});
|
||||
|
||||
test("amount and currency", () {
|
||||
final copy = money.copyWith(amount: BigInt.one);
|
||||
expect(copy.amount, BigInt.one);
|
||||
expect(copy.currency.decimals, 8);
|
||||
});
|
||||
});
|
||||
|
||||
group("toStringWithPrecision", () {
|
||||
test("with fractionalDigits and padded with zeros", () {
|
||||
final money = Money.parse("1", CryptoCurrency.btc);
|
||||
expect(money.amount, equals(BigInt.parse("100000000")));
|
||||
expect(money.currency.decimals, equals(8));
|
||||
expect(money.toStringWithPrecision(fractionalDigits: 5, trimZeros: false), "1.00000");
|
||||
});
|
||||
|
||||
test("using base unit sats", () {
|
||||
final money = Money.parse("1", CryptoCurrency.btc);
|
||||
expect(money.amount, equals(BigInt.parse("100000000")));
|
||||
expect(money.currency.decimals, equals(8));
|
||||
expect(money.toStringWithPrecision(useBaseUnit: true), "100000000");
|
||||
});
|
||||
});
|
||||
|
||||
group("toStringWithSymbol", () {
|
||||
test("with fractionalDigits and padded with zeros", () {
|
||||
final money = Money.parse("1", CryptoCurrency.btc);
|
||||
expect(money.amount, equals(BigInt.parse("100000000")));
|
||||
expect(money.currency.decimals, equals(8));
|
||||
expect(money.toStringWithSymbol(fractionalDigits: 5, trimZeros: false), "1.00000 BTC");
|
||||
});
|
||||
|
||||
test("using base unit sats", () {
|
||||
final money = Money.parse("1", CryptoCurrency.btc);
|
||||
expect(money.amount, equals(BigInt.parse("100000000")));
|
||||
expect(money.currency.decimals, equals(8));
|
||||
expect(money.toStringWithSymbol(useBaseUnit: true), "100000000 sats");
|
||||
});
|
||||
|
||||
group("withSymbolPrefix", () {
|
||||
test("with fractionalDigits and padded with zeros", () {
|
||||
final money = Money.parse("1", CryptoCurrency.btc);
|
||||
expect(money.amount, equals(BigInt.parse("100000000")));
|
||||
expect(money.currency.decimals, equals(8));
|
||||
expect(
|
||||
money.toStringWithSymbol(fractionalDigits: 5, trimZeros: false, withSymbolPrefix: true),
|
||||
"BTC 1.00000",
|
||||
);
|
||||
});
|
||||
|
||||
test("using base unit sats", () {
|
||||
final money = Money.parse("1", CryptoCurrency.btc);
|
||||
expect(money.amount, equals(BigInt.parse("100000000")));
|
||||
expect(money.currency.decimals, equals(8));
|
||||
expect(
|
||||
money.toStringWithSymbol(useBaseUnit: true, withSymbolPrefix: true),
|
||||
"sats 100000000",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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,127 @@
|
||||
import "package:cw_core/amount/money.dart";
|
||||
import "package:cw_core/amount/money_double.dart";
|
||||
import "package:cw_core/crypto_currency.dart";
|
||||
import "package:cw_core/parse_fixed.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
|
||||
/// Every non-finite spelling that `double.tryParse` accepts.
|
||||
///
|
||||
/// These matter because a `double`-based amount guard cannot reject them:
|
||||
/// `Infinity <= 0` is false and *every* comparison against `NaN` is false, so a
|
||||
/// non-finite amount sails through an `if (amount <= 0) throw` check and then
|
||||
/// collapses to zero once it reaches Money/BigInt -- i.e. a zero-amount
|
||||
/// transaction that passed validation. The Money layer is the enforcement point
|
||||
/// for that invariant, so pin it here.
|
||||
const nonFiniteSpellings = <String>[
|
||||
"Infinity",
|
||||
"+Infinity",
|
||||
"-Infinity",
|
||||
"NaN",
|
||||
"1e999", // overflows to Infinity as a double
|
||||
"1E400",
|
||||
" Infinity ",
|
||||
];
|
||||
|
||||
void main() {
|
||||
group("non-finite amounts", () {
|
||||
test("double.tryParse accepts them (this is why Money must reject them)", () {
|
||||
for (final spelling in nonFiniteSpellings) {
|
||||
final parsed = double.tryParse(spelling);
|
||||
expect(parsed, isNotNull, reason: "double.tryParse rejected $spelling");
|
||||
expect(parsed!.isFinite, isFalse, reason: "$spelling parsed as finite");
|
||||
// The point of the whole exercise: a `<= 0` guard does not stop these.
|
||||
if (!parsed.isNegative) {
|
||||
expect(parsed <= 0, isFalse, reason: "$spelling was caught by a <= 0 guard");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("Money.tryParse returns null for every non-finite spelling", () {
|
||||
for (final spelling in nonFiniteSpellings) {
|
||||
expect(
|
||||
Money.tryParse(spelling, CryptoCurrency.btc),
|
||||
isNull,
|
||||
reason: "Money.tryParse accepted $spelling",
|
||||
);
|
||||
expect(
|
||||
Money.tryParse(spelling, CryptoCurrency.btc, strictParsing: false),
|
||||
isNull,
|
||||
reason: "Money.tryParse(strictParsing: false) accepted $spelling",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Money.parse throws for every non-finite spelling", () {
|
||||
for (final spelling in nonFiniteSpellings) {
|
||||
expect(
|
||||
() => Money.parse(spelling, CryptoCurrency.btc),
|
||||
throwsA(isA<FormatException>()),
|
||||
reason: "Money.parse accepted $spelling",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("CryptoCurrency.tryParseAmount returns null, parseAmount throws", () {
|
||||
for (final spelling in nonFiniteSpellings) {
|
||||
expect(CryptoCurrency.btc.tryParseAmount(spelling), isNull, reason: spelling);
|
||||
expect(
|
||||
() => CryptoCurrency.btc.parseAmount(spelling),
|
||||
throwsA(isA<FormatException>()),
|
||||
reason: spelling,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("tryParseFixed returns null / parseFixed throws (the underlying gate)", () {
|
||||
for (final spelling in nonFiniteSpellings) {
|
||||
expect(tryParseFixed(spelling, 8), isNull, reason: spelling);
|
||||
expect(
|
||||
() => parseFixed(spelling, 8),
|
||||
throwsA(isA<FormatException>()),
|
||||
reason: spelling,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("double.tryToMoney returns null for non-finite doubles", () {
|
||||
// Relied on by ExchangeViewModel.changeReceiveAmount/changeDepositAmount, which
|
||||
// divide by `bestRate` and can produce Infinity when the rate is still 0.
|
||||
expect(double.infinity.tryToMoney(CryptoCurrency.btc), isNull);
|
||||
expect(double.negativeInfinity.tryToMoney(CryptoCurrency.btc), isNull);
|
||||
expect(double.nan.tryToMoney(CryptoCurrency.btc), isNull);
|
||||
});
|
||||
|
||||
test("a rejected amount funnels to Money.zero, which is the sweep-all sentinel", () {
|
||||
// Why the cw_monero/cw_wownero guards exist. MONERO_Wallet_createTransaction:
|
||||
// Monero::optional<uint64_t> optAmount;
|
||||
// if (amount != 0) { optAmount = amount; }
|
||||
// so amount == 0 means "no amount given" and wallet2 sweeps the whole account.
|
||||
// Output.cryptoAmountMoney falls back to Money.zero on any parse failure, and a
|
||||
// zero Money stringifies to an all-zeros decimal -- exactly the value the native
|
||||
// layer reads as "send everything". Nothing between them may treat 0 as an amount.
|
||||
for (final spelling in nonFiniteSpellings) {
|
||||
final fallback =
|
||||
CryptoCurrency.btc.tryParseAmount(spelling) ?? Money.zero(CryptoCurrency.xmr);
|
||||
expect(fallback.amount, BigInt.zero, reason: spelling);
|
||||
}
|
||||
|
||||
final zeroXmr = Money.zero(CryptoCurrency.xmr);
|
||||
expect(zeroXmr.amount, BigInt.zero);
|
||||
expect(double.tryParse(zeroXmr.toString()), 0.0,
|
||||
reason: "a zero Money must stringify to a numeric zero, i.e. the sweep-all sentinel");
|
||||
expect(RegExp(r"^0(\.0+)?$").hasMatch(zeroXmr.toString()), isTrue,
|
||||
reason: "unexpected zero rendering: ${zeroXmr.toString()}");
|
||||
|
||||
// One piconero is NOT zero -- the guards must not reject genuine dust.
|
||||
expect(Money(BigInt.one, CryptoCurrency.xmr).sign, 1);
|
||||
expect(double.parse(Money(BigInt.one, CryptoCurrency.xmr).toString()) > 0, isTrue);
|
||||
});
|
||||
|
||||
test("finite amounts still round-trip (guard did not over-reject)", () {
|
||||
expect(Money.tryParse("0", CryptoCurrency.btc)!.amount, BigInt.zero);
|
||||
expect(Money.tryParse("1", CryptoCurrency.btc)!.amount, BigInt.from(100000000));
|
||||
expect(Money.tryParse("0.00000001", CryptoCurrency.btc)!.amount, BigInt.one);
|
||||
expect(1.5.tryToMoney(CryptoCurrency.btc)!.amount, BigInt.from(150000000));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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
|
||||
String get apiString => "fiat.$symbol";
|
||||
|
||||
@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);
|
||||
@@ -108,6 +108,15 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group("Locale without intl number data", () {
|
||||
test("should format as English instead of throwing (ha)", () {
|
||||
const input = "1123.4567";
|
||||
final result = input.withLocalSeperator("ha");
|
||||
|
||||
expect(result, equals("1,123.4567"));
|
||||
});
|
||||
});
|
||||
|
||||
group('With XMR Suffix', () {
|
||||
const locale = 'de_CH';
|
||||
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cake_backup/backup.dart' as cwb;
|
||||
import 'package:cw_core/encryption_file_utils.dart';
|
||||
import 'package:cw_core/key.dart';
|
||||
import 'package:cw_core/utils/file.dart' as encrypted_file;
|
||||
import 'package:cw_core/wallet_keys_file.dart';
|
||||
import 'package:encrypt/encrypt.dart' as encrypt;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
late Directory tmpDir;
|
||||
|
||||
setUp(() {
|
||||
tmpDir = Directory.systemTemp.createTempSync('cw_enc_');
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
if (tmpDir.existsSync()) {
|
||||
tmpDir.deleteSync(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
String path([String name = 'wallet.json']) => '${tmpDir.path}/$name';
|
||||
|
||||
const walletJson = '{"mnemonic":"abandon ability able","privateKey":null}';
|
||||
|
||||
group('XChaCha20 round-trip', () {
|
||||
test('writes and reads JSON with a generated high-entropy password', () async {
|
||||
final password = generateKey();
|
||||
await encrypted_file.write(
|
||||
path: path(),
|
||||
password: password,
|
||||
data: walletJson,
|
||||
highEntropyPassphrase: true,
|
||||
);
|
||||
|
||||
expect(await encrypted_file.read(path: path(), password: password), walletJson);
|
||||
expect(File('${path()}.tmp').existsSync(), isFalse);
|
||||
});
|
||||
|
||||
test('uses high-entropy (v3) vs low-entropy (v2) version bytes', () async {
|
||||
final password = generateKey();
|
||||
|
||||
await encrypted_file.write(
|
||||
path: path('high'),
|
||||
password: password,
|
||||
data: walletJson,
|
||||
highEntropyPassphrase: true,
|
||||
);
|
||||
expect(File(path('high')).readAsBytesSync().first, cwb.highEntropyVersion);
|
||||
|
||||
await encrypted_file.write(
|
||||
path: path('low'),
|
||||
password: password,
|
||||
data: walletJson,
|
||||
highEntropyPassphrase: false,
|
||||
);
|
||||
expect(File(path('low')).readAsBytesSync().first, cwb.lowEntropyVersion);
|
||||
|
||||
expect(await encrypted_file.read(path: path('high'), password: password), walletJson);
|
||||
expect(await encrypted_file.read(path: path('low'), password: password), walletJson);
|
||||
});
|
||||
|
||||
test('read ignores highEntropyPassphrase because version is in the file', () async {
|
||||
final password = generateKey();
|
||||
await encrypted_file.write(
|
||||
path: path(),
|
||||
password: password,
|
||||
data: walletJson,
|
||||
highEntropyPassphrase: true,
|
||||
);
|
||||
|
||||
expect(
|
||||
await encrypted_file.read(
|
||||
path: path(),
|
||||
password: password,
|
||||
highEntropyPassphrase: false,
|
||||
),
|
||||
walletJson,
|
||||
);
|
||||
});
|
||||
|
||||
test('round-trips UTF-8 JSON with non-ASCII characters', () async {
|
||||
final password = generateKey();
|
||||
const data = '{"name":"Café","mnemonic":"abandon"}';
|
||||
await encrypted_file.write(
|
||||
path: path(),
|
||||
password: password,
|
||||
data: data,
|
||||
highEntropyPassphrase: true,
|
||||
);
|
||||
|
||||
expect(await encrypted_file.read(path: path(), password: password), data);
|
||||
});
|
||||
|
||||
test('wrong password does not fall back to Salsa20 or rewrite the file', () async {
|
||||
final password = generateKey();
|
||||
await encrypted_file.write(
|
||||
path: path(),
|
||||
password: password,
|
||||
data: walletJson,
|
||||
highEntropyPassphrase: true,
|
||||
);
|
||||
final before = File(path()).readAsBytesSync();
|
||||
|
||||
await expectLater(
|
||||
encrypted_file.read(path: path(), password: generateKey()),
|
||||
throwsA(anything),
|
||||
);
|
||||
expect(File(path()).readAsBytesSync(), before);
|
||||
});
|
||||
|
||||
test('corrupt XChaCha20 blob is not treated as a legacy Salsa20 file', () async {
|
||||
final password = generateKey();
|
||||
await encrypted_file.write(
|
||||
path: path(),
|
||||
password: password,
|
||||
data: walletJson,
|
||||
highEntropyPassphrase: true,
|
||||
);
|
||||
final bytes = File(path()).readAsBytesSync();
|
||||
bytes[bytes.length - 1] = bytes[bytes.length - 1] ^ 0xFF;
|
||||
File(path()).writeAsBytesSync(bytes);
|
||||
|
||||
await expectLater(
|
||||
encrypted_file.read(path: path(), password: password),
|
||||
throwsA(anything),
|
||||
);
|
||||
expect(File(path()).readAsBytesSync().first, cwb.highEntropyVersion);
|
||||
});
|
||||
});
|
||||
|
||||
group('encryptionFileUtilsFor', () {
|
||||
test('isDirect=false (generated password) writes high-entropy v3', () async {
|
||||
final password = generateKey();
|
||||
final encryption = encryptionFileUtilsFor(false);
|
||||
await encryption.write(path: path(), password: password, data: walletJson);
|
||||
|
||||
expect(File(path()).readAsBytesSync().first, cwb.highEntropyVersion);
|
||||
expect(await encryption.read(path: path(), password: password), walletJson);
|
||||
});
|
||||
|
||||
test('isDirect=true (user password) writes low-entropy v2', () async {
|
||||
const password = 'user-chosen-passphrase';
|
||||
final encryption = encryptionFileUtilsFor(true);
|
||||
await encryption.write(path: path(), password: password, data: walletJson);
|
||||
|
||||
expect(File(path()).readAsBytesSync().first, cwb.lowEntropyVersion);
|
||||
expect(await encryption.read(path: path(), password: password), walletJson);
|
||||
});
|
||||
|
||||
test('WalletKeysData JSON survives a write/read used by getSeeds', () async {
|
||||
final password = generateKey();
|
||||
final keys = WalletKeysData(
|
||||
mnemonic: 'abandon ability able about above absent absorb abstract',
|
||||
privateKey: 'deadbeef',
|
||||
);
|
||||
final encryption = encryptionFileUtilsFor(false);
|
||||
await encryption.write(path: path(), password: password, data: keys.toJSON());
|
||||
|
||||
final decoded = json.decode(await encryption.read(path: path(), password: password))
|
||||
as Map<String, dynamic>;
|
||||
final restored = WalletKeysData.fromJSON(decoded);
|
||||
expect(restored.mnemonic, keys.mnemonic);
|
||||
expect(restored.privateKey, keys.privateKey);
|
||||
});
|
||||
});
|
||||
|
||||
group('legacy Salsa20 migration', () {
|
||||
test('reads a Salsa20 wallet file and re-encrypts it as XChaCha20', () async {
|
||||
final password = generateKey();
|
||||
File(path()).writeAsStringSync(_salsa20Encrypt(password, walletJson));
|
||||
expect(File(path()).readAsBytesSync().first, isNot(cwb.lowEntropyVersion));
|
||||
expect(File(path()).readAsBytesSync().first, isNot(cwb.highEntropyVersion));
|
||||
|
||||
final result = await encrypted_file.read(
|
||||
path: path(),
|
||||
password: password,
|
||||
highEntropyPassphrase: true,
|
||||
);
|
||||
|
||||
expect(result, walletJson);
|
||||
expect(File(path()).readAsBytesSync().first, cwb.highEntropyVersion);
|
||||
expect(await encrypted_file.read(path: path(), password: password), walletJson);
|
||||
});
|
||||
|
||||
test('migrated file can be opened with EncryptionFileUtils', () async {
|
||||
final password = generateKey();
|
||||
File(path()).writeAsStringSync(_salsa20Encrypt(password, walletJson));
|
||||
|
||||
final encryption = encryptionFileUtilsFor(false);
|
||||
expect(await encryption.read(path: path(), password: password), walletJson);
|
||||
expect(await encryption.read(path: path(), password: password), walletJson);
|
||||
});
|
||||
|
||||
test('wrong password does not rewrite the Salsa20 file', () async {
|
||||
final password = generateKey();
|
||||
final salsa = _salsa20Encrypt(password, walletJson);
|
||||
File(path()).writeAsStringSync(salsa);
|
||||
|
||||
await expectLater(
|
||||
encrypted_file.read(path: path(), password: generateKey()),
|
||||
throwsLegacyDecryptError,
|
||||
);
|
||||
expect(File(path()).readAsStringSync(), salsa);
|
||||
});
|
||||
|
||||
test('short or malformed password does not leak _readLegacy errors', () async {
|
||||
final password = generateKey();
|
||||
final salsa = _salsa20Encrypt(password, walletJson);
|
||||
File(path()).writeAsStringSync(salsa);
|
||||
|
||||
await expectLater(
|
||||
encrypted_file.read(path: path(), password: 'user-pass'),
|
||||
throwsLegacyDecryptError,
|
||||
);
|
||||
expect(File(path()).readAsStringSync(), salsa);
|
||||
});
|
||||
|
||||
test('corrupt non-base64 file does not leak _readLegacy errors', () async {
|
||||
File(path()).writeAsStringSync('not-valid-base64!!!');
|
||||
|
||||
await expectLater(
|
||||
encrypted_file.read(path: path(), password: generateKey()),
|
||||
throwsLegacyDecryptError,
|
||||
);
|
||||
expect(File(path()).readAsStringSync(), 'not-valid-base64!!!');
|
||||
});
|
||||
|
||||
test('invalid UTF-8 legacy bytes do not leak _readLegacy errors', () async {
|
||||
File(path()).writeAsBytesSync(const [0x00, 0xFF, 0xFE, 0x01]);
|
||||
|
||||
await expectLater(
|
||||
encrypted_file.read(path: path(), password: generateKey()),
|
||||
throwsLegacyDecryptError,
|
||||
);
|
||||
expect(File(path()).readAsBytesSync(), const [0x00, 0xFF, 0xFE, 0x01]);
|
||||
});
|
||||
|
||||
test('rejects Salsa20 plaintext that does not start with {"', () async {
|
||||
final password = generateKey();
|
||||
File(path()).writeAsStringSync(_salsa20Encrypt(password, 'not-json-payload'));
|
||||
|
||||
await expectLater(
|
||||
encrypted_file.read(path: path(), password: password),
|
||||
throwsLegacyDecryptError,
|
||||
);
|
||||
expect(File(path()).readAsBytesSync().first, isNot(cwb.highEntropyVersion));
|
||||
expect(File(path()).readAsBytesSync().first, isNot(cwb.lowEntropyVersion));
|
||||
});
|
||||
|
||||
test('does not re-encrypt Salsa20 data that starts with {" but is not JSON', () async {
|
||||
final password = generateKey();
|
||||
const garbage = '{"not valid json';
|
||||
File(path()).writeAsStringSync(_salsa20Encrypt(password, garbage));
|
||||
|
||||
expect(await encrypted_file.read(path: path(), password: password), garbage);
|
||||
expect(File(path()).readAsBytesSync().first, isNot(cwb.highEntropyVersion));
|
||||
expect(File(path()).readAsBytesSync().first, isNot(cwb.lowEntropyVersion));
|
||||
});
|
||||
|
||||
test(
|
||||
'wrong password that decrypts to {" does not rewrite the file',
|
||||
() async {
|
||||
final password = generateKey();
|
||||
final salsa = _salsa20Encrypt(password, walletJson);
|
||||
File(path()).writeAsStringSync(salsa);
|
||||
|
||||
final collision = _findWrongPasswordWithJsonObjectPrefix(salsa);
|
||||
expect(collision, isNot(password));
|
||||
expect(_salsa20Decrypt(collision, salsa).startsWith('{"'), isTrue);
|
||||
expect(() => json.decode(_salsa20Decrypt(collision, salsa)), throwsA(anything));
|
||||
|
||||
final result = await encrypted_file.read(path: path(), password: collision);
|
||||
|
||||
expect(result.startsWith('{"'), isTrue);
|
||||
expect(result, isNot(walletJson));
|
||||
expect(File(path()).readAsStringSync(), salsa);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('legacy Linux XChaCha20 Latin-1 payloads', () {
|
||||
test('decodes files stored as one byte per UTF-16 code unit', () async {
|
||||
final password = generateKey();
|
||||
const data = '{"name":"Café","mnemonic":"abandon"}';
|
||||
// Old XChaCha20EncryptionFileUtils used String.codeUnits instead of utf8.encode.
|
||||
final encrypted = await cwb.encrypt(
|
||||
password,
|
||||
Uint8List.fromList(data.codeUnits),
|
||||
highEntropyPassphrase: true,
|
||||
);
|
||||
File(path()).writeAsBytesSync(encrypted);
|
||||
|
||||
expect(await encrypted_file.read(path: path(), password: password), data);
|
||||
});
|
||||
});
|
||||
|
||||
group('generateKey', () {
|
||||
test('produces unique passwords that still encrypt and decrypt', () async {
|
||||
final a = generateKey();
|
||||
final b = generateKey();
|
||||
expect(a, isNot(b));
|
||||
expect(a.length, greaterThan(12));
|
||||
|
||||
await encrypted_file.write(
|
||||
path: path(),
|
||||
password: a,
|
||||
data: walletJson,
|
||||
highEntropyPassphrase: true,
|
||||
);
|
||||
expect(await encrypted_file.read(path: path(), password: a), walletJson);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
final throwsLegacyDecryptError = throwsA(
|
||||
isA<Exception>().having(
|
||||
(e) => e.toString(),
|
||||
'message',
|
||||
contains('Failed to decrypt legacy file'),
|
||||
),
|
||||
);
|
||||
|
||||
({encrypt.Key key, encrypt.IV iv}) _salsa20Keys(String password) {
|
||||
const ivEncodedStringLength = 12;
|
||||
return (
|
||||
key: encrypt.Key.fromBase64(password.substring(0, password.length - ivEncodedStringLength)),
|
||||
iv: encrypt.IV.fromBase64(password.substring(password.length - ivEncodedStringLength)),
|
||||
);
|
||||
}
|
||||
|
||||
encrypt.Encrypter _salsa20(String password) {
|
||||
final keys = _salsa20Keys(password);
|
||||
return encrypt.Encrypter(encrypt.Salsa20(keys.key));
|
||||
}
|
||||
|
||||
String _salsa20Encrypt(String password, String data) {
|
||||
final keys = _salsa20Keys(password);
|
||||
return _salsa20(password).encrypt(data, iv: keys.iv).base64;
|
||||
}
|
||||
|
||||
String _salsa20Decrypt(String password, String data) {
|
||||
final keys = _salsa20Keys(password);
|
||||
return _salsa20(password).decrypt64(data, iv: keys.iv);
|
||||
}
|
||||
|
||||
List<int> _salsa20DecryptBytes(String password, String data) {
|
||||
final keys = _salsa20Keys(password);
|
||||
return _salsa20(password).decryptBytes(encrypt.Encrypted.fromBase64(data), iv: keys.iv);
|
||||
}
|
||||
|
||||
String _passwordFromNonce(int nonce) {
|
||||
final ivBytes = Uint8List(8);
|
||||
ivBytes.buffer.asByteData().setUint64(0, nonce, Endian.little);
|
||||
return encrypt.Key(Uint8List(32)).base64 + encrypt.IV(ivBytes).base64;
|
||||
}
|
||||
|
||||
/// Salsa20 has no MAC, so a wrong key decrypts to garbage. The first two bytes
|
||||
/// are `{"` about once in 2^16 tries; keep going until that happens, then skip
|
||||
/// the much rarer case where the garbage is also valid JSON (that would rewrite).
|
||||
String _findWrongPasswordWithJsonObjectPrefix(String ciphertext) {
|
||||
const maxAttempts = 1 << 20;
|
||||
for (var nonce = 0; nonce < maxAttempts; nonce++) {
|
||||
final candidate = _passwordFromNonce(nonce);
|
||||
final bytes = _salsa20DecryptBytes(candidate, ciphertext);
|
||||
if (bytes.length < 2 || bytes[0] != 0x7b || bytes[1] != 0x22) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
json.decode(_salsa20Decrypt(candidate, ciphertext));
|
||||
} catch (_) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
fail('no Salsa20 {" prefix collision in $maxAttempts attempts');
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import "dart:io";
|
||||
|
||||
import "package:cw_core/db/sqlite.dart";
|
||||
import "package:cw_core/imported_nft.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:path_provider_platform_interface/path_provider_platform_interface.dart";
|
||||
import "package:sqflite_common_ffi/sqflite_ffi.dart";
|
||||
|
||||
// Faking the documents dir keeps getAppDir() off the platform channel, so the
|
||||
// test runs with a plain `flutter test` on any host and in CI.
|
||||
class _FakePathProviderPlatform extends PathProviderPlatform {
|
||||
_FakePathProviderPlatform(this.root);
|
||||
|
||||
final String root;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationDocumentsPath() async => root;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => root;
|
||||
}
|
||||
|
||||
Future<void> main() async {
|
||||
final dataRoot = Directory("./test/data/imported_nft");
|
||||
|
||||
Future<void> insert(String walletName, String chain, String identifier) =>
|
||||
ImportedNFT(walletName: walletName, chain: chain, identifier: identifier, name: identifier)
|
||||
.save();
|
||||
|
||||
Future<List<String>> identifiersFor(String walletName, [String? chain]) async =>
|
||||
(await ImportedNFT.getAllForWallet(walletName, chain)).map((nft) => nft.identifier).toList();
|
||||
|
||||
setUpAll(() async {
|
||||
if (dataRoot.existsSync()) {
|
||||
dataRoot.deleteSync(recursive: true);
|
||||
}
|
||||
dataRoot.createSync(recursive: true);
|
||||
Directory("${dataRoot.path}/cake_wallet").createSync(recursive: true);
|
||||
|
||||
PathProviderPlatform.instance = _FakePathProviderPlatform(dataRoot.absolute.path);
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
await initDb();
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
await db!.delete(ImportedNFT.tableName);
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
if (dataRoot.existsSync()) {
|
||||
dataRoot.deleteSync(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
group("ImportedNFT wallet lifecycle", () {
|
||||
test("reading without a chain returns every chain the wallet imported on", () async {
|
||||
await insert("Main", ImportedNFT.solanaChain, "SolMint");
|
||||
await insert("Main", "eth", "0xcontract:1");
|
||||
await insert("Main", "polygon", "0xother:7");
|
||||
|
||||
expect(await identifiersFor("Main", "eth"), ["0xcontract:1"]);
|
||||
expect(
|
||||
(await identifiersFor("Main"))..sort(),
|
||||
["0xcontract:1", "0xother:7", "SolMint"],
|
||||
);
|
||||
});
|
||||
|
||||
test("rename moves every chain and leaves other wallets alone", () async {
|
||||
await insert("Main", ImportedNFT.solanaChain, "SolMint");
|
||||
await insert("Main", "eth", "0xcontract:1");
|
||||
await insert("Sidekick", "eth", "0xkeep:9");
|
||||
|
||||
await ImportedNFT.renameWallet("Main", "Renamed");
|
||||
|
||||
expect(await identifiersFor("Main"), isEmpty);
|
||||
expect((await identifiersFor("Renamed"))..sort(), ["0xcontract:1", "SolMint"]);
|
||||
expect(await identifiersFor("Sidekick"), ["0xkeep:9"]);
|
||||
});
|
||||
|
||||
test("renaming into a name that still has orphan rows does not trip the unique index",
|
||||
() async {
|
||||
await insert("Orphaned", "eth", "0xcontract:1");
|
||||
await insert("Orphaned", "polygon", "0xsurvivor:5");
|
||||
await insert("Main", "eth", "0xcontract:1");
|
||||
await insert("Main", "eth", "0xsecond:2");
|
||||
|
||||
await ImportedNFT.renameWallet("Main", "Orphaned", chains: const ["eth"]);
|
||||
|
||||
// Only the colliding chain is cleared. A row the destination holds on
|
||||
// another chain is not ours to delete.
|
||||
expect(
|
||||
(await identifiersFor("Orphaned"))..sort(),
|
||||
["0xcontract:1", "0xsecond:2", "0xsurvivor:5"],
|
||||
);
|
||||
});
|
||||
|
||||
test("renaming a solana wallet leaves a same-named evm wallet's NFTs alone", () async {
|
||||
await insert("Sol", ImportedNFT.solanaChain, "SolMint");
|
||||
await insert("Vault", "eth", "0xEthNFT:1");
|
||||
|
||||
await ImportedNFT.renameWallet(
|
||||
"Sol",
|
||||
"Vault",
|
||||
chains: const [ImportedNFT.solanaChain],
|
||||
);
|
||||
|
||||
expect((await identifiersFor("Vault"))..sort(), ["0xEthNFT:1", "SolMint"]);
|
||||
});
|
||||
|
||||
test("deleting a solana wallet leaves a same-named evm wallet's NFTs alone", () async {
|
||||
await insert("Vault", ImportedNFT.solanaChain, "SolMint");
|
||||
await insert("Vault", "eth", "0xEthNFT:1");
|
||||
|
||||
await ImportedNFT.deleteAllForWallet(
|
||||
"Vault",
|
||||
chains: const [ImportedNFT.solanaChain],
|
||||
);
|
||||
|
||||
expect(await identifiersFor("Vault"), ["0xEthNFT:1"]);
|
||||
});
|
||||
|
||||
test("an empty chain scope deletes nothing rather than everything", () async {
|
||||
await insert("Main", "eth", "0xcontract:1");
|
||||
|
||||
await ImportedNFT.deleteAllForWallet("Main", chains: const []);
|
||||
|
||||
expect(await identifiersFor("Main"), ["0xcontract:1"]);
|
||||
});
|
||||
|
||||
test("delete removes every chain for that wallet only", () async {
|
||||
await insert("Main", ImportedNFT.solanaChain, "SolMint");
|
||||
await insert("Main", "eth", "0xcontract:1");
|
||||
await insert("Sidekick", ImportedNFT.solanaChain, "OtherMint");
|
||||
|
||||
await ImportedNFT.deleteAllForWallet("Main");
|
||||
|
||||
expect(await identifiersFor("Main"), isEmpty);
|
||||
expect(await identifiersFor("Sidekick"), ["OtherMint"]);
|
||||
});
|
||||
|
||||
test("deleting one leaves the wallet's other imports in place", () async {
|
||||
await insert("Main", "eth", "0xcontract:1");
|
||||
await insert("Main", "eth", "0xsecond:2");
|
||||
await insert("Main", "polygon", "0xcontract:1");
|
||||
|
||||
await ImportedNFT.deleteOne("Main", "eth", "0xcontract:1");
|
||||
|
||||
expect(await identifiersFor("Main", "eth"), ["0xsecond:2"]);
|
||||
expect(await identifiersFor("Main", "polygon"), ["0xcontract:1"]);
|
||||
});
|
||||
|
||||
test("copying to a new name keeps the rows under the old one", () async {
|
||||
await insert("Main", ImportedNFT.solanaChain, "SolMint");
|
||||
await insert("Main", "eth", "0xcontract:1");
|
||||
|
||||
for (final nft in await ImportedNFT.getAllForWallet("Main")) {
|
||||
await ImportedNFT.copyWith(nft, walletName: "Copy").save();
|
||||
}
|
||||
|
||||
expect((await identifiersFor("Main"))..sort(), ["0xcontract:1", "SolMint"]);
|
||||
expect((await identifiersFor("Copy"))..sort(), ["0xcontract:1", "SolMint"]);
|
||||
});
|
||||
|
||||
test("a copy is a new row, so saving it does not overwrite the original", () async {
|
||||
await insert("Main", "eth", "0xcontract:1");
|
||||
|
||||
final original = (await ImportedNFT.getAllForWallet("Main")).single;
|
||||
final copy = ImportedNFT.copyWith(original, walletName: "Copy");
|
||||
await copy.save();
|
||||
|
||||
expect(copy.id, isNot(original.id));
|
||||
expect((await ImportedNFT.getAllForWallet("Main")).single.id, original.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import "package:cw_core/utils/ipfs_url.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
|
||||
void main() {
|
||||
group("tryNormalizeIpfsUrl", () {
|
||||
test("rewrites an ipfs uri to the gateway", () {
|
||||
expect(tryNormalizeIpfsUrl("ipfs://QmAbC123"), "https://ipfs.io/ipfs/QmAbC123");
|
||||
});
|
||||
|
||||
test("does not double the gateway path when the uri already says ipfs/", () {
|
||||
expect(tryNormalizeIpfsUrl("ipfs://ipfs/QmAbC123"), "https://ipfs.io/ipfs/QmAbC123");
|
||||
});
|
||||
|
||||
test("matches the scheme whatever its case", () {
|
||||
expect(tryNormalizeIpfsUrl("IPFS://QmAbC123"), "https://ipfs.io/ipfs/QmAbC123");
|
||||
expect(tryNormalizeIpfsUrl("Ipfs://QmAbC123"), "https://ipfs.io/ipfs/QmAbC123");
|
||||
});
|
||||
|
||||
test("returns null for an ipfs uri with no cid, rather than a bare gateway url", () {
|
||||
expect(tryNormalizeIpfsUrl("ipfs://"), isNull);
|
||||
expect(tryNormalizeIpfsUrl("ipfs://ipfs/"), isNull);
|
||||
});
|
||||
|
||||
test("passes through an https url that is not ipfs", () {
|
||||
expect(tryNormalizeIpfsUrl("https://arweave.net/abc"), "https://arweave.net/abc");
|
||||
});
|
||||
|
||||
test("drops a scheme nothing can render, rather than handing it to the image widget", () {
|
||||
expect(tryNormalizeIpfsUrl("ar://abc123"), isNull);
|
||||
expect(tryNormalizeIpfsUrl(""), isNull);
|
||||
});
|
||||
|
||||
test("returns null for null", () {
|
||||
expect(tryNormalizeIpfsUrl(null), isNull);
|
||||
});
|
||||
|
||||
test("is idempotent, so a value read back from storage stays stable", () {
|
||||
final once = tryNormalizeIpfsUrl("ipfs://QmAbC123");
|
||||
expect(tryNormalizeIpfsUrl(once), once);
|
||||
});
|
||||
|
||||
test("drops any scheme that is not https", () {
|
||||
// These render through Image.network, which does not go through the proxy.
|
||||
expect(tryNormalizeIpfsUrl("http://127.0.0.1:8080/x.png"), isNull);
|
||||
expect(tryNormalizeIpfsUrl("file:///etc/passwd"), isNull);
|
||||
expect(tryNormalizeIpfsUrl("javascript:alert(1)"), isNull);
|
||||
expect(tryNormalizeIpfsUrl("data:text/html,x"), isNull);
|
||||
});
|
||||
|
||||
test("keeps an https url whatever its casing", () {
|
||||
expect(tryNormalizeIpfsUrl("https://ok.example/a.png"), "https://ok.example/a.png");
|
||||
expect(tryNormalizeIpfsUrl("HTTPS://Up.example/a.png"), "HTTPS://Up.example/a.png");
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:cw_core/amount/money.dart';
|
||||
import 'package:cw_core/crypto_currency.dart';
|
||||
import 'package:cw_core/lnurl.dart';
|
||||
import 'package:cw_core/utils/proxy_wrapper.dart';
|
||||
import 'package:cw_core/utils/tor/abstract.dart';
|
||||
@@ -11,13 +13,14 @@ void main() {
|
||||
expect(content, Uri.parse("https://cake.cash/.well-known/lnurlp/konsti"));
|
||||
});
|
||||
|
||||
test('get amount', () async {
|
||||
CakeTor.instance = await CakeTorInstance.getInstance();
|
||||
|
||||
final content = await LNURL.getPayRequestAmount(
|
||||
"LNURL1DP68GURN8GHJ7CTSWQHXY6T5WFJHZAT9WD6ZU6T09AUZ7URJDAU8JTMKXYHKCM308A5N6MNHVVNXJEPAXGMNWWPEX3JRGDTPXVNXZ0FCX5CRQVPSXQNX602VFE24YNP9XGC8W6T5DQJNYVRPD4HH2MN5GTRNLZ");
|
||||
expect(content, 8500);
|
||||
});
|
||||
// This tests includes a rest request to an URL that expired and due to the lack of an alternative URL we ignore this test
|
||||
// test('get amount', () async {
|
||||
// CakeTor.instance = await CakeTorInstance.getInstance();
|
||||
//
|
||||
// final content = await LNURL.getPayRequestAmount(
|
||||
// "LNURL1DP68GURN8GHJ7CTSWQHXY6T5WFJHZAT9WD6ZU6T09AUZ7URJDAU8JTMKXYHKCM308A5N6MNHVVNXJEPAXGMNWWPEX3JRGDTPXVNXZ0FCX5CRQVPSXQNX602VFE24YNP9XGC8W6T5DQJNYVRPD4HH2MN5GTRNLZ");
|
||||
// expect(content, Money.fromInt(8500, CryptoCurrency.btcln));
|
||||
// });
|
||||
|
||||
test('get no amount', () async {
|
||||
CakeTor.instance = await CakeTorInstance.getInstance();
|
||||
@@ -70,25 +73,25 @@ void main() {
|
||||
test("should get the amount 1 BTC encoded as bitcoin", () {
|
||||
final invoice =
|
||||
"lnbc11p5exaxspp5u6e6lx90rw2vqr2e5axt8ur3v98qnmqfsmh8r0l0mcm7ry70vyvsdqqcqzzsxqrrsssp5eqydmrufk39dwhuxzajwx4xyffn0mlcu3qa8vjwry6qev5gsepkq9qxpqysgqt0zldve9uxt3txjt8wp0gev52lquljgugy26nneue85pjwk5yak4hs2d8533tus07pq5ka6zrnd0ruaeepwres8p7ws5mx63xlwff5sqlxcy8a";
|
||||
expect(getBolt11Amount(invoice), 100000000);
|
||||
expect(getBolt11Amount(invoice), Money.fromInt(100000000, CryptoCurrency.btcln));
|
||||
});
|
||||
|
||||
test("should get the amount 0,1 BTC encoded as millibitcoin", () {
|
||||
final invoice =
|
||||
"lnbc100m1p5exagmpp5n4c5tml3h8h7k4gthc9papkyeymceqqvcldkng4nvxwzj7nu9jnsdqqcqzzsxqrrsssp58kgzrh7y3pevantduc4yg33aa6r79ed6xgmhr0dy40zc8pxrz5ms9qxpqysgqcq27km3v3wcz6pntmsfnz259zy6tff0uwjz9kamdrvmxmdv09gp44mh0ygn7gweqf4zdev4x8e67p5kcvahethchdkkjexhtk7emuqcpasz0lp";
|
||||
expect(getBolt11Amount(invoice), 10000000);
|
||||
expect(getBolt11Amount(invoice), Money.fromInt(10000000, CryptoCurrency.btcln));
|
||||
});
|
||||
|
||||
test("should get the amount 1000 SATS encoded as microbitcoin", () {
|
||||
final invoice =
|
||||
"lnbc10u1p5exfgvpp5n3s9dsw9ddax5c3h8437ya7y4g582uyufyna626yr9vqdfjrhwzqdqqcqzzsxqrrsssp5mmkyp5h35fpaxm4063f3kc7d4nmgd4gd8fv8086wxd784m76kmpq9qxpqysgqye9r0vahpjy9l2je6vakxzk3cjnsyx76r29c4amkz4cmapq5tqt5l7rl0emkd003jjvz2d8jqrw6wc4cduvapuyeseh7a855l82wutcpeq30kg";
|
||||
expect(getBolt11Amount(invoice), 1000);
|
||||
expect(getBolt11Amount(invoice), Money.fromInt(1000, CryptoCurrency.btcln));
|
||||
});
|
||||
|
||||
test("should get the amount 1 SAT encoded as nanobitcoin", () {
|
||||
final invoice =
|
||||
"lnbc10n1p5exazppp5szh273r6nzvwydzsdlg0ws0vhxwnc6nqtqt4sjt0vu6x6l8t5csqdqqcqzzsxqrrsssp5nkup6uht7xlfqaj8lxpe5kx0ejum9p4pvv3nlnndr9ykzge3393s9qxpqysgqufh05a7qs0ezeckvmh7tjkr9ngng96nq274twg20z45ysp7afpdrttm2ky73cpjwpjqsmtzzd79w6lyvszgmkanwn8mj0jcfunrwv3qprg3z3j";
|
||||
expect(getBolt11Amount(invoice), 1);
|
||||
expect(getBolt11Amount(invoice), Money.fromInt(1, CryptoCurrency.btcln));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import "package:cw_core/utils/nft_text.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
|
||||
void main() {
|
||||
group("sanitizeNFTText", () {
|
||||
test("removes the separators that would fake extra lines", () {
|
||||
expect(sanitizeNFTText("Ape\u2028Address: 9xQeW"), "Ape Address: 9xQeW");
|
||||
expect(sanitizeNFTText("Ape\u2029Address: 9xQeW"), "Ape Address: 9xQeW");
|
||||
expect(sanitizeNFTText("Ape\nAddress: 9xQeW"), "Ape Address: 9xQeW");
|
||||
});
|
||||
|
||||
test("removes bidi overrides and isolates that reorder what follows", () {
|
||||
expect(sanitizeNFTText("evil\u202Etxen"), "evil txen");
|
||||
expect(sanitizeNFTText("a\u2066b\u2069c"), "a b c");
|
||||
});
|
||||
|
||||
test("removes invisibles that hide or reorder characters", () {
|
||||
expect(sanitizeNFTText("A\u200BB"), "A B");
|
||||
expect(sanitizeNFTText("A\u200FB"), "A B");
|
||||
expect(sanitizeNFTText("A\u061CB"), "A B");
|
||||
expect(sanitizeNFTText("A\u2060B"), "A B");
|
||||
});
|
||||
|
||||
test("removes control characters and delete", () {
|
||||
expect(sanitizeNFTText("A\u0000B"), "A B");
|
||||
expect(sanitizeNFTText("A\u001FB"), "A B");
|
||||
expect(sanitizeNFTText("A\u007FB"), "A B");
|
||||
});
|
||||
|
||||
test("leaves ordinary text alone", () {
|
||||
expect(sanitizeNFTText("DeGod #1234"), "DeGod #1234");
|
||||
expect(sanitizeNFTText(" padded "), "padded");
|
||||
});
|
||||
|
||||
test("returns null for null", () {
|
||||
expect(sanitizeNFTText(null), isNull);
|
||||
});
|
||||
|
||||
test("counts the cap in characters, so emoji are not cut in half", () {
|
||||
final long = sanitizeNFTText("\u{1F600}" * 600)!;
|
||||
|
||||
expect(long.runes.length, 513);
|
||||
expect(long.runes.last, 0x2026);
|
||||
|
||||
final units = long.codeUnits;
|
||||
final endsOnHighSurrogate = units.isNotEmpty && units.last >= 0xD800 && units.last <= 0xDBFF;
|
||||
expect(endsOnHighSurrogate, isFalse);
|
||||
});
|
||||
|
||||
test("does not truncate a name that is long in code units but short in characters", () {
|
||||
expect(sanitizeNFTText("\u{1F600}" * 400)!.runes.length, 400);
|
||||
});
|
||||
|
||||
test("is idempotent, so re-reading a saved value does not change it", () {
|
||||
const raw = "Ape\u2028Name\u202E with \u{1F600}";
|
||||
final once = sanitizeNFTText(raw);
|
||||
|
||||
expect(sanitizeNFTText(once), once);
|
||||
});
|
||||
|
||||
test("keeps the joiners that carry meaning in persian and emoji", () {
|
||||
// Replacing these put a visible space inside a Persian word and split
|
||||
// a two-person emoji into two glyphs.
|
||||
expect(sanitizeNFTText("\u{1F468}\u200D\u{1F4BB}"), "\u{1F468}\u200D\u{1F4BB}");
|
||||
expect(
|
||||
sanitizeNFTText("\u0645\u06CC\u200C\u062E\u0648\u0627\u0647\u0645"),
|
||||
"\u0645\u06CC\u200C\u062E\u0648\u0627\u0647\u0645",
|
||||
);
|
||||
});
|
||||
|
||||
test("strips the invisibles a mint can hide text behind", () {
|
||||
for (final invisible in [
|
||||
"\u00AD", // soft hyphen
|
||||
"\u034F", // combining grapheme joiner
|
||||
"\u180E", // mongolian vowel separator
|
||||
"\u3164", // hangul filler
|
||||
"\uFEFF", // zero width no-break space
|
||||
"\uFFA0", // halfwidth hangul filler
|
||||
"\u{E0041}", // tag latin capital A
|
||||
]) {
|
||||
expect(
|
||||
sanitizeNFTText("A${invisible}B"),
|
||||
"A B",
|
||||
reason: "U+${invisible.runes.first.toRadixString(16).toUpperCase()} survived",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -45,6 +45,19 @@ void main() {
|
||||
test('should fail to parse `.`, missing value',
|
||||
() => expect(() => parseFixed(".", 6), throwsFormatException));
|
||||
});
|
||||
|
||||
group("parseFixed, zero decimal currency", () {
|
||||
test("should parse 5 as 5", () => expect(parseFixed("5", 0), BigInt.from(5)));
|
||||
|
||||
test("should parse 0 as 0", () => expect(parseFixed("0", 0), BigInt.from(0)));
|
||||
|
||||
test("should parse 1. as 1", () => expect(parseFixed("1.", 0), BigInt.from(1)));
|
||||
|
||||
test("should parse -3 as -3", () => expect(parseFixed("-3", 0), BigInt.from(-3)));
|
||||
|
||||
test("should fail to parse 5.5, fractional component exceeds decimals",
|
||||
() => expect(() => parseFixed("5.5", 0), throwsFormatException));
|
||||
});
|
||||
});
|
||||
|
||||
group('tryParseFixed', () {
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import "package:cw_core/payment_uris.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
|
||||
void main() {
|
||||
group("TronURI", () {
|
||||
const address = "TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL";
|
||||
const contract = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t";
|
||||
|
||||
test("includes the TRC20 contract as the token param", () {
|
||||
final uri = TronURI(amount: "1.5", address: address, contractAddress: contract);
|
||||
|
||||
expect(uri.toString(), "tron:$address?amount=1.5&token=$contract");
|
||||
});
|
||||
|
||||
test("omits the token param for native TRX", () {
|
||||
final uri = TronURI(amount: "1.5", address: address);
|
||||
|
||||
expect(uri.toString(), "tron:$address?amount=1.5");
|
||||
expect(uri.toString().contains("token="), false);
|
||||
});
|
||||
|
||||
test("includes the token param when there is no amount", () {
|
||||
final uri = TronURI(amount: "", address: address, contractAddress: contract);
|
||||
|
||||
expect(uri.toString(), "tron:$address?token=$contract");
|
||||
});
|
||||
|
||||
test("converts a comma decimal separator in the amount", () {
|
||||
final uri = TronURI(amount: "1,5", address: address);
|
||||
|
||||
expect(uri.toString(), "tron:$address?amount=1.5");
|
||||
});
|
||||
|
||||
test("emits a bare address without amount or contract", () {
|
||||
final uri = TronURI(amount: "", address: address);
|
||||
|
||||
expect(uri.toString(), "tron:$address");
|
||||
});
|
||||
});
|
||||
|
||||
group("SolanaURI", () {
|
||||
const address = "4Nd1mYvNQyJ8BDVXLgkvSGpVdQMZ3hxwVFkfwXNq6Wgk";
|
||||
const mint = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB";
|
||||
|
||||
test("includes the SPL mint as the spl-token param", () {
|
||||
final uri = SolanaURI(amount: "2", address: address, contractAddress: mint);
|
||||
|
||||
expect(uri.toString(), "solana:$address?amount=2&spl-token=$mint");
|
||||
});
|
||||
|
||||
test("omits the spl-token param for native SOL", () {
|
||||
final uri = SolanaURI(amount: "2", address: address);
|
||||
|
||||
expect(uri.toString(), "solana:$address?amount=2");
|
||||
expect(uri.toString().contains("spl-token="), false);
|
||||
});
|
||||
|
||||
test("includes the spl-token param when there is no amount", () {
|
||||
final uri = SolanaURI(amount: "", address: address, contractAddress: mint);
|
||||
|
||||
expect(uri.toString(), "solana:$address?spl-token=$mint");
|
||||
});
|
||||
|
||||
test("converts a comma decimal separator in the amount", () {
|
||||
final uri = SolanaURI(amount: "0,5", address: address);
|
||||
|
||||
expect(uri.toString(), "solana:$address?amount=0.5");
|
||||
});
|
||||
|
||||
test("emits a bare address without amount or mint", () {
|
||||
final uri = SolanaURI(amount: "", address: address);
|
||||
|
||||
expect(uri.toString(), "solana:$address");
|
||||
});
|
||||
});
|
||||
|
||||
group("ERC681URI", () {
|
||||
const recipient = "0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6";
|
||||
const contract = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
||||
|
||||
test("includes the mainnet chainId on a plain native transfer", () {
|
||||
final uri = ERC681URI(chainId: 1, address: recipient, amount: "", contractAddress: null);
|
||||
|
||||
expect(uri.toString(), "ethereum:$recipient@1");
|
||||
});
|
||||
|
||||
test("appends the chainId for native transfers", () {
|
||||
final uri = ERC681URI(chainId: 137, address: recipient, amount: "", contractAddress: null);
|
||||
|
||||
expect(uri.toString(), "ethereum:$recipient@137");
|
||||
});
|
||||
|
||||
test("emits the native amount in ERC-681 scientific notation", () {
|
||||
final uri = ERC681URI(chainId: 1, address: recipient, amount: "1", contractAddress: null);
|
||||
|
||||
expect(uri.toString(), "ethereum:$recipient@1?value=1.0e18");
|
||||
});
|
||||
|
||||
test("includes the mainnet chainId in the transfer form", () {
|
||||
final uri = ERC681URI(
|
||||
chainId: 1,
|
||||
address: recipient,
|
||||
amount: "200.172148",
|
||||
contractAddress: contract,
|
||||
tokenDecimals: 6,
|
||||
);
|
||||
|
||||
expect(
|
||||
uri.toString(),
|
||||
"ethereum:$contract@1/transfer?address=$recipient&amount=200.172148",
|
||||
);
|
||||
});
|
||||
|
||||
test("builds the transfer form for a token", () {
|
||||
final uri =
|
||||
ERC681URI(chainId: 137, address: recipient, amount: "", contractAddress: contract);
|
||||
|
||||
expect(uri.toString(), "ethereum:$contract@137/transfer?address=$recipient");
|
||||
});
|
||||
|
||||
test("parses the transfer form back into recipient, contract and chainId", () {
|
||||
final parsed = ERC681URI.fromUri(
|
||||
Uri.parse("ethereum:$contract@1/transfer?address=$recipient&uint256=1000000"),
|
||||
);
|
||||
|
||||
expect(parsed.address, recipient);
|
||||
expect(parsed.contractAddress, contract);
|
||||
expect(parsed.chainId, 1);
|
||||
expect(parsed.rawTokenAmount, "1000000");
|
||||
expect(parsed.amount, "");
|
||||
});
|
||||
|
||||
test("keeps the uint256 raw so the caller can apply the token decimals", () {
|
||||
final parsed = ERC681URI.fromUri(
|
||||
Uri.parse(
|
||||
"ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7@1/transfer?address=$recipient&uint256=1000000",
|
||||
),
|
||||
);
|
||||
|
||||
expect(parsed.rawTokenAmount, "1000000");
|
||||
expect(parsed.amount, "");
|
||||
});
|
||||
|
||||
test("prefers the amount param over the uint256 when both are present", () {
|
||||
final parsed = ERC681URI.fromUri(
|
||||
Uri.parse(
|
||||
"ethereum:$contract@1/transfer?address=$recipient&uint256=1000000000000000000&amount=1",
|
||||
),
|
||||
);
|
||||
|
||||
expect(parsed.amount, "1");
|
||||
expect(parsed.rawTokenAmount, "1000000000000000000");
|
||||
});
|
||||
|
||||
test("treats a uint256 with a decimal point as a display amount", () {
|
||||
final parsed = ERC681URI.fromUri(
|
||||
Uri.parse("ethereum:$contract@1/transfer?address=$recipient&uint256=1.5"),
|
||||
);
|
||||
|
||||
expect(parsed.amount, "1.5");
|
||||
expect(parsed.rawTokenAmount, null);
|
||||
});
|
||||
|
||||
test("parses a native transfer with chainId", () {
|
||||
final parsed = ERC681URI.fromUri(
|
||||
Uri.parse("ethereum:$recipient@137?value=2000000000000000000"),
|
||||
);
|
||||
|
||||
expect(parsed.address, recipient);
|
||||
expect(parsed.contractAddress, null);
|
||||
expect(parsed.chainId, 137);
|
||||
expect(parsed.amount, "2");
|
||||
});
|
||||
|
||||
test("emits the uint256 and the amount param for an 18 decimal token", () {
|
||||
final uri =
|
||||
ERC681URI(chainId: 137, address: recipient, amount: "1", contractAddress: contract);
|
||||
|
||||
expect(
|
||||
uri.toString(),
|
||||
"ethereum:$contract@137/transfer?address=$recipient&uint256=1000000000000000000&amount=1",
|
||||
);
|
||||
});
|
||||
|
||||
test("omits the uint256 for tokens that do not use 18 decimals", () {
|
||||
final uri = ERC681URI(
|
||||
chainId: 137,
|
||||
address: recipient,
|
||||
amount: "1.5",
|
||||
contractAddress: contract,
|
||||
tokenDecimals: 6,
|
||||
);
|
||||
|
||||
expect(uri.toString(), "ethereum:$contract@137/transfer?address=$recipient&amount=1.5");
|
||||
});
|
||||
|
||||
test("emits a stored raw token amount verbatim", () {
|
||||
final uri = ERC681URI(
|
||||
chainId: 1,
|
||||
address: recipient,
|
||||
amount: "",
|
||||
contractAddress: contract,
|
||||
tokenDecimals: 6,
|
||||
rawTokenAmount: "1000000",
|
||||
);
|
||||
|
||||
expect(uri.toString(), "ethereum:$contract@1/transfer?address=$recipient&uint256=1000000");
|
||||
});
|
||||
|
||||
test("parses a scientific notation value", () {
|
||||
final parsed = ERC681URI.fromUri(
|
||||
Uri.parse("ethereum:$recipient@1?value=2.014e18"),
|
||||
);
|
||||
|
||||
expect(parsed.amount, "2.014");
|
||||
});
|
||||
|
||||
test("parses a Cake style amount param", () {
|
||||
final parsed = ERC681URI.fromUri(
|
||||
Uri.parse("ethereum:$recipient?amount=1.5"),
|
||||
);
|
||||
|
||||
expect(parsed.address, recipient);
|
||||
expect(parsed.amount, "1.5");
|
||||
expect(parsed.chainId, 1);
|
||||
});
|
||||
|
||||
test("defaults to mainnet when the chainId is absent", () {
|
||||
final parsed = ERC681URI.fromUri(Uri.parse("ethereum:$recipient"));
|
||||
|
||||
expect(parsed.address, recipient);
|
||||
expect(parsed.contractAddress, null);
|
||||
expect(parsed.chainId, 1);
|
||||
expect(parsed.amount, "");
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import "package:cw_core/amount/money.dart";
|
||||
import "package:cw_core/spl_token.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
|
||||
void main() {
|
||||
group("SPL token raw amounts", () {
|
||||
test("6 decimal token renders raw base units", () {
|
||||
final jup = SPLToken(
|
||||
name: "Jupiter",
|
||||
symbol: "JUP",
|
||||
mint: "jup",
|
||||
mintAddress: "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN",
|
||||
decimal: 6,
|
||||
);
|
||||
|
||||
expect(Money(BigInt.from(100000), jup).toStringWithPrecision(), "0.1");
|
||||
expect(Money(BigInt.from(931614), jup).toStringWithPrecision(), "0.931614");
|
||||
});
|
||||
|
||||
test("8 decimal token renders raw base units", () {
|
||||
final xstock = SPLToken(
|
||||
name: "Amazon xStock",
|
||||
symbol: "AMZNX",
|
||||
mint: "amznx",
|
||||
mintAddress: "mintAddressWithEightDecimals",
|
||||
decimal: 8,
|
||||
);
|
||||
|
||||
expect(Money(BigInt.from(341694), xstock).toStringWithPrecision(), "0.00341694");
|
||||
});
|
||||
|
||||
test("the same raw amount means different values per decimals", () {
|
||||
final raw = BigInt.from(100000);
|
||||
|
||||
final six = SPLToken(
|
||||
name: "Six",
|
||||
symbol: "SIX",
|
||||
mint: "six",
|
||||
mintAddress: "mintAddressWithSixDecimals",
|
||||
decimal: 6,
|
||||
);
|
||||
|
||||
final nine = SPLToken(
|
||||
name: "Nine",
|
||||
symbol: "NINE",
|
||||
mint: "nine",
|
||||
mintAddress: "mintAddressWithNineDecimals",
|
||||
decimal: 9,
|
||||
);
|
||||
|
||||
final zero = SPLToken(
|
||||
name: "Zero",
|
||||
symbol: "ZERO",
|
||||
mint: "zero",
|
||||
mintAddress: "mintAddressWithZeroDecimals",
|
||||
decimal: 0,
|
||||
);
|
||||
|
||||
expect(Money(raw, six).toStringWithPrecision(), "0.1");
|
||||
expect(Money(raw, nine).toStringWithPrecision(), "0.0001");
|
||||
expect(Money(raw, zero).toStringWithPrecision(), "100000");
|
||||
});
|
||||
});
|
||||
|
||||
group("SPL token amount parsing", () {
|
||||
test("parses whole amounts for a zero decimal token", () {
|
||||
final zero = SPLToken(
|
||||
name: "Zero",
|
||||
symbol: "ZERO",
|
||||
mint: "zero",
|
||||
mintAddress: "mintAddressWithZeroDecimals",
|
||||
decimal: 0,
|
||||
);
|
||||
|
||||
expect(Money.parse("5", zero).amount, BigInt.from(5));
|
||||
expect(Money.parse("0", zero).amount, BigInt.zero);
|
||||
});
|
||||
|
||||
test("parses fractional amounts for a 6 decimal token", () {
|
||||
final jup = SPLToken(
|
||||
name: "Jupiter",
|
||||
symbol: "JUP",
|
||||
mint: "jup",
|
||||
mintAddress: "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN",
|
||||
decimal: 6,
|
||||
);
|
||||
|
||||
expect(Money.parse("0.1", jup).amount, BigInt.from(100000));
|
||||
expect(Money.parse("10.339089", jup).amount, BigInt.from(10339089));
|
||||
});
|
||||
|
||||
test("rejects more fractional digits than the token has decimals", () {
|
||||
final zero = SPLToken(
|
||||
name: "Zero",
|
||||
symbol: "ZERO",
|
||||
mint: "zero",
|
||||
mintAddress: "mintAddressWithZeroDecimals",
|
||||
decimal: 0,
|
||||
);
|
||||
|
||||
expect(() => Money.parse("5.5", zero), throwsFormatException);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user