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