| 1 | import "package:cake_wallet/core/anypay/anypay_models.dart"; |
| 2 | import "package:cake_wallet/core/anypay/anypay_parser.dart"; |
| 3 | import "package:cake_wallet/core/anypay/anypay_resolver.dart"; |
| 4 | import "package:cw_core/crypto_currency.dart"; |
| 5 | import "package:cw_core/erc20_token.dart"; |
| 6 | import "package:cw_core/wallet_type.dart"; |
| 7 | import "package:flutter_test/flutter_test.dart"; |
| 8 | |
| 9 | class _FakeTokenLookup implements AnyPayTokenLookup { |
| 10 | _FakeTokenLookup({this.onFindToken, this.onFindChainId}); |
| 11 | |
| 12 | final Future<CryptoCurrency?> Function({required WalletType walletType, required String address})? |
| 13 | onFindToken; |
| 14 | final Future<int?> Function(String contractAddress, {int? excludingChainId})? onFindChainId; |
| 15 | |
| 16 | @override |
| 17 | Future<CryptoCurrency?> findTokenByAddress({ |
| 18 | required WalletType walletType, |
| 19 | required String address, |
| 20 | }) { |
| 21 | if (onFindToken == null) { |
| 22 | fail("no token lookup expected"); |
| 23 | } |
| 24 | return onFindToken!(walletType: walletType, address: address); |
| 25 | } |
| 26 | |
| 27 | @override |
| 28 | Future<int?> findEvmChainIdForContract(String contractAddress, {int? excludingChainId}) { |
| 29 | if (onFindChainId == null) { |
| 30 | fail("no chain sweep expected"); |
| 31 | } |
| 32 | return onFindChainId!(contractAddress, excludingChainId: excludingChainId); |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | void main() { |
| 37 | const recipient = "0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41"; |
| 38 | const usdtEthContract = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; |
| 39 | const solAddress = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; |
| 40 | const usdcSolMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; |
| 41 | |
| 42 | final usdt = Erc20Token( |
| 43 | name: "Tether", |
| 44 | symbol: "USDT", |
| 45 | contractAddress: usdtEthContract, |
| 46 | decimal: 6, |
| 47 | ); |
| 48 | |
| 49 | WalletSnapshot snapshot({ |
| 50 | WalletType type = WalletType.ethereum, |
| 51 | int? chainId, |
| 52 | bool hasEvmProxy = true, |
| 53 | }) => |
| 54 | WalletSnapshot( |
| 55 | type: type, |
| 56 | currentWalletIsEvm: |
| 57 | type == WalletType.ethereum || type == WalletType.polygon || type == WalletType.base, |
| 58 | currentChainId: chainId, |
| 59 | wallets: const [], |
| 60 | // The service builds an empty chain map when the evm proxy is absent. |
| 61 | supportedEvmChains: hasEvmProxy |
| 62 | ? const { |
| 63 | 1: WalletType.ethereum, |
| 64 | 137: WalletType.polygon, |
| 65 | 8453: WalletType.base, |
| 66 | } |
| 67 | : const {}, |
| 68 | hasEvmProxy: hasEvmProxy, |
| 69 | hasSolanaProxy: true, |
| 70 | hasTronProxy: true, |
| 71 | ); |
| 72 | |
| 73 | AnyPayRequest parse(String input) => AnyPayParser.fromRaw(input); |
| 74 | |
| 75 | test("a request without a contract needs no resolution", () async { |
| 76 | final resolver = AnyPayResolver(tokenLookup: _FakeTokenLookup()); |
| 77 | |
| 78 | final resolution = await resolver.resolve( |
| 79 | parse("ethereum:$recipient@1?value=1e18"), |
| 80 | snapshot(chainId: 1), |
| 81 | ); |
| 82 | |
| 83 | expect(resolution, isA<NoTokenRequested>()); |
| 84 | }); |
| 85 | |
| 86 | test("a token on the explicit target chain resolves with its amount", () async { |
| 87 | final lookedUp = <WalletType>[]; |
| 88 | final resolver = AnyPayResolver( |
| 89 | tokenLookup: _FakeTokenLookup( |
| 90 | onFindToken: ({required walletType, required address}) async { |
| 91 | lookedUp.add(walletType); |
| 92 | return walletType == WalletType.ethereum ? usdt : null; |
| 93 | }, |
| 94 | ), |
| 95 | ); |
| 96 | |
| 97 | final resolution = await resolver.resolve( |
| 98 | parse("ethereum:$usdtEthContract@1/transfer?address=$recipient&uint256=1000000"), |
| 99 | snapshot(type: WalletType.base, chainId: 8453), |
| 100 | ); |
| 101 | |
| 102 | final resolved = resolution as TokenResolved; |
| 103 | expect(lookedUp, [WalletType.ethereum]); |
| 104 | expect(resolved.chainId, 1); |
| 105 | expect(resolved.token, usdt); |
| 106 | expect(resolved.amountOverride, "1"); |
| 107 | }); |
| 108 | |
| 109 | test("an unknown token on an explicit chain is not swept to other chains", () async { |
| 110 | bool sweepCalled = false; |
| 111 | final resolver = AnyPayResolver( |
| 112 | tokenLookup: _FakeTokenLookup( |
| 113 | onFindToken: ({required walletType, required address}) async => null, |
| 114 | onFindChainId: (contract, {excludingChainId}) async { |
| 115 | sweepCalled = true; |
| 116 | return 137; |
| 117 | }, |
| 118 | ), |
| 119 | ); |
| 120 | |
| 121 | final resolution = await resolver.resolve( |
| 122 | parse("ethereum:$usdtEthContract@1/transfer?address=$recipient&uint256=1000000"), |
| 123 | snapshot(chainId: 1), |
| 124 | ); |
| 125 | |
| 126 | expect(resolution, isA<TokenUnknown>()); |
| 127 | expect(sweepCalled, false); |
| 128 | }); |
| 129 | |
| 130 | test("a chainless token unknown on the current chain reroutes through the sweep", () async { |
| 131 | final resolver = AnyPayResolver( |
| 132 | tokenLookup: _FakeTokenLookup( |
| 133 | onFindToken: ({required walletType, required address}) async => |
| 134 | walletType == WalletType.ethereum ? usdt : null, |
| 135 | onFindChainId: (contract, {excludingChainId}) async { |
| 136 | expect(excludingChainId, 8453); |
| 137 | return 1; |
| 138 | }, |
| 139 | ), |
| 140 | ); |
| 141 | |
| 142 | final resolution = await resolver.resolve( |
| 143 | parse("ethereum:$usdtEthContract/transfer?address=$recipient&uint256=2000000"), |
| 144 | snapshot(type: WalletType.base, chainId: 8453), |
| 145 | ); |
| 146 | |
| 147 | final resolved = resolution as TokenResolved; |
| 148 | expect(resolved.chainId, 1); |
| 149 | expect(resolved.amountOverride, "2"); |
| 150 | }); |
| 151 | |
| 152 | test("a chainless token unknown everywhere stays unknown", () async { |
| 153 | final resolver = AnyPayResolver( |
| 154 | tokenLookup: _FakeTokenLookup( |
| 155 | onFindToken: ({required walletType, required address}) async => null, |
| 156 | onFindChainId: (contract, {excludingChainId}) async => null, |
| 157 | ), |
| 158 | ); |
| 159 | |
| 160 | final resolution = await resolver.resolve( |
| 161 | parse("ethereum:$usdtEthContract/transfer?address=$recipient&uint256=2000000"), |
| 162 | snapshot(chainId: 1), |
| 163 | ); |
| 164 | |
| 165 | expect((resolution as TokenUnknown).contract, usdtEthContract); |
| 166 | }); |
| 167 | |
| 168 | test("a solana token resolves against the detected wallet type", () async { |
| 169 | const usdcSol = CryptoCurrency.usdtSol; |
| 170 | final resolver = AnyPayResolver( |
| 171 | tokenLookup: _FakeTokenLookup( |
| 172 | onFindToken: ({required walletType, required address}) async { |
| 173 | expect(walletType, WalletType.solana); |
| 174 | expect(address, usdcSolMint); |
| 175 | return usdcSol; |
| 176 | }, |
| 177 | ), |
| 178 | ); |
| 179 | |
| 180 | final resolution = await resolver.resolve( |
| 181 | parse("solana:$solAddress?amount=3&spl-token=$usdcSolMint"), |
| 182 | snapshot(type: WalletType.monero), |
| 183 | ); |
| 184 | |
| 185 | final resolved = resolution as TokenResolved; |
| 186 | expect(resolved.chainId, null); |
| 187 | expect(resolved.token, usdcSol); |
| 188 | expect(resolved.amountOverride, "3"); |
| 189 | }); |
| 190 | |
| 191 | test("an evm contract without the evm proxy stays unknown without any lookups", () async { |
| 192 | final resolver = AnyPayResolver(tokenLookup: _FakeTokenLookup()); |
| 193 | |
| 194 | final resolution = await resolver.resolve( |
| 195 | parse("ethereum:$usdtEthContract@1/transfer?address=$recipient&uint256=1000000"), |
| 196 | snapshot(hasEvmProxy: false), |
| 197 | ); |
| 198 | |
| 199 | expect(resolution, isA<TokenUnknown>()); |
| 200 | }); |
| 201 | } |