mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-26 06:08:40 +01:00
CW1612: AnyPay Service Layer (#3516)
* fix android CI * feat: add anypay core models, parser and routing engine * feat: add anypay resolver, anypay service and wallet switch service * refactor: route the send page payment flow through anypay service * fix: show message when there's no swap providers for a pair and reset the trade guard on dismissal * refactor: merge the swap from network and send to network decision pages together and clean up anypay widgets * feat: reapply deep links to a currently open send flow and register the solana scheme * feat: add support for zcash address detection to anypay and other minor fixes * refactor: remove trailing icon * feat: hide swap flow for sp and mweb addresses * feat: enhance zcash address detection and deeplink handling * fix: pin bitcoin_base * refactor: Move the anypay decision navigation out of the send page and address minor review comments * refactor: Move the anypay decision navigation out of the send page and address minor review comments * feat: handle exception in switch service and mvoe balance compute to call site * refactor: combine anypay entry points to one and extract selected mode widget --------- Co-authored-by: Czarek Nakamoto <cyjan@mrcyjanek.net>
This commit is contained in:
co-authored by
Czarek Nakamoto
parent
4cc1495686
commit
3b100eae33
@@ -0,0 +1,103 @@
|
||||
import "package:cake_wallet/core/anypay/anypay_models.dart";
|
||||
import "package:cake_wallet/core/anypay/anypay_parser.dart";
|
||||
import "package:cake_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,201 @@
|
||||
import "package:cake_wallet/core/anypay/anypay_models.dart";
|
||||
import "package:cake_wallet/core/anypay/anypay_parser.dart";
|
||||
import "package:cake_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("a solana token resolves against the detected wallet type", () async {
|
||||
const usdcSol = CryptoCurrency.usdtSol;
|
||||
final resolver = AnyPayResolver(
|
||||
tokenLookup: _FakeTokenLookup(
|
||||
onFindToken: ({required walletType, required address}) async {
|
||||
expect(walletType, WalletType.solana);
|
||||
expect(address, usdcSolMint);
|
||||
return usdcSol;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final resolution = await resolver.resolve(
|
||||
parse("solana:$solAddress?amount=3&spl-token=$usdcSolMint"),
|
||||
snapshot(type: WalletType.monero),
|
||||
);
|
||||
|
||||
final resolved = resolution as TokenResolved;
|
||||
expect(resolved.chainId, null);
|
||||
expect(resolved.token, usdcSol);
|
||||
expect(resolved.amountOverride, "3");
|
||||
});
|
||||
|
||||
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,414 @@
|
||||
import "package:cake_wallet/core/anypay/anypay_models.dart";
|
||||
import "package:cake_wallet/core/anypay/anypay_parser.dart";
|
||||
import "package:cake_wallet/core/anypay/anypay_router.dart";
|
||||
import "package:cake_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("raw evm address at a non evm wallet asks for a network choice", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse(recipient),
|
||||
snapshot(type: WalletType.solana),
|
||||
const NoTokenRequested(),
|
||||
);
|
||||
|
||||
expect(decision, isA<AnyPayEvmNetworkChoice>());
|
||||
});
|
||||
|
||||
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("tron address at another wallet goes cross chain to tron", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse(tronAddress),
|
||||
snapshot(chainId: 1, wallets: [walletOf(WalletType.tron)]),
|
||||
const NoTokenRequested(),
|
||||
);
|
||||
|
||||
final crossChain = decision as AnyPayCrossChainPayment;
|
||||
expect(crossChain.targetWalletType, WalletType.tron);
|
||||
expect(crossChain.targetChainId, null);
|
||||
expect(crossChain.wallets.length, 1);
|
||||
});
|
||||
|
||||
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>());
|
||||
});
|
||||
|
||||
test("an unknown non evm token never falls back to the native asset", () {
|
||||
final decision = AnyPayRouter.route(
|
||||
parse("solana:$solAddress?amount=3&spl-token=$solAddress"),
|
||||
snapshot(type: WalletType.solana),
|
||||
const TokenUnknown(solAddress),
|
||||
);
|
||||
|
||||
expect(decision, isA<AnyPayUnsupportedToken>());
|
||||
});
|
||||
|
||||
test("solana token resolved on the current wallet applies with the token", () {
|
||||
const splToken = CryptoCurrency.usdtSol;
|
||||
final decision = AnyPayRouter.route(
|
||||
parse("solana:$solAddress?amount=3&spl-token=$solAddress"),
|
||||
snapshot(type: WalletType.solana),
|
||||
const TokenResolved(token: splToken, amountOverride: "3"),
|
||||
);
|
||||
|
||||
final apply = decision as AnyPayApplyToCurrentWallet;
|
||||
expect(apply.token, splToken);
|
||||
expect(apply.amountOverride, "3");
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -196,6 +196,38 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group("Zcash Address Detection", () {
|
||||
test("detects Zcash transparent address", () {
|
||||
const address = "t1V9mnyk5Z5cTNMCkLbaDwSskgJZucTLdgW";
|
||||
final result = UniversalAddressDetector.detectAddress(address);
|
||||
|
||||
expect(result.isValid, true);
|
||||
expect(result.detectedCurrency, CryptoCurrency.zec);
|
||||
expect(result.detectedWalletType, WalletType.zcash);
|
||||
expect(result.address, address);
|
||||
});
|
||||
|
||||
test("detects Zcash shielded address", () {
|
||||
const address =
|
||||
"zsab12cd34efab12cd34efab12cd34efab12cd34efab12cd34efab12cd34efab12cd34efgh56kl";
|
||||
final result = UniversalAddressDetector.detectAddress(address);
|
||||
|
||||
expect(result.isValid, true);
|
||||
expect(result.detectedCurrency, CryptoCurrency.zec);
|
||||
expect(result.detectedWalletType, WalletType.zcash);
|
||||
});
|
||||
|
||||
test("detects Zcash unified address", () {
|
||||
const address =
|
||||
"u1z9y8x7w6v5z9y8x7w6v5z9y8x7w6v5z9y8x7w6v5z9y8x7w6v5z9y8x7w6v5z9y8x7w6v5z9y8x7w6v5z9y8x7w6v5z9y8x7w6v5";
|
||||
final result = UniversalAddressDetector.detectAddress(address);
|
||||
|
||||
expect(result.isValid, true);
|
||||
expect(result.detectedCurrency, CryptoCurrency.zec);
|
||||
expect(result.detectedWalletType, WalletType.zcash);
|
||||
});
|
||||
});
|
||||
|
||||
group('Wownero Address Detection', () {
|
||||
test('detects Wownero address', () {
|
||||
const address =
|
||||
|
||||
Reference in New Issue
Block a user