diff --git a/README.md b/README.md index fa4be3c5e..7c020f744 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ This repository contains example applications demonstrating various [Base] and [ | Demo Name | Type | Location | Description | |-----------|------|----------|-------------| | **Agent Spend Permissions** | Base Account | `base-account/agent-spend-permissions/` | AI-powered Zora coin purchasing with Base Account spend permissions and gas-free transactions | +| **Gasless USDC Payments** | Payments | `apps/gasless-usdc-payments/` | Gasless USDC transfers via EIP-3009 transferWithAuthorization — user signs, relayer submits and pays gas | | **Trading Agent** | Agents | `agents/trading-agent/` | CLI that scaffolds a fully configured LangChain trading agent on Base from a plain-English strategy | | **Base Pay Amazon** | Base Account | `base-account/base-pay-amazon/` | Chrome extension and checkout app that adds Base Pay to Amazon product pages | | **Base App Coins** | Base Account | `base-app-coins/` | Index and load metadata for Uniswap v4 pools related to coins created via the Base App | diff --git a/apps/gasless-usdc-payments/.env.example b/apps/gasless-usdc-payments/.env.example new file mode 100644 index 000000000..075c13f18 --- /dev/null +++ b/apps/gasless-usdc-payments/.env.example @@ -0,0 +1,6 @@ +# Relayer EOA private key. Needs ETH for gas on the selected network. NEVER commit the real key. +RELAYER_PRIVATE_KEY=0x... + +# base | base-sepolia (default: base-sepolia) +NETWORK=base-sepolia +NEXT_PUBLIC_NETWORK=base-sepolia diff --git a/apps/gasless-usdc-payments/README.md b/apps/gasless-usdc-payments/README.md new file mode 100644 index 000000000..6ee001416 --- /dev/null +++ b/apps/gasless-usdc-payments/README.md @@ -0,0 +1,56 @@ +# Gasless USDC Payments (EIP-3009) + +A minimal demo of **gasless USDC transfers on Base** using EIP-3009 `transferWithAuthorization`: the user signs a typed-data authorization (no ETH needed), and a relayer submits the transaction and pays gas. + +This is the pattern behind "pay with USDC without holding ETH" flows — onboarding users who have stablecoins but no gas token. + +## How it works + +``` +User wallet (no ETH) Relayer (holds ETH) Base +───────────────────── ─────────────────── ──── +1. Sign EIP-712 typed data + TransferWithAuthorization + (from, to, value, validAfter, + validBefore, nonce) + ──────────────▶ 2. Validate the authorization + (recipient, value cap, + expiry window) + 3. Submit transferWithAuthorization + to the USDC contract, paying gas + ──────────▶ 4. USDC contract verifies the + signature ONCHAIN and moves + funds from user → recipient +``` + +Key property: the relayer **cannot alter the transfer**. All six parameters are locked by the user's signature, which the USDC contract verifies onchain. A malicious relayer can only decline to submit — it can never redirect funds or change the amount. + +## Run it + +```bash +npm install +cp .env.example .env.local # fill in RELAYER_PRIVATE_KEY +npm run dev +``` + +- **Network:** defaults to Base Sepolia (`NETWORK=base-sepolia`). Set `NETWORK=base` for mainnet. +- **Relayer key:** needs a small amount of ETH on the selected network for gas. +- **Test USDC:** get Base Sepolia USDC from the [Circle faucet](https://faucet.circle.com/). + +Open http://localhost:3000, connect a wallet holding USDC (but no ETH needed), enter a recipient and amount, sign — the relayer submits and pays gas. + +## Security notes (read before productionizing) + +- **Cap `validBefore`.** The API rejects authorizations valid for more than 1 hour. Long-lived signed authorizations are bearer instruments — anyone holding one can submit it at any time before expiry. +- **The relayer is a gas sponsor, not a custodian.** It never holds user funds; the signature authorizes exactly one transfer to one recipient. +- **Nonce reuse is prevented onchain** by the USDC contract (random 32-byte nonces, each usable once per `from` address). +- **EOA wallets only.** EIP-3009 requires an ECDSA signature recoverable to `from`. Smart-contract wallets (Base Account, Safe, etc.) cannot produce one — detect contract accounts (`getCode`) and route them to a different flow (e.g. batched calls with a paymaster) instead of letting the signature fail at submission. +- **Rate-limit the relay endpoint** in production; each submission costs you gas. + +## Files + +| File | Purpose | +|------|---------| +| `lib/eip3009.ts` | EIP-712 domain/types for USDC on Base + Base Sepolia | +| `pages/index.tsx` | Wallet connect, typed-data signing, relay request | +| `pages/api/relay.ts` | Validates the authorization and submits it, paying gas | diff --git a/apps/gasless-usdc-payments/lib/eip3009.ts b/apps/gasless-usdc-payments/lib/eip3009.ts new file mode 100644 index 000000000..577d0ab06 --- /dev/null +++ b/apps/gasless-usdc-payments/lib/eip3009.ts @@ -0,0 +1,73 @@ +import { base, baseSepolia } from 'viem/chains'; + +export const NETWORKS = { + base: { + chain: base, + // USDC on Base mainnet + usdc: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' as const, + // EIP-712 domain name — must match the token contract's name() exactly + usdcName: 'USD Coin' as const, + }, + 'base-sepolia': { + chain: baseSepolia, + // USDC on Base Sepolia (Circle testnet deployment) + usdc: '0x036CbD53842c5426634e7929541eC2318f3dCF7e' as const, + // Circle's testnet USDC returns "USDC" from name(), not "USD Coin" + usdcName: 'USDC' as const, + }, +} as const; + +export type NetworkKey = keyof typeof NETWORKS; + +export const USDC_DECIMALS = 6; + +/** + * EIP-712 domain for USDC's EIP-3009 implementation. + * Note: `name` must exactly match the token contract's name() — this differs + * between networks (e.g. Base mainnet uses "USD Coin", Base Sepolia testnet + * uses "USDC"). Pass the `usdcName` from the NETWORKS entry for the active + * network to avoid an "invalid signature" revert. + */ +export function usdcDomain(chainId: number, usdcAddress: `0x${string}`, usdcName: string) { + return { + name: usdcName, + version: '2', + chainId, + verifyingContract: usdcAddress, + } as const; +} + +export const TRANSFER_WITH_AUTHORIZATION_TYPES = { + TransferWithAuthorization: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'validAfter', type: 'uint256' }, + { name: 'validBefore', type: 'uint256' }, + { name: 'nonce', type: 'bytes32' }, + ], +} as const; + +/** Minimal ABI for submitting the signed authorization. */ +export const TRANSFER_WITH_AUTHORIZATION_ABI = [ + { + type: 'function', + name: 'transferWithAuthorization', + stateMutability: 'nonpayable', + inputs: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'validAfter', type: 'uint256' }, + { name: 'validBefore', type: 'uint256' }, + { name: 'nonce', type: 'bytes32' }, + { name: 'v', type: 'uint8' }, + { name: 'r', type: 'bytes32' }, + { name: 's', type: 'bytes32' }, + ], + outputs: [], + }, +] as const; + +/** Maximum authorization lifetime accepted by the relay endpoint (seconds). */ +export const MAX_AUTHORIZATION_LIFETIME_SECONDS = 3600; diff --git a/apps/gasless-usdc-payments/next.config.js b/apps/gasless-usdc-payments/next.config.js new file mode 100644 index 000000000..04b970e60 --- /dev/null +++ b/apps/gasless-usdc-payments/next.config.js @@ -0,0 +1 @@ +module.exports = { reactStrictMode: true }; diff --git a/apps/gasless-usdc-payments/package.json b/apps/gasless-usdc-payments/package.json new file mode 100644 index 000000000..b3187f492 --- /dev/null +++ b/apps/gasless-usdc-payments/package.json @@ -0,0 +1,23 @@ +{ + "name": "gasless-usdc-payments", + "private": true, + "version": "0.1.0", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "14.2.4", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "viem": "^2.21.0" + }, + "devDependencies": { + "@types/node": "^20.12.10", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", + "typescript": "^5.8.3" + } +} diff --git a/apps/gasless-usdc-payments/pages/api/relay.ts b/apps/gasless-usdc-payments/pages/api/relay.ts new file mode 100644 index 000000000..03514f885 --- /dev/null +++ b/apps/gasless-usdc-payments/pages/api/relay.ts @@ -0,0 +1,111 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { createWalletClient, createPublicClient, http, isAddress, parseSignature } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { + NETWORKS, + NetworkKey, + TRANSFER_WITH_AUTHORIZATION_ABI, + MAX_AUTHORIZATION_LIFETIME_SECONDS, +} from '../../lib/eip3009'; + +/** + * Relay endpoint: accepts a signed EIP-3009 authorization and submits it, + * paying gas on behalf of the user. + * + * The signature locks every transfer parameter, so this endpoint cannot + * redirect funds — but it CAN be griefed into wasting gas. In production add + * rate limiting and (optionally) an allowlist of recipients. + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const relayerKey = process.env.RELAYER_PRIVATE_KEY; + if (!relayerKey) { + return res.status(500).json({ error: 'RELAYER_PRIVATE_KEY is not configured' }); + } + + try { + const { from, to, value, validAfter, validBefore, nonce, signature } = req.body ?? {}; + + // --- Validate inputs explicitly; fail loudly, never silently coerce --- + if (!isAddress(from) || !isAddress(to)) { + return res.status(400).json({ error: 'Invalid from/to address' }); + } + if (typeof signature !== 'string' || !signature.startsWith('0x')) { + return res.status(400).json({ error: 'Missing or malformed signature' }); + } + if (typeof nonce !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(nonce)) { + return res.status(400).json({ error: 'nonce must be a 32-byte hex string' }); + } + + let valueBig: bigint, afterBig: bigint, beforeBig: bigint; + try { + valueBig = BigInt(value); + afterBig = BigInt(validAfter); + beforeBig = BigInt(validBefore); + } catch { + return res.status(400).json({ error: 'value/validAfter/validBefore must be integers' }); + } + if (valueBig <= 0n) { + return res.status(400).json({ error: 'value must be positive' }); + } + + // --- Cap authorization lifetime: long-lived authorizations are bearer instruments --- + const now = BigInt(Math.floor(Date.now() / 1000)); + if (beforeBig <= now) { + return res.status(400).json({ error: 'Authorization already expired' }); + } + if (beforeBig - now > BigInt(MAX_AUTHORIZATION_LIFETIME_SECONDS)) { + return res.status(400).json({ + error: `validBefore too far in the future (max ${MAX_AUTHORIZATION_LIFETIME_SECONDS}s)`, + }); + } + + const networkKey = (process.env.NETWORK ?? 'base-sepolia') as NetworkKey; + const network = NETWORKS[networkKey]; + if (!network) { + return res.status(500).json({ error: `Unsupported NETWORK: ${networkKey}` }); + } + + const account = privateKeyToAccount(relayerKey as `0x${string}`); + const walletClient = createWalletClient({ + account, + chain: network.chain, + transport: http(), + }); + const publicClient = createPublicClient({ chain: network.chain, transport: http() }); + + // --- EOA check: EIP-3009 signatures cannot come from contract accounts --- + const code = await publicClient.getCode({ address: from }); + if (code && code !== '0x') { + return res.status(400).json({ + error: + 'from is a smart-contract account. EIP-3009 requires an EOA signature; use a batched-call + paymaster flow for smart wallets instead.', + }); + } + + const { v, r, s } = parseSignature(signature as `0x${string}`); + + const hash = await walletClient.writeContract({ + address: network.usdc, + abi: TRANSFER_WITH_AUTHORIZATION_ABI, + functionName: 'transferWithAuthorization', + args: [from, to, valueBig, afterBig, beforeBig, nonce as `0x${string}`, Number(v), r, s], + }); + + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + + return res.status(200).json({ + success: receipt.status === 'success', + transactionHash: hash, + blockNumber: receipt.blockNumber.toString(), + }); + } catch (error) { + console.error('relay error:', error); + const message = error instanceof Error ? error.message : 'Internal error'; + // Surface revert reasons (e.g. "authorization is used") to the client for debuggability + return res.status(500).json({ error: message.slice(0, 300) }); + } +} diff --git a/apps/gasless-usdc-payments/pages/index.tsx b/apps/gasless-usdc-payments/pages/index.tsx new file mode 100644 index 000000000..104467034 --- /dev/null +++ b/apps/gasless-usdc-payments/pages/index.tsx @@ -0,0 +1,180 @@ +import { useState } from 'react'; +import { createWalletClient, custom, parseUnits, isAddress } from 'viem'; +import { + NETWORKS, + NetworkKey, + usdcDomain, + TRANSFER_WITH_AUTHORIZATION_TYPES, + USDC_DECIMALS, +} from '../lib/eip3009'; + +const NETWORK_KEY = (process.env.NEXT_PUBLIC_NETWORK ?? 'base-sepolia') as NetworkKey; + +export default function Home() { + const [account, setAccount] = useState<`0x${string}` | null>(null); + const [recipient, setRecipient] = useState(''); + const [amount, setAmount] = useState(''); + const [status, setStatus] = useState(''); + const [txHash, setTxHash] = useState(''); + const [busy, setBusy] = useState(false); + + const network = NETWORKS[NETWORK_KEY]; + + async function connect() { + const ethereum = (window as any).ethereum; + if (!ethereum) { + setStatus('No wallet found. Install a browser wallet first.'); + return; + } + const [addr] = await ethereum.request({ method: 'eth_requestAccounts' }); + setAccount(addr); + setStatus(''); + } + + async function payGasless() { + if (!account) return; + if (!isAddress(recipient)) { + setStatus('Invalid recipient address.'); + return; + } + let value: bigint; + try { + value = parseUnits(amount, USDC_DECIMALS); + if (value <= 0n) throw new Error(); + } catch { + setStatus('Invalid amount.'); + return; + } + + setBusy(true); + setTxHash(''); + try { + setStatus('Requesting signature… (no gas needed)'); + const ethereum = (window as any).ethereum; + const walletClient = createWalletClient({ + chain: network.chain, + transport: custom(ethereum), + }); + + const now = Math.floor(Date.now() / 1000); + const nonce = ('0x' + + Array.from(crypto.getRandomValues(new Uint8Array(32))) + .map((b) => b.toString(16).padStart(2, '0')) + .join('')) as `0x${string}`; + + const message = { + from: account, + to: recipient as `0x${string}`, + value, + validAfter: 0n, + validBefore: BigInt(now + 600), // 10 minutes — well under the relay's 1h cap + nonce, + }; + + const signature = await walletClient.signTypedData({ + account, + domain: usdcDomain(network.chain.id, network.usdc, network.usdcName), + types: TRANSFER_WITH_AUTHORIZATION_TYPES, + primaryType: 'TransferWithAuthorization', + message, + }); + + setStatus('Relaying transaction…'); + const res = await fetch('/api/relay', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + from: message.from, + to: message.to, + value: message.value.toString(), + validAfter: message.validAfter.toString(), + validBefore: message.validBefore.toString(), + nonce, + signature, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? `Relay failed (${res.status})`); + + setTxHash(data.transactionHash); + setStatus('Transfer complete — gas paid by the relayer.'); + } catch (e) { + setStatus(e instanceof Error ? e.message : 'Something went wrong.'); + } finally { + setBusy(false); + } + } + + const explorer = network.chain.blockExplorers?.default.url; + + return ( +
+

Gasless USDC Payment

+

+ Sign an EIP-3009 authorization — the relayer submits it and pays gas. Network:{' '} + {network.chain.name} +

+ + {!account ? ( + + ) : ( + <> +

Connected: {account}

+ + + + + )} + + {status &&

{status}

} + {txHash && explorer && ( +

+ + View transaction + +

+ )} +
+ ); +} + +const btn: React.CSSProperties = { + padding: '10px 20px', + fontSize: 16, + borderRadius: 8, + border: 'none', + background: '#0052ff', + color: '#fff', + cursor: 'pointer', + marginTop: 12, +}; +const label: React.CSSProperties = { display: 'block', marginTop: 12, fontSize: 14 }; +const input: React.CSSProperties = { + display: 'block', + width: '100%', + padding: 8, + marginTop: 4, + fontSize: 14, + borderRadius: 6, + border: '1px solid #ccc', +}; diff --git a/apps/gasless-usdc-payments/tsconfig.json b/apps/gasless-usdc-payments/tsconfig.json new file mode 100644 index 000000000..df0d16bd3 --- /dev/null +++ b/apps/gasless-usdc-payments/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true + }, + "include": [ + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules" + ] +}