dev
dart 414 lines 13.6 KB
Raw
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 }