From 3ccda39148c71e8d85ee8ae324d23a0b8b02f02f Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:55:12 -0300 Subject: [PATCH 1/4] feat(pay-tests): add USDT-on-Arbitrum Permit2 pay flow + allowance-reset script Add pay_usdt_arbitrum.yaml: creates a payment, selects USDT on Arbitrum, pays, and asserts the success screen. USDT is a Permit2 token, so the wallet sends an approve (allowance) tx then the payment tx; the flow best-effort observes the approve step via the new pay-loading-setup-note testID. To keep the approve step exercised on every run, consumers must reset the Permit2 allowance to 0 after the test. Add a shared CI-helper Node script (scripts/revoke-permit2-approval.js + package.json pinning ethers@5) for this. It signs a transaction, so it lives outside .maestro/scripts/ and runs as a Node post-test step, not a Maestro runScript (the GraalJS sandbox can't sign). Co-Authored-By: Claude Opus 4.8 --- .../pay-tests/.maestro/pay_usdt_arbitrum.yaml | 77 +++++ maestro/pay-tests/README.md | 28 +- maestro/pay-tests/scripts/package.json | 12 + .../scripts/revoke-permit2-approval.js | 325 ++++++++++++++++++ 4 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 maestro/pay-tests/.maestro/pay_usdt_arbitrum.yaml create mode 100644 maestro/pay-tests/scripts/package.json create mode 100644 maestro/pay-tests/scripts/revoke-permit2-approval.js diff --git a/maestro/pay-tests/.maestro/pay_usdt_arbitrum.yaml b/maestro/pay-tests/.maestro/pay_usdt_arbitrum.yaml new file mode 100644 index 0000000..d92ea55 --- /dev/null +++ b/maestro/pay-tests/.maestro/pay_usdt_arbitrum.yaml @@ -0,0 +1,77 @@ +appId: ${APP_ID} +name: WalletConnect Pay - USDT on Arbitrum (Permit2 approve + pay) +tags: + - pay + - pay-usdt-arbitrum +--- +# Create payment via API using the merchant that offers USDT on Arbitrum. +# Reuses the shared create-payment.js; the merchant determines which assets +# (USDT on Arbitrum among them) are offered to the wallet. +- runScript: + file: scripts/create-payment.js + env: + WPAY_CUSTOMER_KEY: ${WPAY_CUSTOMER_KEY_MULTI_NOKYC} + WPAY_MERCHANT_ID: ${WPAY_MERCHANT_ID_MULTI_NOKYC} + +- clearState + +- startRecording: "Pay - USDT on Arbitrum" + +# Open wallet, paste payment URL, wait for merchant info / options +- runFlow: + file: flows/pay_open_and_paste_url.yaml + +- extendedWaitUntil: + visible: + id: "pay-select-option-header" + timeout: 30000 + +# Select the USDT-on-Arbitrum option. The option's visible label is +# " USDT"; matching on the USDT asset symbol picks the USDT option. +# The Arbitrum network is then verified on the review screen below. +- tapOn: + text: ".*USDT.*" + +# Review screen must be the Arbitrum token (testID is keyed by network name) +- assertVisible: + id: "pay-review-token-arbitrum" + +# Verify the pay button shows a fiat amount +- copyTextFrom: + id: "pay-button-pay" +- assertTrue: + condition: "${maestro.copiedText.startsWith('Pay $')}" + +# Pay. USDT is a Permit2 token, so the wallet sends approve() then payment(). +- tapOn: + id: "pay-button-pay" + +# Best-effort observation of the Permit2 approve step. The setup note only renders +# while a token is being set up for the first time (allowance == 0), and it can +# flash by quickly, so this is intentionally soft: a `when: visible` guard with a +# screenshot (never fails) instead of an assertion. If Maestro misses the brief +# window, or a prior-run allowance reset didn't happen (allowance != 0 -> no setup +# step), the block is simply skipped. The success screen below is the real assertion. +- runFlow: + when: + visible: + id: "pay-loading-setup-note" + commands: + - takeScreenshot: usdt-permit2-approve-setup + +# Wait for the success screen. Generous timeout: two on-chain txs on Arbitrum. +- extendedWaitUntil: + visible: + id: "pay-result-success-icon" + timeout: 90000 + +- tapOn: + id: "pay-button-result-action-success" + +# Verify the payment modal is dismissed and we're back at the main wallet screen +- extendedWaitUntil: + visible: + id: "button-scan" + timeout: 10000 + +- stopRecording diff --git a/maestro/pay-tests/README.md b/maestro/pay-tests/README.md index 6eeb1b4..e6d668b 100644 --- a/maestro/pay-tests/README.md +++ b/maestro/pay-tests/README.md @@ -37,6 +37,7 @@ Every wallet platform must add these accessibility identifiers to the correspond |---|---|---| | `pay-merchant-info` | Merchant display | Shows merchant name and payment amount | | `pay-loading-message` | Loading text | Shown during payment processing | +| `pay-loading-setup-note` | Token setup note | Secondary line shown only while setting up a token for the first time (e.g. the USDT Permit2 approve step) | ### Payment Modal — Option Selection @@ -155,10 +156,35 @@ Each merchant pair represents a different test configuration. The tests use thes | `pay_kyc_back_navigation` | Back/close button navigation in KYC | MULTI_KYC | | `pay_insufficient_funds` | Payment amount exceeds wallet balance | SINGLE_NOKYC | | `pay_double_scan` | Re-scan same QR after completion | SINGLE_NOKYC | +| `pay_usdt_arbitrum` | USDT on Arbitrum — Permit2 token: wallet sends `approve` then `pay` (two-tx path) | MULTI_NOKYC | | `pay_expired_link` | Hardcoded expired payment URL | None (hardcoded) | | `pay_cancelled` | Hardcoded cancelled payment URL | None (hardcoded) | -All flows are tagged with `pay` for filtering via `--include-tags`. +All flows are tagged with `pay` for filtering via `--include-tags`. `pay_usdt_arbitrum` additionally +carries the `pay-usdt-arbitrum` tag so it can be run on its own. + +### Permit2 tokens & the allowance reset (`pay_usdt_arbitrum`) + +USDT is a [Permit2](https://github.com/Uniswap/permit2) token, so paying with it requires the wallet +to send an `approve` (allowance) transaction **and then** the payment transaction. The flow asserts the +success screen and best-effort observes the approve step via the `pay-loading-setup-note` testID. + +To keep the `approve` step exercised on every run, the consuming repo must **reset the Permit2 +allowance back to 0 after the test**. This is a real Node script that signs a transaction, so it can +**not** run inside Maestro's `runScript` sandbox (GraalJS — no `require`, no signing). It lives in a +sibling CI-helper dir, `scripts/revoke-permit2-approval.js` (deps pinned in `scripts/package.json`), +and runs as a post-test Node step: + +```bash +cd maestro/pay-tests/scripts && npm ci +node revoke-permit2-approval.js \ + --chainId eip155:42161 \ + --privateKey "$TEST_WALLET_PRIVATE_KEY" \ + --rpcUrl https://arb1.arbitrum.io/rpc # WC Blockchain API gates some Arbitrum methods; use a real RPC +# --walletAddress is optional: derived from the key when omitted; verified to match when passed. +``` + +The test wallet must hold USDT **and** a little ETH (gas) on Arbitrum. ## Deep Link Support diff --git a/maestro/pay-tests/scripts/package.json b/maestro/pay-tests/scripts/package.json new file mode 100644 index 0000000..d1060e1 --- /dev/null +++ b/maestro/pay-tests/scripts/package.json @@ -0,0 +1,12 @@ +{ + "name": "wc-pay-maestro-scripts", + "version": "1.0.0", + "private": true, + "description": "CI helper scripts for the shared WalletConnect Pay Maestro tests. Unlike .maestro/scripts/*.js (which run in Maestro's runScript sandbox), these are real Node scripts that may sign transactions and require dependencies.", + "scripts": { + "permit2:revoke": "node ./revoke-permit2-approval.js" + }, + "dependencies": { + "ethers": "5.8.0" + } +} diff --git a/maestro/pay-tests/scripts/revoke-permit2-approval.js b/maestro/pay-tests/scripts/revoke-permit2-approval.js new file mode 100644 index 0000000..63beb99 --- /dev/null +++ b/maestro/pay-tests/scripts/revoke-permit2-approval.js @@ -0,0 +1,325 @@ +#!/usr/bin/env node + +const { ethers } = require('ethers'); + +const WALLETCONNECT_RPC_BASE_URL = 'https://rpc.walletconnect.org/v1/'; +const PERMIT2_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3'; +const ERC20_ABI = ['function approve(address spender, uint256 amount)']; + +const USDT_BY_CHAIN = { + 'eip155:1': '0xdac17f958d2ee523a2206206994597c13d831ec7', + 'eip155:10': '0x94b008aA00579c1307B0EF2c499aD98a8ce58e58', + 'eip155:137': '0xc2132D05D31c914a87C6611C10748AEb04B58e8F', + 'eip155:8453': '0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2', + 'eip155:42161': '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9', + 'eip155:43114': '0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7', + 'eip155:59144': '0xA219439258ca9da29E9Cc4cE5596924745e12B93', +}; + +const MIN_PRIORITY_FEE_GWEI_BY_CHAIN = { + 'eip155:137': '25', +}; + +function printUsage() { + const supportedChains = Object.keys(USDT_BY_CHAIN).join(', '); + console.log(`Usage: + yarn permit2:revoke --chainId --privateKey <0x...> (--projectId | --rpcUrl ) [--walletAddress <0x...>] [--tokenAddress <0x...>] [--minPriorityFeeGwei ] + +Example: + yarn permit2:revoke --chainId eip155:137 --privateKey 0xYourPrivateKey --projectId yourProjectId + yarn permit2:revoke --chainId eip155:42161 --privateKey 0xYourPrivateKey --rpcUrl https://arb1.arbitrum.io/rpc + +Defaults: + If --walletAddress is omitted, it is derived from --privateKey. If provided, it is verified to match the key. + If --tokenAddress is omitted, the script uses the USDT address for the selected chain. + If --rpcUrl is provided it takes precedence over --projectId (use this for chains where the + WalletConnect Blockchain API gates methods like eth_blockNumber, e.g. Arbitrum). + For Polygon (eip155:137), min priority fee defaults to 25 gwei. + Supported USDT chains: ${supportedChains} +`); +} + +function parseArgs(argv) { + const args = {}; + + for (let index = 0; index < argv.length; index += 1) { + const current = argv[index]; + if (!current.startsWith('--')) { + throw new Error(`Unexpected argument "${current}"`); + } + + const eqIndex = current.indexOf('='); + if (eqIndex > -1) { + const key = current.slice(2, eqIndex); + const value = current.slice(eqIndex + 1); + args[key] = value; + continue; + } + + const key = current.slice(2); + const next = argv[index + 1]; + if (!next || next.startsWith('--')) { + args[key] = 'true'; + continue; + } + + args[key] = next; + index += 1; + } + + return args; +} + +function normalizeChainId(chainIdInput) { + if (!chainIdInput) { + throw new Error('Missing --chainId'); + } + + const value = String(chainIdInput).trim(); + if (/^\d+$/.test(value)) { + return `eip155:${value}`; + } + + if (/^eip155:\d+$/.test(value)) { + return value; + } + + throw new Error( + `Invalid chainId "${chainIdInput}". Use numeric (e.g. 137) or CAIP-2 (e.g. eip155:137).`, + ); +} + +function normalizePrivateKey(privateKeyInput) { + if (!privateKeyInput) { + throw new Error('Missing --privateKey'); + } + + const value = String(privateKeyInput).trim(); + const prefixed = value.startsWith('0x') ? value : `0x${value}`; + if (!/^0x[0-9a-fA-F]{64}$/.test(prefixed)) { + throw new Error('Invalid private key format. Expected 32-byte hex.'); + } + + return prefixed; +} + +function normalizeAddress(name, address) { + if (!address) { + throw new Error(`Missing --${name}`); + } + + try { + return ethers.utils.getAddress(String(address).trim()); + } catch { + throw new Error(`Invalid ${name}: ${address}`); + } +} + +function parseMinPriorityFeeWei(chainId, minPriorityFeeGweiArg) { + const raw = + minPriorityFeeGweiArg != null && String(minPriorityFeeGweiArg).trim() !== '' + ? String(minPriorityFeeGweiArg).trim() + : MIN_PRIORITY_FEE_GWEI_BY_CHAIN[chainId]; + + if (!raw) { + return ethers.constants.Zero; + } + + if (!/^\d+(\.\d+)?$/.test(raw)) { + throw new Error(`Invalid --minPriorityFeeGwei value: ${minPriorityFeeGweiArg}`); + } + + return ethers.utils.parseUnits(raw, 'gwei'); +} + +function extractMinTipFromError(error) { + const message = error instanceof Error ? error.message : String(error); + const match = message.match(/minimum needed (\d+)/i); + if (!match) { + return null; + } + + try { + return ethers.BigNumber.from(match[1]); + } catch { + return null; + } +} + +function getWalletConnectRpcUrl(chainId, projectId) { + if (!projectId) { + throw new Error('Missing --projectId (or pass --rpcUrl to use a custom RPC).'); + } + + return `${WALLETCONNECT_RPC_BASE_URL}?chainId=${encodeURIComponent(chainId)}&projectId=${encodeURIComponent(projectId)}`; +} + +function maxBigNumber(a, b) { + return a.gt(b) ? a : b; +} + +async function buildFeeOverrides({ + provider, + signerAddress, + txRequest, + chainId, + minPriorityFeeWei, +}) { + const gasLimit = await provider.estimateGas({ + ...txRequest, + from: signerAddress, + }); + const feeData = await provider.getFeeData(); + let baseFeePerGas = ethers.constants.Zero; + try { + const latestBlock = await provider.getBlock('latest'); + baseFeePerGas = latestBlock?.baseFeePerGas || ethers.constants.Zero; + } catch (error) { + console.log( + `Skipping baseFee read (${error instanceof Error ? error.message : String(error)}); falling back to feeData.`, + ); + } + + const hasEip1559FeeData = + !!feeData.maxFeePerGas || + !!feeData.maxPriorityFeePerGas || + baseFeePerGas.gt(0); + + if (!hasEip1559FeeData) { + const gasPrice = maxBigNumber( + feeData.gasPrice || ethers.constants.Zero, + minPriorityFeeWei, + ); + return { gasLimit, gasPrice }; + } + + const maxPriorityFeePerGas = maxBigNumber( + feeData.maxPriorityFeePerGas || ethers.constants.Zero, + minPriorityFeeWei, + ); + const minMaxFeePerGas = baseFeePerGas.gt(0) + ? baseFeePerGas.mul(2).add(maxPriorityFeePerGas) + : maxPriorityFeePerGas.mul(2); + const maxFeePerGas = maxBigNumber( + feeData.maxFeePerGas || ethers.constants.Zero, + minMaxFeePerGas, + ); + + console.log( + `fee config for ${chainId}: maxPriorityFeePerGas=${ethers.utils.formatUnits(maxPriorityFeePerGas, 'gwei')} gwei, maxFeePerGas=${ethers.utils.formatUnits(maxFeePerGas, 'gwei')} gwei`, + ); + + return { + gasLimit, + maxPriorityFeePerGas, + maxFeePerGas, + }; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help === 'true' || args.h === 'true') { + printUsage(); + return; + } + + const chainId = normalizeChainId(args.chainId); + const walletAddressArg = args.walletAddress + ? normalizeAddress('walletAddress', args.walletAddress) + : null; + const privateKey = normalizePrivateKey(args.privateKey); + const tokenAddress = normalizeAddress( + 'tokenAddress', + args.tokenAddress || USDT_BY_CHAIN[chainId], + ); + const minPriorityFeeWei = parseMinPriorityFeeWei( + chainId, + args.minPriorityFeeGwei, + ); + const projectId = String(args.projectId || '').trim(); + const rpcUrlOverride = String(args.rpcUrl || '').trim(); + + if (!USDT_BY_CHAIN[chainId] && !args.tokenAddress) { + throw new Error( + `No default USDT address configured for ${chainId}. Pass --tokenAddress explicitly.`, + ); + } + + const rpcUrl = rpcUrlOverride || getWalletConnectRpcUrl(chainId, projectId); + const chainIdNumber = Number(chainId.split(':')[1]); + const provider = new ethers.providers.StaticJsonRpcProvider(rpcUrl, { + chainId: chainIdNumber, + name: `eip155-${chainIdNumber}`, + }); + + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = ethers.utils.getAddress(signer.address); + if (walletAddressArg && signerAddress !== walletAddressArg) { + throw new Error( + `walletAddress (${walletAddressArg}) does not match private key (${signerAddress}).`, + ); + } + const walletAddress = signerAddress; + + const token = new ethers.Contract(tokenAddress, ERC20_ABI, signer); + + console.log('Revoking Permit2 token approval...'); + console.log(`chainId: ${chainId}`); + console.log(`wallet: ${walletAddress}`); + console.log(`token: ${tokenAddress}`); + console.log(`spender: ${PERMIT2_ADDRESS}`); + + const txRequest = await token.populateTransaction.approve(PERMIT2_ADDRESS, 0); + const feeOverrides = await buildFeeOverrides({ + provider, + signerAddress, + txRequest, + chainId, + minPriorityFeeWei, + }); + + let tx; + try { + tx = await signer.sendTransaction({ + ...txRequest, + ...feeOverrides, + }); + } catch (error) { + const minTipFromRpc = extractMinTipFromError(error); + if (!minTipFromRpc) { + throw error; + } + + console.log( + `RPC rejected tip as too low. Retrying with min tip ${ethers.utils.formatUnits(minTipFromRpc, 'gwei')} gwei...`, + ); + const retryOverrides = await buildFeeOverrides({ + provider, + signerAddress, + txRequest, + chainId, + minPriorityFeeWei: maxBigNumber(minPriorityFeeWei, minTipFromRpc), + }); + tx = await signer.sendTransaction({ + ...txRequest, + ...retryOverrides, + }); + } + + console.log(`tx hash: ${tx.hash}`); + + const receipt = await tx.wait(); + if (receipt.status !== 1) { + throw new Error('Transaction reverted.'); + } + + console.log(`Revoke successful in block ${receipt.blockNumber}.`); +} + +main().catch(error => { + console.error( + `Failed to revoke Permit2 approval: ${error instanceof Error ? error.message : String(error)}`, + ); + printUsage(); + process.exit(1); +}); From e78552d1094d43321bb9b9a34d138216a2bafb5c Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:10:33 -0300 Subject: [PATCH 2/4] refactor(pay-tests): target USDT on Polygon instead of Arbitrum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit USDT on Arbitrum (0xFd086…) is EIP-3009 (signature-based, gasless), so WC Pay never returns an on-chain approve action for it — the approve step the test is meant to cover would never run. USDT on Polygon (0xc2132D…) is a plain ERC-20, so WC Pay uses the Permit2 approve + pay path. Rename the flow to pay_usdt_polygon (tag pay-usdt-polygon), assert pay-review-token-polygon, and update the reset invocation to --chainId eip155:137 --rpcUrl https://polygon-rpc.com. Co-Authored-By: Claude Opus 4.8 --- ...dt_arbitrum.yaml => pay_usdt_polygon.yaml} | 37 ++++++++++--------- maestro/pay-tests/README.md | 25 ++++++++----- 2 files changed, 35 insertions(+), 27 deletions(-) rename maestro/pay-tests/.maestro/{pay_usdt_arbitrum.yaml => pay_usdt_polygon.yaml} (53%) diff --git a/maestro/pay-tests/.maestro/pay_usdt_arbitrum.yaml b/maestro/pay-tests/.maestro/pay_usdt_polygon.yaml similarity index 53% rename from maestro/pay-tests/.maestro/pay_usdt_arbitrum.yaml rename to maestro/pay-tests/.maestro/pay_usdt_polygon.yaml index d92ea55..fd7ea30 100644 --- a/maestro/pay-tests/.maestro/pay_usdt_arbitrum.yaml +++ b/maestro/pay-tests/.maestro/pay_usdt_polygon.yaml @@ -1,12 +1,12 @@ appId: ${APP_ID} -name: WalletConnect Pay - USDT on Arbitrum (Permit2 approve + pay) +name: WalletConnect Pay - USDT on Polygon (Permit2 approve + pay) tags: - pay - - pay-usdt-arbitrum + - pay-usdt-polygon --- -# Create payment via API using the merchant that offers USDT on Arbitrum. +# Create payment via API using the merchant that offers USDT on Polygon. # Reuses the shared create-payment.js; the merchant determines which assets -# (USDT on Arbitrum among them) are offered to the wallet. +# (USDT on Polygon among them) are offered to the wallet. - runScript: file: scripts/create-payment.js env: @@ -15,7 +15,7 @@ tags: - clearState -- startRecording: "Pay - USDT on Arbitrum" +- startRecording: "Pay - USDT on Polygon" # Open wallet, paste payment URL, wait for merchant info / options - runFlow: @@ -26,15 +26,18 @@ tags: id: "pay-select-option-header" timeout: 30000 -# Select the USDT-on-Arbitrum option. The option's visible label is +# Select the USDT-on-Polygon option. The option's visible label is # " USDT"; matching on the USDT asset symbol picks the USDT option. -# The Arbitrum network is then verified on the review screen below. +# The Polygon network is then verified on the review screen below. USDT on +# Polygon is a plain ERC-20 (no EIP-3009/2612), so it takes the Permit2 +# approve + pay path — the point of this test. (USDT on Arbitrum is EIP-3009 / +# signature-based and would never trigger an on-chain approve.) - tapOn: text: ".*USDT.*" -# Review screen must be the Arbitrum token (testID is keyed by network name) +# Review screen must be the Polygon token (testID is keyed by network name) - assertVisible: - id: "pay-review-token-arbitrum" + id: "pay-review-token-polygon" # Verify the pay button shows a fiat amount - copyTextFrom: @@ -42,16 +45,16 @@ tags: - assertTrue: condition: "${maestro.copiedText.startsWith('Pay $')}" -# Pay. USDT is a Permit2 token, so the wallet sends approve() then payment(). +# Pay. USDT on Polygon is a Permit2 token, so the wallet sends approve() then payment(). - tapOn: id: "pay-button-pay" -# Best-effort observation of the Permit2 approve step. The setup note only renders -# while a token is being set up for the first time (allowance == 0), and it can -# flash by quickly, so this is intentionally soft: a `when: visible` guard with a -# screenshot (never fails) instead of an assertion. If Maestro misses the brief -# window, or a prior-run allowance reset didn't happen (allowance != 0 -> no setup -# step), the block is simply skipped. The success screen below is the real assertion. +# Best-effort observation of the approve step. The setup note only renders while a +# token is being set up for the first time (allowance == 0), and it can flash by +# quickly, so this is intentionally soft: a `when: visible` guard with a screenshot +# (never fails) instead of an assertion. If Maestro misses the brief window, or a +# prior-run allowance reset didn't happen, the block is simply skipped. The success +# screen below is the real assertion. - runFlow: when: visible: @@ -59,7 +62,7 @@ tags: commands: - takeScreenshot: usdt-permit2-approve-setup -# Wait for the success screen. Generous timeout: two on-chain txs on Arbitrum. +# Wait for the success screen. Generous timeout: two on-chain txs on Polygon. - extendedWaitUntil: visible: id: "pay-result-success-icon" diff --git a/maestro/pay-tests/README.md b/maestro/pay-tests/README.md index e6d668b..c2da945 100644 --- a/maestro/pay-tests/README.md +++ b/maestro/pay-tests/README.md @@ -156,18 +156,22 @@ Each merchant pair represents a different test configuration. The tests use thes | `pay_kyc_back_navigation` | Back/close button navigation in KYC | MULTI_KYC | | `pay_insufficient_funds` | Payment amount exceeds wallet balance | SINGLE_NOKYC | | `pay_double_scan` | Re-scan same QR after completion | SINGLE_NOKYC | -| `pay_usdt_arbitrum` | USDT on Arbitrum — Permit2 token: wallet sends `approve` then `pay` (two-tx path) | MULTI_NOKYC | +| `pay_usdt_polygon` | USDT on Polygon — Permit2 token: wallet sends `approve` then `pay` (two-tx path) | MULTI_NOKYC | | `pay_expired_link` | Hardcoded expired payment URL | None (hardcoded) | | `pay_cancelled` | Hardcoded cancelled payment URL | None (hardcoded) | -All flows are tagged with `pay` for filtering via `--include-tags`. `pay_usdt_arbitrum` additionally -carries the `pay-usdt-arbitrum` tag so it can be run on its own. +All flows are tagged with `pay` for filtering via `--include-tags`. `pay_usdt_polygon` additionally +carries the `pay-usdt-polygon` tag so it can be run on its own. -### Permit2 tokens & the allowance reset (`pay_usdt_arbitrum`) +### Permit2 tokens & the allowance reset (`pay_usdt_polygon`) -USDT is a [Permit2](https://github.com/Uniswap/permit2) token, so paying with it requires the wallet -to send an `approve` (allowance) transaction **and then** the payment transaction. The flow asserts the -success screen and best-effort observes the approve step via the `pay-loading-setup-note` testID. +USDT on Polygon is a plain ERC-20 (no EIP-3009/2612), so WC Pay pays it via +[Permit2](https://github.com/Uniswap/permit2): the wallet sends an `approve` (allowance) transaction +**and then** the payment transaction. The flow asserts the success screen and best-effort observes the +approve step via the `pay-loading-setup-note` testID. + +> Polygon is used deliberately: USDT on **Arbitrum** is EIP-3009 (signature-based / gasless), so WC Pay +> never returns an on-chain `approve` action there and the approve step would never run. To keep the `approve` step exercised on every run, the consuming repo must **reset the Permit2 allowance back to 0 after the test**. This is a real Node script that signs a transaction, so it can @@ -178,13 +182,14 @@ and runs as a post-test Node step: ```bash cd maestro/pay-tests/scripts && npm ci node revoke-permit2-approval.js \ - --chainId eip155:42161 \ + --chainId eip155:137 \ --privateKey "$TEST_WALLET_PRIVATE_KEY" \ - --rpcUrl https://arb1.arbitrum.io/rpc # WC Blockchain API gates some Arbitrum methods; use a real RPC + --rpcUrl https://polygon-rpc.com # --walletAddress is optional: derived from the key when omitted; verified to match when passed. +# Polygon (eip155:137) min priority fee defaults to 25 gwei in the script. ``` -The test wallet must hold USDT **and** a little ETH (gas) on Arbitrum. +The test wallet must hold USDT **and** a little POL (gas) on Polygon. ## Deep Link Support From bd594e1a64538a388034b782c81ae2b197da3979 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:17:27 -0300 Subject: [PATCH 3/4] docs(pay-tests): use publicnode Polygon RPC in reset example polygon-rpc.com returns HTTP 401 "tenant disabled" from CI runners; use https://polygon-bor-rpc.publicnode.com (keyless) in the allowance-reset example. Co-Authored-By: Claude Opus 4.8 --- maestro/pay-tests/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/maestro/pay-tests/README.md b/maestro/pay-tests/README.md index c2da945..86d1ba2 100644 --- a/maestro/pay-tests/README.md +++ b/maestro/pay-tests/README.md @@ -184,7 +184,7 @@ cd maestro/pay-tests/scripts && npm ci node revoke-permit2-approval.js \ --chainId eip155:137 \ --privateKey "$TEST_WALLET_PRIVATE_KEY" \ - --rpcUrl https://polygon-rpc.com + --rpcUrl https://polygon-bor-rpc.publicnode.com # --walletAddress is optional: derived from the key when omitted; verified to match when passed. # Polygon (eip155:137) min priority fee defaults to 25 gwei in the script. ``` From 06e4e04ca111ab27cdcb35b73d70325607510f83 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:01:06 -0300 Subject: [PATCH 4/4] fix(pay-tests): select USDT-on-Polygon by stable network+token testID The merchant lists USDT on multiple networks (e.g. Arbitrum + Polygon), so a "USDT" text match selected the wrong row (Arbitrum, EIP-3009, no approve). Select the Polygon option by its stable `pay-option-usdt-polygon` testID (scrolling it into view first). Document the additive `pay-option-{assetSymbol}-{networkName}` id in the README. Co-Authored-By: Claude Opus 4.8 --- .../pay-tests/.maestro/pay_usdt_polygon.yaml | 19 ++++++++++++------- maestro/pay-tests/README.md | 1 + 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/maestro/pay-tests/.maestro/pay_usdt_polygon.yaml b/maestro/pay-tests/.maestro/pay_usdt_polygon.yaml index fd7ea30..f8ca7a0 100644 --- a/maestro/pay-tests/.maestro/pay_usdt_polygon.yaml +++ b/maestro/pay-tests/.maestro/pay_usdt_polygon.yaml @@ -26,14 +26,19 @@ tags: id: "pay-select-option-header" timeout: 30000 -# Select the USDT-on-Polygon option. The option's visible label is -# " USDT"; matching on the USDT asset symbol picks the USDT option. -# The Polygon network is then verified on the review screen below. USDT on -# Polygon is a plain ERC-20 (no EIP-3009/2612), so it takes the Permit2 -# approve + pay path — the point of this test. (USDT on Arbitrum is EIP-3009 / -# signature-based and would never trigger an on-chain approve.) +# Select the USDT-on-Polygon option by its stable network+token testID +# (the merchant may also list USDT on other networks, e.g. Arbitrum, so a plain +# "USDT" text match would pick the wrong row). Scroll it into view first since it +# may be below the fold. USDT on Polygon is a plain ERC-20 (no EIP-3009/2612), so +# it takes the Permit2 approve + pay path — the point of this test. (USDT on +# Arbitrum is EIP-3009 / signature-based and would never trigger an on-chain approve.) +- scrollUntilVisible: + element: + id: "pay-option-usdt-polygon" + direction: DOWN + timeout: 20000 - tapOn: - text: ".*USDT.*" + id: "pay-option-usdt-polygon" # Review screen must be the Polygon token (testID is keyed by network name) - assertVisible: diff --git a/maestro/pay-tests/README.md b/maestro/pay-tests/README.md index 86d1ba2..51f0f3d 100644 --- a/maestro/pay-tests/README.md +++ b/maestro/pay-tests/README.md @@ -45,6 +45,7 @@ Every wallet platform must add these accessibility identifiers to the correspond |---|---|---| | `pay-option-{index}` | Payment option (unselected) | 0-based index from the payment options array | | `pay-option-{index}-selected` | Payment option (selected) | Same element when selected | +| `pay-option-{assetSymbol}-{networkName}` | Payment option (stable) | Lowercased, spaces → `-` (e.g. `pay-option-usdt-polygon`). Additive to the index-based id; lets a flow pick a specific asset+network when the same token appears on multiple networks. Used by `pay_usdt_polygon`. | | `pay-info-required-badge` | "Info required" badge | Shown on options that require KYC | | `pay-button-info` | Info (?) button in header | Explains KYC requirement | | `pay-button-continue` | Continue button | Proceeds after selecting a payment option |