fix: anypay (#3507)

* fix android CI * fix: handle the parser seeing chainless QRs in the old flow as belonging to the current wallet, causing the error jack reported * fix: handle network switch when anypay is interrupted mid flow and currency is changed

David Adegoke committed Aug 12, 2026 at 00:14 UTC 414b2eadee2666e485daf90989f13d60bceb3d79
7 files changed +490 -52
cw_core/lib/payment_uris.dart
+12 -10
@@ -283,8 +283,8 @@ class ERC681URI extends PaymentURI {
283 } else {
284 final valueParam = uri.queryParameters["value"];
285 if (valueParam != null) {
286 - final normalized = _normalizeToIntegerWei(valueParam);
287 - formatedAmount = formatFixed(BigInt.parse(normalized), 18);
286 + final normalized = BigInt.tryParse(_normalizeToIntegerWei(valueParam));
287 + formatedAmount = normalized != null ? formatFixed(normalized, 18) : "";
288 } else {
289 formatedAmount = uri.queryParameters["amount"] ?? "";
290 }
@@ -313,9 +313,7 @@ class ERC681URI extends PaymentURI {
313 final targetAddress = contractAddress ?? address;
314 uri += targetAddress;
315
316 - if (chainId != 1) {
317 - uri += "@$chainId";
318 - }
316 + uri += "@$chainId";
317
318 if (contractAddress != null) {
319 uri += "/transfer";
@@ -364,14 +362,18 @@ class ERC681URI extends PaymentURI {
362 }
363 }
364
367 - static int _getChainID(String path) => int.parse(
365 + static int _getChainID(String path) =>
366 + int.tryParse(
367 RegExp(r"@\d*").firstMatch(path)?.group(0)?.replaceAll("@", "") ?? "1",
369 - );
368 + ) ??
369 + 1;
370
371 static (bool, String) _getTargetAddress(String path) {
372 - final targetAddress =
373 - RegExp(r"^(0x)?[0-9a-f]{40}", caseSensitive: false).firstMatch(path)!.group(0)!;
374 - return (path.contains("/"), targetAddress);
372 + // I saw in the schema (thanks Konsti) that EIP-681 allows an optional "pay-" prefix before the target address, so adding a check for it here
373 + final cleaned = path.startsWith("pay-") ? path.substring(4) : path;
374 + final match = RegExp(r"^(0x)?[0-9a-f]{40}", caseSensitive: false).firstMatch(cleaned);
375 + final targetAddress = match?.group(0) ?? cleaned.split("@").first.split("/").first;
376 + return (cleaned.contains("/"), targetAddress);
377 }
378
379 /// Normalize an input amount into an integer wei string.
cw_core/test/payment_uris_test.dart
+20 -5
@@ -78,13 +78,13 @@ void main() {
78 const recipient = "0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6";
79 const contract = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
80
81 - test("builds a plain native transfer on mainnet", () {
81 + test("includes the mainnet chainId on a plain native transfer", () {
82 final uri = ERC681URI(chainId: 1, address: recipient, amount: "", contractAddress: null);
83
84 - expect(uri.toString(), "ethereum:$recipient");
84 + expect(uri.toString(), "ethereum:$recipient@1");
85 });
86
87 - test("appends the chainId for non-mainnet native transfers", () {
87 + test("appends the chainId for native transfers", () {
88 final uri = ERC681URI(chainId: 137, address: recipient, amount: "", contractAddress: null);
89
90 expect(uri.toString(), "ethereum:$recipient@137");
@@ -93,7 +93,22 @@ void main() {
93 test("emits the native amount in ERC-681 scientific notation", () {
94 final uri = ERC681URI(chainId: 1, address: recipient, amount: "1", contractAddress: null);
95
96 - expect(uri.toString(), "ethereum:$recipient?value=1.0e18");
96 + expect(uri.toString(), "ethereum:$recipient@1?value=1.0e18");
97 + });
98 +
99 + test("includes the mainnet chainId in the transfer form", () {
100 + final uri = ERC681URI(
101 + chainId: 1,
102 + address: recipient,
103 + amount: "200.172148",
104 + contractAddress: contract,
105 + tokenDecimals: 6,
106 + );
107 +
108 + expect(
109 + uri.toString(),
110 + "ethereum:$contract@1/transfer?address=$recipient&amount=200.172148",
111 + );
112 });
113
114 test("builds the transfer form for a token", () {
@@ -189,7 +204,7 @@ void main() {
204 rawTokenAmount: "1000000",
205 );
206
192 - expect(uri.toString(), "ethereum:$contract/transfer?address=$recipient&uint256=1000000");
207 + expect(uri.toString(), "ethereum:$contract@1/transfer?address=$recipient&uint256=1000000");
208 });
209
210 test("parses a scientific notation value", () {
lib/new-ui/pages/send_page.dart
+59 -25
@@ -64,6 +64,7 @@ import "package:cw_core/amount/amount_sanitizer.dart";
64 import "package:cw_core/amount/money.dart";
65 import "package:cw_core/crypto_currency.dart";
66 import "package:cw_core/currency_for_wallet_type.dart";
67 +import "package:cw_core/erc20_token.dart";
68 import "package:cw_core/lnurl.dart";
69 import "package:cw_core/payment_uris.dart";
70 import "package:cw_core/transaction_priority.dart";
@@ -247,35 +248,17 @@ class _NewSendPageState extends State<NewSendPage> {
248
249 if (widget.initialPaymentRequest != null) {
250 if (_isInitialRequestTypeSameAsCurrentWallet()) {
250 - _addressControllers[0].text = widget.initialPaymentRequest!.address;
251 - // _memoControllers[0].text = widget.initialPaymentRequest!.note;
252 - _applyNote(widget.initialPaymentRequest!.note, 0);
251 final contractAddress = widget.initialPaymentRequest!.contractAddress;
252 if (contractAddress != null && contractAddress.isNotEmpty) {
253 WidgetsBinding.instance.addPostFrameCallback((_) async {
254 if (!mounted) {
255 return;
256 }
259 - final token = await TokenUtilities.findTokenByAddress(
260 - walletType: widget.sendViewModel.wallet.type,
261 - address: contractAddress,
262 - );
263 - if (!mounted) {
264 - return;
265 - }
266 -
267 - if (token == null) {
268 - _showUnsupportedTokenAlert();
269 - return;
270 - }
271 - await widget.sendViewModel.fetchTokenForContractAddress(contractAddress);
272 - if (!mounted) {
273 - return;
274 - }
275 - _amountControllers[0].text =
276 - widget.initialPaymentRequest!.resolveTokenAmount(token) ?? "";
257 + await _applyPaymentSelectingCurrency(widget.initialPaymentRequest!, null);
258 });
259 } else {
260 + _addressControllers[0].text = widget.initialPaymentRequest!.address;
261 + _applyNote(widget.initialPaymentRequest!.note, 0);
262 _amountControllers[0].text = widget.initialPaymentRequest!.amount;
263 }
264 } else {
@@ -1049,8 +1032,9 @@ class _NewSendPageState extends State<NewSendPage> {
1032
1033 Future<void> _handleManualNetworkSelection(ChainInfo target) async {
1034 final address = _addressControllers[_selectedOutput].text.trim();
1052 - final amount = _amountControllers[_selectedOutput].text;
1035 final note = _memoControllers[_selectedOutput].text;
1036 + final isTokenSelected = widget.sendViewModel.selectedCryptoCurrency is Erc20Token;
1037 + final amount = isTokenSelected ? "" : _amountControllers[_selectedOutput].text;
1038 final paymentRequest = PaymentRequest(address, amount, note, "", null);
1039 await _handleEvmNetworkFlow(target, paymentRequest);
1040 }
@@ -1153,7 +1137,7 @@ class _NewSendPageState extends State<NewSendPage> {
1137 if (isCrossChain) {
1138 await _handleEvmNetworkFlow(targetChain, paymentRequest);
1139 } else if (widget.sendViewModel.isEVMWallet) {
1156 - _applyPaymentRequest(paymentRequest);
1140 + await _applyPaymentSelectingCurrency(paymentRequest, null);
1141 } else {
1142 await _showEvmNetworkPicker(paymentRequest, result.walletType);
1143 }
@@ -1328,8 +1312,12 @@ class _NewSendPageState extends State<NewSendPage> {
1312 String? amountOverride;
1313 final contract = paymentRequest.contractAddress;
1314 if (contract != null && contract.isNotEmpty) {
1315 + final walletType = widget.sendViewModel.wallet.type;
1316 + final lookupType = evm != null && isEVMCompatibleChain(walletType)
1317 + ? (evm!.getWalletTypeByChainId(_currentEvmChainIdOrMainnet()) ?? walletType)
1318 + : walletType;
1319 final token = await TokenUtilities.findTokenByAddress(
1332 - walletType: widget.sendViewModel.wallet.type,
1320 + walletType: lookupType,
1321 address: contract,
1322 );
1323 if (!mounted) {
@@ -1337,13 +1325,21 @@ class _NewSendPageState extends State<NewSendPage> {
1325 }
1326
1327 if (token == null) {
1328 + final rerouted = await _rerouteChainlessContractPayment(paymentRequest);
1329 + if (rerouted || !mounted) {
1330 + return;
1331 + }
1332 _showUnsupportedTokenAlert();
1333 return;
1334 }
1343 - await widget.sendViewModel.fetchTokenForContractAddress(contract);
1335 + await widget.sendViewModel.fetchTokenForContractAddress(contract, walletType: lookupType);
1336 amountOverride = paymentRequest.resolveTokenAmount(token);
1337 } else if (fallbackCurrency != null) {
1338 widget.sendViewModel.setSelectedCryptoCurrency(fallbackCurrency.title);
1339 + if (paymentRequest.amount.isEmpty) {
1340 + widget.sendViewModel.outputs[_selectedOutput].setCryptoAmount("");
1341 + _amountControllers[_selectedOutput].clear();
1342 + }
1343 }
1344 if (!mounted) {
1345 return;
@@ -1351,6 +1347,44 @@ class _NewSendPageState extends State<NewSendPage> {
1347 _applyPaymentRequest(paymentRequest, amountOverride: amountOverride);
1348 }
1349
1350 + Future<bool> _rerouteChainlessContractPayment(PaymentRequest paymentRequest) async {
1351 + if (evm == null || paymentRequest.chainId != null) {
1352 + return false;
1353 + }
1354 +
1355 + if (paymentRequest.scheme.toLowerCase() != "ethereum") {
1356 + return false;
1357 + }
1358 +
1359 + final contract = paymentRequest.contractAddress;
1360 + if (contract == null || contract.isEmpty) {
1361 + return false;
1362 + }
1363 +
1364 + final currentChainId = isEVMCompatibleChain(widget.sendViewModel.wallet.type)
1365 + ? _currentEvmChainIdOrMainnet()
1366 + : null;
1367 +
1368 + // QRs from old app versions omit the chainId on mainnet, so a contract the current
1369 + // network does not know may still belong to another EVM network
1370 + final chainId = await TokenUtilities.findEvmChainIdForContract(
1371 + contract,
1372 + excludingChainId: currentChainId,
1373 + );
1374 + if (chainId == null || !mounted) {
1375 + return false;
1376 + }
1377 +
1378 + final targetChain = evm!.getChainInfoByChainId(chainId);
1379 + if (targetChain == null) {
1380 + return false;
1381 + }
1382 +
1383 + printV("chainless contract payment rerouted to chainId $chainId");
1384 + await _handleEvmNetworkFlow(targetChain, paymentRequest);
1385 + return true;
1386 + }
1387 +
1388 Future<void> _completeWalletSwitch(
1389 WalletInfo wallet,
1390 PaymentFlowResult result,
lib/utils/token_utilities.dart
+32
@@ -8,6 +8,7 @@ import 'package:cw_core/currency_for_wallet_type.dart';
8 import 'package:cw_core/erc20_token.dart';
9 import 'package:cw_core/spl_token.dart';
10 import 'package:cw_core/tron_token.dart';
11 +import "package:cw_core/utils/print_verbose.dart";
12 import 'package:cw_core/wallet_base.dart';
13 import 'package:cw_core/wallet_info.dart';
14 import 'package:cw_core/wallet_type.dart';
@@ -163,6 +164,37 @@ class TokenUtilities {
164 return null;
165 }
166
167 + static Future<int?> findEvmChainIdForContract(
168 + String contractAddress, {
169 + int? excludingChainId,
170 + }) async {
171 + if (evm == null || contractAddress.isEmpty) {
172 + return null;
173 + }
174 +
175 + try {
176 + for (final chain in evm!.getAllChains()) {
177 + if (chain.chainId == excludingChainId) {
178 + continue;
179 + }
180 +
181 + final walletType = evm!.getWalletTypeByChainId(chain.chainId);
182 + if (walletType == null) {
183 + continue;
184 + }
185 +
186 + final token = await findTokenByAddress(walletType: walletType, address: contractAddress);
187 + if (token != null) {
188 + return chain.chainId;
189 + }
190 + }
191 + } catch (e) {
192 + printV("findEvmChainIdForContract failed: $e");
193 + }
194 +
195 + return null;
196 + }
197 +
198 static Future<Box<Erc20Token>> _openEvmTokensBoxFor(WalletInfo walletInfo) async {
199 final walletKey = walletInfo.name.replaceAll(' ', '_');
200 final boxName = _getErc20TokensBoxName(walletKey, walletInfo.type);
lib/view_model/send/send_view_model.dart
+5 -5
@@ -126,9 +126,8 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
126 super(appStore: _appStore) {
127 outputs.add(Output(wallet, _appStore, _fiatConversationStore, _outputCryptoCurrencyHandler));
128
129 - unspentCoinsListViewModel
130 - .initialSetup();
131 - // .then((_) => unspentCoinsListViewModel.resetUnspentCoinsInfoSelections());
129 + unspentCoinsListViewModel.initialSetup();
130 + // .then((_) => unspentCoinsListViewModel.resetUnspentCoinsInfoSelections());
131
132 reaction((_) {
133 if (isEVMCompatibleChain(wallet.type)) {
@@ -1719,9 +1718,10 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
1718 String? payjoinUri;
1719
1720 @action
1722 - Future<void> fetchTokenForContractAddress(String contractAddress) async {
1721 + Future<void> fetchTokenForContractAddress(String contractAddress,
1722 + {WalletType? walletType}) async {
1723 final token = await TokenUtilities.findTokenByAddress(
1724 - walletType: wallet.type,
1724 + walletType: walletType ?? wallet.type,
1725 address: contractAddress,
1726 );
1727
test/utils/anypay_matrix_test.dart new
+344
@@ -0,0 +1,344 @@
1 +import "package:cake_wallet/core/universal_address_detector.dart";
2 +import "package:cake_wallet/utils/payment_request.dart";
3 +import "package:cw_core/erc20_token.dart";
4 +import "package:cw_core/payment_uris.dart";
5 +import "package:cw_core/wallet_type.dart";
6 +import "package:flutter_test/flutter_test.dart";
7 +
8 +void main() {
9 + const recipient = "0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41";
10 + const usdtEthContract = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
11 + const daiEthContract = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
12 + const usdtBaseContract = "0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2";
13 +
14 + final usdt = Erc20Token(
15 + name: "Tether",
16 + symbol: "USDT",
17 + contractAddress: usdtEthContract,
18 + decimal: 6,
19 + );
20 + final dai = Erc20Token(
21 + name: "Dai",
22 + symbol: "DAI",
23 + contractAddress: daiEthContract,
24 + decimal: 18,
25 + );
26 +
27 + group("cross generation QR matrix", () {
28 + test("new app mainnet USDT QR parses with explicit chain and amount", () {
29 + final uri = ERC681URI(
30 + chainId: 1,
31 + address: recipient,
32 + amount: "200.172148",
33 + contractAddress: usdtEthContract,
34 + tokenDecimals: 6,
35 + ).toString();
36 +
37 + final request = PaymentRequest.fromUri(Uri.parse(uri));
38 + expect(request.chainId, 1);
39 + expect(request.contractAddress, usdtEthContract);
40 + expect(request.resolveTokenAmount(usdt), "200.172148");
41 + });
42 +
43 + test("new app mainnet DAI QR emits both params and the amount wins", () {
44 + final uri = ERC681URI(
45 + chainId: 1,
46 + address: recipient,
47 + amount: "1.5",
48 + contractAddress: daiEthContract,
49 + ).toString();
50 +
51 + expect(uri.contains("uint256=1500000000000000000"), true);
52 + expect(uri.contains("amount=1.5"), true);
53 +
54 + final request = PaymentRequest.fromUri(Uri.parse(uri));
55 + expect(request.chainId, 1);
56 + expect(request.resolveTokenAmount(dai), "1.5");
57 + });
58 +
59 + test("old app chainless USDT QR resolves through the legacy fallback", () {
60 + final request = PaymentRequest.fromUri(
61 + Uri.parse(
62 + "ethereum:$usdtEthContract/transfer?address=$recipient&uint256=2000000000000000000",
63 + ),
64 + );
65 +
66 + expect(request.chainId, null);
67 + expect(request.resolveTokenAmount(usdt), "2");
68 + });
69 +
70 + test("team lead legacy URI resolves to the intended two USDT", () {
71 + final request = PaymentRequest.fromUri(
72 + Uri.parse(
73 + "ethereum:$usdtEthContract@1/transfer?address=$recipient&uint256=2013999999999999744",
74 + ),
75 + );
76 +
77 + expect(request.chainId, 1);
78 + expect(request.resolveTokenAmount(usdt), "2.013999999999999744");
79 + });
80 +
81 + test("external QR with a true six decimal uint256 resolves directly", () {
82 + final request = PaymentRequest.fromUri(
83 + Uri.parse("ethereum:$usdtEthContract@1/transfer?address=$recipient&uint256=1000000"),
84 + );
85 +
86 + expect(request.resolveTokenAmount(usdt), "1");
87 + });
88 +
89 + test("old app native scientific value parses without a chain", () {
90 + final request = PaymentRequest.fromUri(Uri.parse("ethereum:$recipient?value=2.014e18"));
91 +
92 + expect(request.chainId, null);
93 + expect(request.amount, "2.014");
94 + });
95 +
96 + test("new app native QR round trips with the explicit chain", () {
97 + final uri = ERC681URI(chainId: 1, address: recipient, amount: "2.5", contractAddress: null)
98 + .toString();
99 + expect(uri, "ethereum:$recipient@1?value=2.5e18");
100 +
101 + final request = PaymentRequest.fromUri(Uri.parse(uri));
102 + expect(request.chainId, 1);
103 + expect(request.amount, "2.5");
104 + });
105 +
106 + test("new app base token QR carries the base chain", () {
107 + final uri = ERC681URI(
108 + chainId: 8453,
109 + address: recipient,
110 + amount: "3",
111 + contractAddress: usdtBaseContract,
112 + tokenDecimals: 6,
113 + ).toString();
114 +
115 + final request = PaymentRequest.fromUri(Uri.parse(uri));
116 + expect(request.chainId, 8453);
117 + expect(request.contractAddress, usdtBaseContract);
118 + expect(request.amount, "3");
119 + });
120 + });
121 +
122 + group("emitter invariants", () {
123 + test("every emitted ethereum URI names its chain", () {
124 + final uris = [
125 + ERC681URI(chainId: 1, address: recipient, amount: "", contractAddress: null),
126 + ERC681URI(chainId: 1, address: recipient, amount: "1", contractAddress: null),
127 + ERC681URI(
128 + chainId: 1,
129 + address: recipient,
130 + amount: "1",
131 + contractAddress: usdtEthContract,
132 + tokenDecimals: 6,
133 + ),
134 + ERC681URI(chainId: 8453, address: recipient, amount: "", contractAddress: usdtBaseContract),
135 + ];
136 +
137 + for (final uri in uris) {
138 + expect(uri.toString().contains("@"), true, reason: uri.toString());
139 + }
140 + });
141 +
142 + test("six decimal tokens emit no uint256", () {
143 + final uri = ERC681URI(
144 + chainId: 1,
145 + address: recipient,
146 + amount: "1.5",
147 + contractAddress: usdtEthContract,
148 + tokenDecimals: 6,
149 + ).toString();
150 +
151 + expect(uri.contains("uint256"), false);
152 + expect(uri.contains("amount=1.5"), true);
153 + });
154 +
155 + test("a stored raw amount is passed through verbatim", () {
156 + final uri = ERC681URI(
157 + chainId: 1,
158 + address: recipient,
159 + amount: "",
160 + contractAddress: usdtEthContract,
161 + tokenDecimals: 6,
162 + rawTokenAmount: "1000000",
163 + ).toString();
164 +
165 + expect(uri, "ethereum:$usdtEthContract@1/transfer?address=$recipient&uint256=1000000");
166 + });
167 +
168 + test("comma decimal separators are normalized", () {
169 + final uri = ERC681URI(
170 + chainId: 1,
171 + address: recipient,
172 + amount: "2,5",
173 + contractAddress: usdtEthContract,
174 + tokenDecimals: 6,
175 + ).toString();
176 +
177 + expect(uri.contains("amount=2.5"), true);
178 + });
179 + });
180 +
181 + group("hostile and external input", () {
182 + test("EIP681 pay- prefix parses for native transfers", () {
183 + final request = PaymentRequest.fromUri(Uri.parse("ethereum:pay-$recipient@1?value=1e18"));
184 +
185 + expect(request.address, recipient);
186 + expect(request.chainId, 1);
187 + expect(request.amount, "1");
188 + });
189 +
190 + test("EIP681 pay- prefix parses for token transfers", () {
191 + final request = PaymentRequest.fromUri(
192 + Uri.parse("ethereum:pay-$usdtEthContract@1/transfer?address=$recipient&uint256=1000000"),
193 + );
194 +
195 + expect(request.contractAddress, usdtEthContract);
196 + expect(request.address, recipient);
197 + expect(request.resolveTokenAmount(usdt), "1");
198 + });
199 +
200 + test("a trailing chain separator defaults to mainnet", () {
201 + final request = PaymentRequest.fromUri(Uri.parse("ethereum:$recipient@?value=1e18"));
202 +
203 + expect(request.address, recipient);
204 + expect(request.chainId, 1);
205 + });
206 +
207 + test("a non numeric chain id defaults to mainnet", () {
208 + final request = PaymentRequest.fromUri(Uri.parse("ethereum:$recipient@abc"));
209 +
210 + expect(request.address, recipient);
211 + expect(request.chainId, 1);
212 + });
213 +
214 + test("an ENS style target degrades to the raw name without throwing", () {
215 + final request = PaymentRequest.fromUri(Uri.parse("ethereum:vitalik.eth?amount=1"));
216 +
217 + expect(request.address, "vitalik.eth");
218 + expect(request.amount, "1");
219 + });
220 +
221 + test("junk after the scheme does not throw", () {
222 + final request = PaymentRequest.fromUri(Uri.parse("ethereum:junk"));
223 +
224 + expect(request.address, "junk");
225 + });
226 +
227 + test("an empty ethereum URI does not throw", () {
228 + final request = PaymentRequest.fromUri(Uri.parse("ethereum:"));
229 +
230 + expect(request.address, "");
231 + });
232 +
233 + test("a non numeric native value yields no amount", () {
234 + final request = PaymentRequest.fromUri(Uri.parse("ethereum:$recipient?value=abc"));
235 +
236 + expect(request.address, recipient);
237 + expect(request.amount, "");
238 + });
239 +
240 + test("an unknown contract function still extracts the contract", () {
241 + final request = PaymentRequest.fromUri(
242 + Uri.parse("ethereum:$usdtEthContract@1/approve?address=$recipient"),
243 + );
244 +
245 + expect(request.contractAddress, usdtEthContract);
246 + expect(request.address, recipient);
247 + });
248 +
249 + test("plain text input falls back to an address passthrough", () {
250 + final request = PaymentRequest.fromString("definitelynotauri");
251 +
252 + expect(request.address, "definitelynotauri");
253 + expect(request.scheme, "");
254 + });
255 + });
256 +
257 + group("resolveTokenAmount boundaries", () {
258 + test("one billion whole tokens is still read with the token decimals", () {
259 + final request =
260 + PaymentRequest("addr", "", "", "ethereum", null, rawTokenAmount: "1000000000000000");
261 +
262 + expect(request.resolveTokenAmount(usdt), "1000000000");
263 + });
264 +
265 + test("just past the plausibility bound flips to the legacy reading", () {
266 + final request =
267 + PaymentRequest("addr", "", "", "ethereum", null, rawTokenAmount: "1000000000000001");
268 +
269 + expect(request.resolveTokenAmount(usdt), "0.001000000000000001");
270 + });
271 +
272 + test("eighteen decimal tokens never take the legacy branch", () {
273 + final request =
274 + PaymentRequest("addr", "", "", "ethereum", null, rawTokenAmount: "50000000000000000000");
275 +
276 + expect(request.resolveTokenAmount(dai), "50");
277 + });
278 +
279 + test("garbage raw amounts resolve to null", () {
280 + final request =
281 + PaymentRequest("addr", "", "", "ethereum", null, rawTokenAmount: "not a number");
282 +
283 + expect(request.resolveTokenAmount(usdt), null);
284 + });
285 + });
286 +
287 + group("send page reconstruction round trip", () {
288 + test("an explicit chain request rebuilds into the same payment", () {
289 + final original = PaymentRequest.fromUri(
290 + Uri.parse("ethereum:$usdtBaseContract@8453/transfer?address=$recipient&uint256=3000000"),
291 + );
292 +
293 + final rebuilt = ERC681URI(
294 + address: original.address,
295 + amount: original.amount,
296 + contractAddress: original.contractAddress,
297 + chainId: original.chainId ?? 1,
298 + rawTokenAmount: original.rawTokenAmount,
299 + ).toString();
300 +
301 + final reparsed = PaymentRequest.fromUri(Uri.parse(rebuilt));
302 + expect(reparsed.chainId, 8453);
303 + expect(reparsed.contractAddress, usdtBaseContract);
304 + expect(reparsed.rawTokenAmount, "3000000");
305 + expect(reparsed.address, recipient);
306 + });
307 +
308 + test("a dual param request survives reconstruction with the raw intact", () {
309 + final original = PaymentRequest.fromUri(
310 + Uri.parse(
311 + "ethereum:$daiEthContract@1/transfer?address=$recipient&uint256=1500000000000000000&amount=1.5",
312 + ),
313 + );
314 +
315 + final rebuilt = ERC681URI(
316 + address: original.address,
317 + amount: original.amount,
318 + contractAddress: original.contractAddress,
319 + chainId: original.chainId ?? 1,
320 + rawTokenAmount: original.rawTokenAmount,
321 + ).toString();
322 +
323 + final reparsed = PaymentRequest.fromUri(Uri.parse(rebuilt));
324 + expect(reparsed.amount, "1.5");
325 + expect(reparsed.rawTokenAmount, "1500000000000000000");
326 + });
327 + });
328 +
329 + group("detector supplements", () {
330 + test("pay- prefixed URIs are detected as EVM payments", () {
331 + final result = UniversalAddressDetector.detectAddress("ethereum:pay-$recipient@1?value=1e18");
332 +
333 + expect(result.isValid, true);
334 + expect(result.detectedWalletType, WalletType.ethereum);
335 + });
336 +
337 + test("an unsupported chain id is still surfaced for routing", () {
338 + final result = UniversalAddressDetector.detectAddress("ethereum:$recipient@10?value=1e18");
339 +
340 + expect(result.isValid, true);
341 + expect(result.chainId, 10);
342 + });
343 + });
344 +}
test/utils/payment_request_test.dart
+18 -7
@@ -7,7 +7,8 @@ void main() {
7 group("Ethereum URIs", () {
8 test("extract address and raw token amount from EIP681 Uri with contract", () {
9 final uri = Uri.parse(
10 - "ethereum:0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174@1/transfer?address=0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41&uint256=2000000000000000000");
10 + "ethereum:0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174@1/transfer?address=0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41&uint256=2000000000000000000",
11 + );
12 final paymentRequest = PaymentRequest.fromUri(uri);
13
14 expect(paymentRequest.address, "0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41");
@@ -17,7 +18,8 @@ void main() {
18
19 test("extract address and amount from EIP681 Uri", () {
20 final uri = Uri.parse(
20 - "ethereum:0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41@1?value=2000000000000000000");
21 + "ethereum:0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41@1?value=2000000000000000000",
22 + );
23 final paymentRequest = PaymentRequest.fromUri(uri);
24
25 expect(paymentRequest.address, "0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41");
@@ -34,7 +36,8 @@ void main() {
36
37 test("extract address from EIP681 Uri with contract", () {
38 final uri = Uri.parse(
37 - "ethereum:0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174@1/transfer?address=0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41");
39 + "ethereum:0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174@1/transfer?address=0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41",
40 + );
41 final paymentRequest = PaymentRequest.fromUri(uri);
42
43 expect(paymentRequest.address, "0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41");
@@ -43,7 +46,8 @@ void main() {
46
47 test("extract address and raw token amount from EIP681 Uri with contract and no chainId", () {
48 final uri = Uri.parse(
46 - "ethereum:0x1234567890abcdef1234567890abcdef12345678/transfer?address=0xabcdef1234567890abcdef1234567890abcdef12&uint256=1000000000000000000");
49 + "ethereum:0x1234567890abcdef1234567890abcdef12345678/transfer?address=0xabcdef1234567890abcdef1234567890abcdef12&uint256=1000000000000000000",
50 + );
51 final paymentRequest = PaymentRequest.fromUri(uri);
52
53 expect(paymentRequest.address, "0xabcdef1234567890abcdef1234567890abcdef12");
@@ -76,7 +80,8 @@ void main() {
80
81 test("converts the raw uint256 using the token decimals", () {
82 final uri = Uri.parse(
79 - "ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7@1/transfer?address=0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41&uint256=1000000");
83 + "ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7@1/transfer?address=0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41&uint256=1000000",
84 + );
85 final paymentRequest = PaymentRequest.fromUri(uri);
86
87 expect(paymentRequest.resolveTokenAmount(usdt), "1");
@@ -90,8 +95,14 @@ void main() {
95 });
96
97 test("falls back to the legacy 18 decimal reading for implausibly large amounts", () {
93 - final paymentRequest = PaymentRequest("addr", "", "", "ethereum", null,
94 - rawTokenAmount: "50000000000000000000");
98 + final paymentRequest = PaymentRequest(
99 + "addr",
100 + "",
101 + "",
102 + "ethereum",
103 + null,
104 + rawTokenAmount: "50000000000000000000",
105 + );
106
107 expect(paymentRequest.resolveTokenAmount(usdt), "50");
108 });