diff --git a/maestro/pay-tests/.maestro/pay_usdt_polygon.yaml b/maestro/pay-tests/.maestro/pay_usdt_polygon.yaml new file mode 100644 index 0000000..7883f2e --- /dev/null +++ b/maestro/pay-tests/.maestro/pay_usdt_polygon.yaml @@ -0,0 +1,68 @@ +appId: ${APP_ID} +name: WalletConnect Pay - USDT on Polygon (Permit2 approve + pay) +tags: + - pay + - pay-usdt-polygon +--- +# Create payment via API (merchant that offers USDT on Polygon). +- 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 Polygon" + +# Open wallet, paste payment URL, wait for options +- runFlow: + file: flows/pay_open_and_paste_url.yaml + +- extendedWaitUntil: + visible: + id: "pay-select-option-header" + timeout: 30000 + +# Swipe to reveal the USDT-on-Polygon option, then select it by its +# network+token testID (the merchant lists USDT on other networks too). +- swipe: + direction: UP +- tapOn: + id: "pay-option-usdt-polygon" + +- assertVisible: + id: "pay-review-token-polygon" + +- copyTextFrom: + id: "pay-button-pay" +- assertTrue: + condition: "${maestro.copiedText.startsWith('Pay $')}" + +# USDT on Polygon is a Permit2 token: the wallet sends approve() then payment(). +- tapOn: + id: "pay-button-pay" + +# Best-effort capture of the approve step (only shown while allowance is 0). +- runFlow: + when: + visible: + id: "pay-loading-setup-note" + commands: + - takeScreenshot: usdt-permit2-approve-setup + +# Success screen (generous timeout: two on-chain txs). +- extendedWaitUntil: + visible: + id: "pay-result-success-icon" + timeout: 90000 + +- tapOn: + id: "pay-button-result-action-success" + +- extendedWaitUntil: + visible: + id: "button-scan" + timeout: 10000 + +- stopRecording diff --git a/maestro/pay-tests/README.md b/maestro/pay-tests/README.md index 6eeb1b4..51f0f3d 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 @@ -44,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 | @@ -155,10 +157,40 @@ 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_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`. +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_polygon`) + +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 +**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:137 \ + --privateKey "$TEST_WALLET_PRIVATE_KEY" \ + --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. +``` + +The test wallet must hold USDT **and** a little POL (gas) on Polygon. ## 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); +});