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
});