Skip to content

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.

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:

FieldTypeMeaning
fromaddressPayer wallet
toaddressMerchant receive address (from the 402 body)
valueuint256Amount in base units (6 decimals for USDC)
validAfteruint256Unix timestamp — authorization starts
validBeforeuint256Unix timestamp — authorization expires
noncebytes32Random 32-byte hex — prevents replay

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.asset
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,
},
});

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.
  • validBefore gives 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 name and version must 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/client SDK. Verify against your facilitator’s docs.

@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");