| 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_router.dart"; |
| 4 | import "package:cake_wallet/reactions/wallet_connect.dart"; |
| 5 | import "package:cw_core/crypto_currency.dart"; |
| 6 | import "package:cw_core/erc20_token.dart"; |
| 7 | import "package:cw_core/wallet_info.dart"; |
| 8 | import "package:cw_core/wallet_type.dart"; |
| 9 | import "package:flutter_test/flutter_test.dart"; |
| 10 | |
| 11 | void main() { |
| 12 | const recipient = "0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41"; |
| 13 | const usdtEthContract = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; |
| 14 | const btcAddress = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq"; |
| 15 | const xmrAddress = |
| 16 | "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A"; |
| 17 | const tronAddress = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; |
| 18 | const solAddress = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; |
| 19 | |
| 20 | final usdt = Erc20Token( |
| 21 | name: "Tether", |
| 22 | symbol: "USDT", |
| 23 | contractAddress: usdtEthContract, |
| 24 | decimal: 6, |
| 25 | ); |
| 26 | |
| 27 | WalletInfo walletOf(WalletType type) => WalletInfo.external( |
| 28 | id: "id-${type.name}", |
| 29 | name: "wallet-${type.name}", |
| 30 | type: type, |
| 31 | isRecovery: false, |
| 32 | restoreHeight: 0, |
| 33 | date: DateTime(2026), |
| 34 | dirPath: "", |
| 35 | path: "", |
| 36 | address: "", |
| 37 | ); |
| 38 | |
| 39 | WalletSnapshot snapshot({ |
| 40 | WalletType type = WalletType.ethereum, |
| 41 | int? chainId, |
| 42 | List<WalletInfo> wallets = const [], |
| 43 | bool hasEvmProxy = true, |
| 44 | bool hasSolanaProxy = true, |
| 45 | bool hasTronProxy = true, |
| 46 | }) => |
| 47 | WalletSnapshot( |
| 48 | type: type, |
| 49 | currentWalletIsEvm: isEVMCompatibleChain(type), |
| 50 | currentChainId: chainId, |
| 51 | wallets: wallets, |
| 52 | supportedEvmChains: hasEvmProxy |
| 53 | ? const { |
| 54 | 1: WalletType.ethereum, |
| 55 | 137: WalletType.polygon, |
| 56 | 8453: WalletType.base, |
| 57 | } |
| 58 | : const {}, |
| 59 | hasEvmProxy: hasEvmProxy, |
| 60 | hasSolanaProxy: hasSolanaProxy, |
| 61 | hasTronProxy: hasTronProxy, |
| 62 | ); |
| 63 | |
| 64 | AnyPayRequest parse(String input) => AnyPayParser.fromRaw(input); |
| 65 | |
| 66 | group("evm routing", () { |
| 67 | test("explicit chain matching the current wallet applies directly", () { |
| 68 | final decision = AnyPayRouter.route( |
| 69 | parse("ethereum:$recipient@1?value=1e18"), |
| 70 | snapshot(chainId: 1), |
| 71 | const NoTokenRequested(), |
| 72 | ); |
| 73 | |
| 74 | expect(decision, isA<AnyPayApplyToCurrentWallet>()); |
| 75 | }); |
| 76 | |
| 77 | test("explicit chain differing from the current wallet goes cross chain", () { |
| 78 | final decision = AnyPayRouter.route( |
| 79 | parse("ethereum:$recipient@8453?value=1e18"), |
| 80 | snapshot(chainId: 1, wallets: [walletOf(WalletType.base)]), |
| 81 | const NoTokenRequested(), |
| 82 | ); |
| 83 | |
| 84 | expect(decision, isA<AnyPayCrossChainPayment>()); |
| 85 | final crossChain = decision as AnyPayCrossChainPayment; |
| 86 | expect(crossChain.targetChainId, 8453); |
| 87 | expect(crossChain.targetWalletType, WalletType.base); |
| 88 | expect(crossChain.hasCompatibleWallet, true); |
| 89 | }); |
| 90 | |
| 91 | test("cross chain with no wallet for the target offers no compatible wallet", () { |
| 92 | final decision = AnyPayRouter.route( |
| 93 | parse("ethereum:$recipient@8453?value=1e18"), |
| 94 | snapshot(chainId: 1), |
| 95 | const NoTokenRequested(), |
| 96 | ); |
| 97 | |
| 98 | expect((decision as AnyPayCrossChainPayment).hasCompatibleWallet, false); |
| 99 | }); |
| 100 | |
| 101 | test("unsupported explicit chain is rejected with its id", () { |
| 102 | final decision = AnyPayRouter.route( |
| 103 | parse("ethereum:$recipient@10?value=1e18"), |
| 104 | snapshot(chainId: 1), |
| 105 | const NoTokenRequested(), |
| 106 | ); |
| 107 | |
| 108 | expect((decision as AnyPayUnsupportedNetwork).chainId, 10); |
| 109 | }); |
| 110 | |
| 111 | test("chainless request binds to the current evm chain", () { |
| 112 | final decision = AnyPayRouter.route( |
| 113 | parse("ethereum:$recipient?value=1e18"), |
| 114 | snapshot(type: WalletType.base, chainId: 8453), |
| 115 | const NoTokenRequested(), |
| 116 | ); |
| 117 | |
| 118 | expect(decision, isA<AnyPayApplyToCurrentWallet>()); |
| 119 | }); |
| 120 | |
| 121 | test("chainless request at a non evm wallet targets mainnet cross chain", () { |
| 122 | final decision = AnyPayRouter.route( |
| 123 | parse("ethereum:$recipient?value=1e18"), |
| 124 | snapshot(type: WalletType.monero, wallets: [walletOf(WalletType.ethereum)]), |
| 125 | const NoTokenRequested(), |
| 126 | ); |
| 127 | |
| 128 | expect((decision as AnyPayCrossChainPayment).targetChainId, 1); |
| 129 | }); |
| 130 | |
| 131 | test("raw evm address at an evm wallet applies directly", () { |
| 132 | final decision = AnyPayRouter.route( |
| 133 | parse(recipient), |
| 134 | snapshot(chainId: 1), |
| 135 | const NoTokenRequested(), |
| 136 | ); |
| 137 | |
| 138 | expect(decision, isA<AnyPayApplyToCurrentWallet>()); |
| 139 | }); |
| 140 | |
| 141 | test("raw evm address at a non evm wallet asks for a network choice", () { |
| 142 | final decision = AnyPayRouter.route( |
| 143 | parse(recipient), |
| 144 | snapshot(type: WalletType.solana), |
| 145 | const NoTokenRequested(), |
| 146 | ); |
| 147 | |
| 148 | expect(decision, isA<AnyPayEvmNetworkChoice>()); |
| 149 | }); |
| 150 | |
| 151 | test("resolved token on the current chain applies with the token", () { |
| 152 | final decision = AnyPayRouter.route( |
| 153 | parse("ethereum:$usdtEthContract/transfer?address=$recipient&uint256=2000000"), |
| 154 | snapshot(type: WalletType.base, chainId: 8453), |
| 155 | TokenResolved(token: usdt, chainId: 8453, amountOverride: "2"), |
| 156 | ); |
| 157 | |
| 158 | final apply = decision as AnyPayApplyToCurrentWallet; |
| 159 | expect(apply.token, usdt); |
| 160 | expect(apply.amountOverride, "2"); |
| 161 | }); |
| 162 | |
| 163 | test("chainless token resolved on another chain reroutes there", () { |
| 164 | final decision = AnyPayRouter.route( |
| 165 | parse("ethereum:$usdtEthContract/transfer?address=$recipient&uint256=2000000"), |
| 166 | snapshot(type: WalletType.base, chainId: 8453, wallets: [walletOf(WalletType.ethereum)]), |
| 167 | TokenResolved(token: usdt, chainId: 1, amountOverride: "2"), |
| 168 | ); |
| 169 | |
| 170 | final crossChain = decision as AnyPayCrossChainPayment; |
| 171 | expect(crossChain.targetChainId, 1); |
| 172 | expect(crossChain.token, usdt); |
| 173 | }); |
| 174 | |
| 175 | test("an unknown token never falls back to the native asset", () { |
| 176 | final decision = AnyPayRouter.route( |
| 177 | parse("ethereum:$usdtEthContract@1/transfer?address=$recipient&uint256=2000000"), |
| 178 | snapshot(chainId: 1), |
| 179 | const TokenUnknown(usdtEthContract), |
| 180 | ); |
| 181 | |
| 182 | expect(decision, isA<AnyPayUnsupportedToken>()); |
| 183 | expect((decision as AnyPayUnsupportedToken).contract, usdtEthContract); |
| 184 | }); |
| 185 | |
| 186 | test("an unsupported network wins over an unknown token", () { |
| 187 | final decision = AnyPayRouter.route( |
| 188 | parse("ethereum:$usdtEthContract@10/transfer?address=$recipient&uint256=2000000"), |
| 189 | snapshot(chainId: 1), |
| 190 | const TokenUnknown(usdtEthContract), |
| 191 | ); |
| 192 | |
| 193 | expect((decision as AnyPayUnsupportedNetwork).chainId, 10); |
| 194 | }); |
| 195 | |
| 196 | test("evm address without the evm proxy applies as plain input", () { |
| 197 | final decision = AnyPayRouter.route( |
| 198 | parse(recipient), |
| 199 | snapshot(type: WalletType.monero, hasEvmProxy: false), |
| 200 | const NoTokenRequested(), |
| 201 | ); |
| 202 | |
| 203 | expect(decision, isA<AnyPayApplyToCurrentWallet>()); |
| 204 | expect((decision as AnyPayApplyToCurrentWallet).token, null); |
| 205 | }); |
| 206 | }); |
| 207 | |
| 208 | group("non evm routing", () { |
| 209 | test("address matching the current wallet applies directly", () { |
| 210 | final decision = AnyPayRouter.route( |
| 211 | parse(xmrAddress), |
| 212 | snapshot(type: WalletType.monero), |
| 213 | const NoTokenRequested(), |
| 214 | ); |
| 215 | |
| 216 | expect(decision, isA<AnyPayApplyToCurrentWallet>()); |
| 217 | }); |
| 218 | |
| 219 | test("btc address at another wallet goes cross chain with its wallets", () { |
| 220 | final decision = AnyPayRouter.route( |
| 221 | parse(btcAddress), |
| 222 | snapshot(type: WalletType.monero, wallets: [walletOf(WalletType.bitcoin)]), |
| 223 | const NoTokenRequested(), |
| 224 | ); |
| 225 | |
| 226 | final crossChain = decision as AnyPayCrossChainPayment; |
| 227 | expect(crossChain.targetWalletType, WalletType.bitcoin); |
| 228 | expect(crossChain.hasCompatibleWallet, true); |
| 229 | }); |
| 230 | |
| 231 | test("btc address with no bitcoin wallet leaves only the swap offer", () { |
| 232 | final decision = AnyPayRouter.route( |
| 233 | parse(btcAddress), |
| 234 | snapshot(type: WalletType.monero), |
| 235 | const NoTokenRequested(), |
| 236 | ); |
| 237 | |
| 238 | expect((decision as AnyPayCrossChainPayment).hasCompatibleWallet, false); |
| 239 | }); |
| 240 | |
| 241 | test("tron address at another wallet goes cross chain to tron", () { |
| 242 | final decision = AnyPayRouter.route( |
| 243 | parse(tronAddress), |
| 244 | snapshot(chainId: 1, wallets: [walletOf(WalletType.tron)]), |
| 245 | const NoTokenRequested(), |
| 246 | ); |
| 247 | |
| 248 | final crossChain = decision as AnyPayCrossChainPayment; |
| 249 | expect(crossChain.targetWalletType, WalletType.tron); |
| 250 | expect(crossChain.targetChainId, null); |
| 251 | expect(crossChain.wallets.length, 1); |
| 252 | }); |
| 253 | |
| 254 | test("solana address without the solana proxy applies as plain input", () { |
| 255 | final decision = AnyPayRouter.route( |
| 256 | parse(solAddress), |
| 257 | snapshot(chainId: 1, hasSolanaProxy: false), |
| 258 | const NoTokenRequested(), |
| 259 | ); |
| 260 | |
| 261 | expect(decision, isA<AnyPayApplyToCurrentWallet>()); |
| 262 | }); |
| 263 | |
| 264 | test("an unknown non evm token never falls back to the native asset", () { |
| 265 | final decision = AnyPayRouter.route( |
| 266 | parse("solana:$solAddress?amount=3&spl-token=$solAddress"), |
| 267 | snapshot(type: WalletType.solana), |
| 268 | const TokenUnknown(solAddress), |
| 269 | ); |
| 270 | |
| 271 | expect(decision, isA<AnyPayUnsupportedToken>()); |
| 272 | }); |
| 273 | |
| 274 | test("solana token resolved on the current wallet applies with the token", () { |
| 275 | const splToken = CryptoCurrency.usdtSol; |
| 276 | final decision = AnyPayRouter.route( |
| 277 | parse("solana:$solAddress?amount=3&spl-token=$solAddress"), |
| 278 | snapshot(type: WalletType.solana), |
| 279 | const TokenResolved(token: splToken, amountOverride: "3"), |
| 280 | ); |
| 281 | |
| 282 | final apply = decision as AnyPayApplyToCurrentWallet; |
| 283 | expect(apply.token, splToken); |
| 284 | expect(apply.amountOverride, "3"); |
| 285 | }); |
| 286 | }); |
| 287 | |
| 288 | group("native request currency", () { |
| 289 | test("an explicit native request with an amount carries its currency", () { |
| 290 | final decision = AnyPayRouter.route( |
| 291 | parse("ethereum:$recipient@1?value=2e18"), |
| 292 | snapshot(chainId: 1), |
| 293 | const NoTokenRequested(), |
| 294 | ); |
| 295 | |
| 296 | expect((decision as AnyPayApplyToCurrentWallet).fallbackCurrency, CryptoCurrency.eth); |
| 297 | }); |
| 298 | |
| 299 | test("a raw address carries no fallback currency", () { |
| 300 | final decision = AnyPayRouter.route( |
| 301 | parse(recipient), |
| 302 | snapshot(chainId: 1), |
| 303 | const NoTokenRequested(), |
| 304 | ); |
| 305 | |
| 306 | expect((decision as AnyPayApplyToCurrentWallet).fallbackCurrency, null); |
| 307 | }); |
| 308 | }); |
| 309 | |
| 310 | group("degenerate input", () { |
| 311 | test("empty input is invalid", () { |
| 312 | final decision = AnyPayRouter.route( |
| 313 | parse(""), |
| 314 | snapshot(chainId: 1), |
| 315 | const NoTokenRequested(), |
| 316 | ); |
| 317 | |
| 318 | expect(decision, isA<AnyPayEmptyInput>()); |
| 319 | }); |
| 320 | |
| 321 | test("undetectable text applies as plain input for the validator to reject", () { |
| 322 | final decision = AnyPayRouter.route( |
| 323 | parse("definitelynotanaddress"), |
| 324 | snapshot(chainId: 1), |
| 325 | const NoTokenRequested(), |
| 326 | ); |
| 327 | |
| 328 | expect(decision, isA<AnyPayApplyToCurrentWallet>()); |
| 329 | }); |
| 330 | }); |
| 331 | |
| 332 | group("requested evm chain", () { |
| 333 | test("explicit binding wins over the wallet chain", () { |
| 334 | final request = parse("ethereum:$recipient@137?value=1e18"); |
| 335 | |
| 336 | expect(AnyPayRouter.requestedEvmChainId(request, snapshot(chainId: 1)), 137); |
| 337 | }); |
| 338 | |
| 339 | test("chainless binding takes the wallet chain", () { |
| 340 | final request = parse("ethereum:$recipient?value=1e18"); |
| 341 | |
| 342 | expect( |
| 343 | AnyPayRouter.requestedEvmChainId( |
| 344 | request, |
| 345 | snapshot(type: WalletType.base, chainId: 8453), |
| 346 | ), |
| 347 | 8453, |
| 348 | ); |
| 349 | }); |
| 350 | |
| 351 | test("chainless binding defaults to mainnet without an evm wallet", () { |
| 352 | final request = parse("ethereum:$recipient?value=1e18"); |
| 353 | |
| 354 | expect( |
| 355 | AnyPayRouter.requestedEvmChainId(request, snapshot(type: WalletType.monero)), |
| 356 | 1, |
| 357 | ); |
| 358 | }); |
| 359 | |
| 360 | test("raw addresses bind no target", () { |
| 361 | expect(AnyPayRouter.requestedEvmChainId(parse(recipient), snapshot(chainId: 1)), null); |
| 362 | }); |
| 363 | }); |
| 364 | |
| 365 | group("swap incompatible recipients", () { |
| 366 | final spAddress = "sp1${"q" * 113}"; |
| 367 | final mwebAddress = "ltcmweb1q${"a" * 90}"; |
| 368 | |
| 369 | test("a silent payment address with a bitcoin wallet switches without a swap offer", () { |
| 370 | final decision = AnyPayRouter.route( |
| 371 | parse(spAddress), |
| 372 | snapshot(type: WalletType.monero, wallets: [walletOf(WalletType.bitcoin)]), |
| 373 | const NoTokenRequested(), |
| 374 | ); |
| 375 | |
| 376 | final crossChain = decision as AnyPayCrossChainPayment; |
| 377 | expect(crossChain.targetWalletType, WalletType.bitcoin); |
| 378 | expect(crossChain.hasCompatibleWallet, true); |
| 379 | expect(crossChain.canSwap, false); |
| 380 | }); |
| 381 | |
| 382 | test("a silent payment address with no bitcoin wallet cannot swap either", () { |
| 383 | final decision = AnyPayRouter.route( |
| 384 | parse(spAddress), |
| 385 | snapshot(type: WalletType.monero), |
| 386 | const NoTokenRequested(), |
| 387 | ); |
| 388 | |
| 389 | final crossChain = decision as AnyPayCrossChainPayment; |
| 390 | expect(crossChain.hasCompatibleWallet, false); |
| 391 | expect(crossChain.canSwap, false); |
| 392 | }); |
| 393 | |
| 394 | test("an mweb address never offers a swap", () { |
| 395 | final decision = AnyPayRouter.route( |
| 396 | parse(mwebAddress), |
| 397 | snapshot(), |
| 398 | const NoTokenRequested(), |
| 399 | ); |
| 400 | |
| 401 | expect((decision as AnyPayCrossChainPayment).canSwap, false); |
| 402 | }); |
| 403 | |
| 404 | test("a plain btc address keeps the swap offer", () { |
| 405 | final decision = AnyPayRouter.route( |
| 406 | parse(btcAddress), |
| 407 | snapshot(type: WalletType.monero), |
| 408 | const NoTokenRequested(), |
| 409 | ); |
| 410 | |
| 411 | expect((decision as AnyPayCrossChainPayment).canSwap, true); |
| 412 | }); |
| 413 | }); |
| 414 | } |