Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
6 changes: 6 additions & 0 deletions apps/gasless-usdc-payments/.env.example
Original file line number Diff line number Diff line change
@@ -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
56 changes: 56 additions & 0 deletions apps/gasless-usdc-payments/README.md
Original file line number Diff line number Diff line change
@@ -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 |
73 changes: 73 additions & 0 deletions apps/gasless-usdc-payments/lib/eip3009.ts
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions apps/gasless-usdc-payments/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = { reactStrictMode: true };
23 changes: 23 additions & 0 deletions apps/gasless-usdc-payments/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
111 changes: 111 additions & 0 deletions apps/gasless-usdc-payments/pages/api/relay.ts
Original file line number Diff line number Diff line change
@@ -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) });
}
}
Loading