Wallet signing with viem
This guide walks through signing a payment authorization that a facilitator can settle on-chain. It focuses on EVM + USDC using viem because that is the most common x402 stack today.
If you use wagmi, the same signTypedData action is available through the wagmi hook.
Background: EIP-3009
Section titled “Background: EIP-3009”Most EVM-based facilitators settle via transferWithAuthorization (EIP-3009). The payer signs an EIP-712 typed-data message off-chain; the facilitator submits it on-chain. The payer never submits a transaction themselves — gasless from the user’s perspective.
The typed-data struct:
| Field | Type | Meaning |
|---|---|---|
from | address | Payer wallet |
to | address | Merchant receive address (from the 402 body) |
value | uint256 | Amount in base units (6 decimals for USDC) |
validAfter | uint256 | Unix timestamp — authorization starts |
validBefore | uint256 | Unix timestamp — authorization expires |
nonce | bytes32 | Random 32-byte hex — prevents replay |
1. Parse the 402 response
Section titled “1. Parse the 402 response”When the server returns 402, the payment-required header contains base64-encoded JSON:
const res = await fetch("/api/premium");if (res.status !== 402) return res;
const encoded = res.headers.get("payment-required")!;const requirements = JSON.parse(atob(encoded));
const accept = requirements.accepts[0];// accept.payTo, accept.maxAmountRequired, accept.network, accept.asset2. Build and sign EIP-712 typed data
Section titled “2. Build and sign EIP-712 typed data”import { createWalletClient, custom, hexToBigInt } from "viem";import { base } from "viem/chains";
const walletClient = createWalletClient({ chain: base, transport: custom(window.ethereum!),});const [account] = await walletClient.requestAddresses();
const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";const nonce = `0x${crypto.getRandomValues(new Uint8Array(32)).reduce( (s, b) => s + b.toString(16).padStart(2, "0"), "")}` as `0x${string}`;
const now = Math.floor(Date.now() / 1000);
const signature = await walletClient.signTypedData({ account, domain: { name: "USD Coin", version: "2", chainId: base.id, verifyingContract: USDC_BASE, }, 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" }, ], }, primaryType: "TransferWithAuthorization", message: { from: account, to: accept.payTo as `0x${string}`, value: BigInt(accept.maxAmountRequired), validAfter: 0n, validBefore: BigInt(now + 3600), nonce, },});3. Retry with payment-signature
Section titled “3. Retry with payment-signature”Build a JSON payload containing everything the facilitator needs, base64-encode it, and send it back:
const payload = { x402Version: 1, scheme: "exact", network: accept.network, payload: { signature, authorization: { from: account, to: accept.payTo, value: accept.maxAmountRequired, validAfter: "0", validBefore: String(now + 3600), nonce, }, },};
const retry = await fetch("/api/premium", { headers: { "payment-signature": btoa(JSON.stringify(payload)), },});// retry.status should be 200 if the facilitator settled successfully- Nonce must be cryptographically random. Re-using a nonce lets the facilitator (or anyone who captured the signature) replay the transfer.
validBeforegives the authorization a TTL. One hour is generous for a single request; tighter windows reduce exposure.- USDC contract address differs per chain. Base mainnet:
0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913. Base Sepolia:0x036CbD53842c5426634e7929541eC2318f3dCF7e. - Domain
nameandversionmust match the token contract. USDC uses"USD Coin"/"2"on Base. Get these wrong and the signature is invalid. - Some facilitators accept the payload structure from the official
@x402/clientSDK. Verify against your facilitator’s docs.
Using with TallyPay
Section titled “Using with TallyPay”@tallypay/core’s wrapFetch automates the 402-detect → sign → retry loop. You supply the signing function:
import { wrapFetch } from "@tallypay/core";
const payFetch = wrapFetch(fetch, { onPaymentRequired: async (paymentRequired, url) => { // Build and sign as above, return the base64-encoded payload string return btoa(JSON.stringify(payload)); },});
const res = await payFetch("/api/premium");Next steps
Section titled “Next steps”- Express + React tutorial — server side of the flow
- Choosing a facilitator — endpoint differences between providers
- Debugging & pitfalls — common signing errors