uvd-x402-sdk
v2.99.1
Published
x402 Payment SDK - Gasless crypto payments across 29 networks via Ultravioleta facilitator. Supports EVM (including Scroll, SKALE Base, Robinhood Chain, Arc mainnet + testnet), Solana, Fogo, Stellar, NEAR, Algorand, Sui, XRP Ledger, and native Hedera main
Maintainers
Readme
uvd-x402-sdk
Gasless crypto payments across 29 blockchain networks using the x402 protocol.
Users sign a message or transaction, and the Ultravioleta facilitator handles on-chain settlement. No gas fees for users.
New in v2.94.0: EURC on Arc mainnet and testnet, six-decimal euro amounts and token-specific signatures. Gas remains USDC. Funded EURC payments confirmed on Arc mainnet (x402 v1 and v2, 2026-09-22); Arc testnet funded acceptance pending. Arc EURC guide.
New in v2.95.0: Hedera mainnet and testnet accept native USDC payments only, with offline signing and buyer/merchant helpers. HBAR funds sponsor network fees and is rejected as payment. Hedera guide. Arc USDC/EURC remains supported on mainnet and testnet.
Features
- 29 Networks: EVM (17 including Arc, Scroll, SKALE Base, Robinhood Chain), Solana, Fogo, Stellar, NEAR, Algorand, Sui, XRP Ledger, Hedera (mainnet + testnet)
- Multi-Stablecoin: USDC, EURC, AUSD, PYUSD, USDT, USDG (Robinhood Chain)
- x402 v1 & v2: Protocol auto-detection; native Hedera supports v2/exact only
- Gasless: Facilitator pays all network fees
- Buyer Policy: Per-payment and cumulative budgets, payee allowlist and offer expiry, evaluated against the offer in hand before signing — six closed refusal codes in a fixed order
- Type-Safe: Full TypeScript support
- React & Wagmi: First-class integrations
- Signing Wallet Adapters: EnvKeyAdapter (server/CLI), OWSWalletAdapter (Open Wallet Standard), or bring your own
- ERC-8128 Signed Requests: Authenticate HTTP requests with a wallet (RFC 9421 + EIP-191) — no API keys
- ERC-8004 Trustless Agents: On-chain reputation and identity across 23 networks (21 EVM + 2 Solana)
- Escrow & Refunds: Hold payments with dispute resolution
- Advanced Escrow: Full escrow lifecycle (authorize, release, refund, charge) with SigningWalletAdapter support
- Escrow Pre-Auth: Sign-on-assignment
X-Payment-Authbuilder (buildEscrowPreAuth) — vector-pinned parity with the Python SDK and Execution Market - Commerce Scheme:
'commerce'scheme alias for marketplace integrations (identical to'escrow'on-chain) /acceptsNegotiation: Discover facilitator capabilities before constructing payments- Bazaar Discovery: Register and discover paid resources across the x402 network
- Live Traffic Stream: Subscribe to
GET /events(SSE) for settlements as they happen — lossy live hint, not a ledger - Facilitator Info: Query version, supported networks, blacklist, and health
Installation
npm install uvd-x402-sdkPeer Dependencies
# EVM (included by default)
npm install ethers@^6
# Solana/Fogo
npm install @solana/web3.js @solana/spl-token
# Stellar
npm install @stellar/stellar-sdk @stellar/freighter-api
# NEAR
npm install @near-wallet-selector/core @near-wallet-selector/my-near-wallet
# Algorand
npm install algosdk lute-connect
# Sui
npm install @mysten/sui
# XRPL (XRP Ledger)
npm install xrplQuick Start
Server + Client (Private Key)
The fastest way to get up and running. No browser wallet needed — works in Node.js, scripts, and agents.
.env
RECEIVING_ADDRESS=0xYourWalletAddress
PRIVATE_KEY=0xYourPrivateKeyServer (Hono)
npm install hono @hono/node-server uvd-x402-sdk dotenvimport { Hono } from 'hono';
import { serve } from '@hono/node-server';
import { createHonoMiddleware } from 'uvd-x402-sdk';
import 'dotenv/config';
const app = new Hono();
const receiver = process.env.RECEIVING_ADDRESS as string;
// x402 payment middleware — handles 402, verify, and settle automatically
const paywall = createHonoMiddleware({
accepts: [{
network: 'skale-base',
asset: '0x85889c8c714505E0c94b30fcfcF64fE3Ac8FCb20',
amount: '1000000', // $1.00 USDC.e (6 decimals)
payTo: receiver,
extra: {
name: 'Bridged USDC (SKALE Bridge)',
version: '2',
},
}],
});
app.get('/api/free', (c) => c.json({ message: 'This endpoint is free!' }));
app.get('/api/premium', paywall, (c) => {
return c.json({ message: 'Payment verified and settled!', timestamp: new Date().toISOString() });
});
serve({ fetch: app.fetch, port: 3000 });
console.log('Server running on http://localhost:3000');Client (Private Key)
npm install uvd-x402-sdk ethers dotenvimport { X402Client } from 'uvd-x402-sdk';
import 'dotenv/config';
const client = new X402Client({ defaultChain: 'skale-base' });
await client.connectWithPrivateKey(process.env.PRIVATE_KEY as string);
const result = await client.createPayment({
recipient: process.env.RECEIVING_ADDRESS as string,
amount: '1.00',
});
const response = await fetch('http://localhost:3000/api/premium', {
headers: { 'X-PAYMENT': result.paymentHeader },
});
const data = await response.json();
console.log('Response:', data);This example uses SKALE Base (zero gas costs). Replace network, asset, and extra to use any supported chain — see Supported Networks.
EVM Chains (Browser Wallet)
import { X402Client } from 'uvd-x402-sdk';
const client = new X402Client({ defaultChain: 'base' });
const address = await client.connect('base');
const result = await client.createPayment({
recipient: '0x...',
amount: '10.00',
});
await fetch('/api/purchase', {
headers: { 'X-PAYMENT': result.paymentHeader },
});Solana
import { SVMProvider } from 'uvd-x402-sdk/solana';
import { getChainByName } from 'uvd-x402-sdk';
const svm = new SVMProvider();
const address = await svm.connect();
const chainConfig = getChainByName('solana')!;
const payload = await svm.signPayment({
recipient: '5Y32Dk6weq1LrMRdujpJyDbTN3SjwXGoQS9QN39WQ9Cq',
amount: '10.00',
}, chainConfig);
const header = svm.encodePaymentHeader(payload, chainConfig);Algorand
import { AlgorandProvider } from 'uvd-x402-sdk/algorand';
import { getChainByName } from 'uvd-x402-sdk';
const algorand = new AlgorandProvider();
const address = await algorand.connect(); // Lute or Pera wallet
const chainConfig = getChainByName('algorand')!;
const payload = await algorand.signPayment({
recipient: 'NCDSNUQ2QLXDMJXRALAW4CRUSSKG4IS37MVOFDQQPC45SE4EBZO42U6ZX4',
amount: '10.00',
}, chainConfig);
const header = algorand.encodePaymentHeader(payload, chainConfig);Algorand uses atomic transaction groups:
- Transaction 0: Fee payment (unsigned, facilitator signs)
- Transaction 1: USDC ASA transfer (signed by user)
Sui
import { SuiProvider } from 'uvd-x402-sdk/sui';
import { getChainByName } from 'uvd-x402-sdk';
const sui = new SuiProvider();
const address = await sui.connect(); // Sui Wallet
const chainConfig = getChainByName('sui')!;
const payload = await sui.signPayment({
recipient: '0x1234...', // 66-char Sui address
amount: '10.00',
}, chainConfig);
const header = sui.encodePaymentHeader(payload, chainConfig);Sui uses sponsored transactions:
- User creates and signs a programmable transaction block
- Facilitator sponsors gas (pays in SUI)
- User pays zero gas fees
XRPL (XRP Ledger)
import { XRPLProvider } from 'uvd-x402-sdk/xrpl';
import { getChainByName } from 'uvd-x402-sdk';
// Seed-based signer (Node.js / server-side)
const xrpl = new XRPLProvider({ seed: process.env.XRPL_SEED });
const address = await xrpl.connect(); // classic r-address
const chainConfig = getChainByName('xrpl-mainnet')!;
// Build + FULLY sign the Payment off-chain. Returns JSON: { signedTxBlob }
const payload = await xrpl.signPayment({
recipient: 'rfADKkVXBNqK3z72tVSS3LVzAR3psYkonp', // classic r-address
amount: '10.00',
}, chainConfig);
const header = xrpl.encodePaymentHeader(payload);XRPL uses the t54 "pre-signed Payment blob" scheme:
- The client builds and FULLY signs a native XRP
Paymentoff-chain (paying its own XRP fee) - Only one field is sent to the facilitator:
{ "signedTxBlob": "<hex tx blob>" } - The facilitator decodes the blob to re-derive payer/amount/destination and submits it
- The Payment sets
LastLedgerSequence, must NOT settfPartialPayment, and must NOT useSendMax - All payment-level fields (destination, amount, InvoiceID, SourceTag, Memo) come from the requirements
Requires the optional peer dependency
xrpl(npm install xrpl).
Stellar
import { StellarProvider } from 'uvd-x402-sdk/stellar';
import { getChainByName } from 'uvd-x402-sdk';
const stellar = new StellarProvider();
const address = await stellar.connect(); // Freighter wallet
const chainConfig = getChainByName('stellar')!;
const payload = await stellar.signPayment({
recipient: 'GD3FWQ4QFSCO2F2KVXZPQWOC27CQHXHYCRCRRZBMWU3DNOZW2IIGOU54',
amount: '10.00',
}, chainConfig);
const header = stellar.encodePaymentHeader(payload);NEAR
Important: The SDK's
NEARProvider.signPayment()only works with injected wallets (browser extensions). For browser-redirect wallets like MyNearWallet via@near-wallet-selector, you must use the popup flow below.
Option 1: Injected Wallet (Browser Extension)
import { NEARProvider } from 'uvd-x402-sdk/near';
import { getChainByName } from 'uvd-x402-sdk';
const near = new NEARProvider();
const accountId = await near.connect(); // MyNearWallet browser extension
const chainConfig = getChainByName('near')!;
const payload = await near.signPayment({
recipient: 'merchant.near',
amount: '10.00',
}, chainConfig);
const header = near.encodePaymentHeader(payload);Option 2: Browser-Redirect Wallet (Popup Flow) - Recommended
MyNearWallet via @near-wallet-selector is a browser-redirect wallet that requires a popup flow.
This is the recommended approach and works with the custom MyNearWallet deployment that supports NEP-366.
import { setupWalletSelector } from '@near-wallet-selector/core';
import { setupModal } from '@near-wallet-selector/modal-ui';
import { setupMyNearWallet } from '@near-wallet-selector/my-near-wallet';
import '@near-wallet-selector/modal-ui/styles.css';
// Configuration
const NEAR_CONFIG = {
usdcContract: '17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1',
recipientAccount: 'merchant.near',
// Custom MyNearWallet with NEP-366 signDelegateAction support
walletUrl: 'https://mynearwallet.ultravioletadao.xyz',
};
// Step 1: Initialize wallet selector
const selector = await setupWalletSelector({
network: 'mainnet',
modules: [
setupMyNearWallet({ walletUrl: NEAR_CONFIG.walletUrl }),
],
});
const modal = setupModal(selector, {
contractId: NEAR_CONFIG.usdcContract,
});
// Step 2: Connect wallet
modal.show(); // User selects wallet
// Wait for connection via selector.store.observable.subscribe()
const state = selector.store.getState();
const accountId = state.accounts[0].accountId;
// Step 3: Create payment with popup flow
async function createNearPayment(amount: string): Promise<string> {
const amountRaw = Math.floor(parseFloat(amount) * 1_000_000); // 6 decimals
// Get access key info and block height from RPC
const [accessKeyInfo, blockInfo] = await Promise.all([
getNearAccessKeyInfo(accountId),
getNearBlockHeight(),
]);
const nonce = accessKeyInfo.nonce + 1;
const maxBlockHeight = blockInfo.blockHeight + 1000; // ~17 minutes
// Build wallet URL for signDelegateAction
const popupUrl = new URL(NEAR_CONFIG.walletUrl);
popupUrl.pathname = '/sign-delegate-action';
popupUrl.searchParams.set('receiverId', NEAR_CONFIG.usdcContract);
popupUrl.searchParams.set('actions', JSON.stringify([{
methodName: 'ft_transfer',
args: {
receiver_id: NEAR_CONFIG.recipientAccount,
amount: amountRaw.toString(),
memo: 'x402 payment',
},
gas: '30000000000000', // 30 TGas
deposit: '1', // 1 yoctoNEAR
}]));
popupUrl.searchParams.set('callbackUrl', window.location.origin + '/near-callback');
popupUrl.searchParams.set('meta', JSON.stringify({
sender: accountId,
nonce,
maxBlockHeight,
publicKey: accessKeyInfo.publicKey,
}));
// Open popup
const popup = window.open(popupUrl.toString(), 'nearWallet', 'width=500,height=700');
if (!popup) throw new Error('Popup blocked. Please allow popups.');
// Wait for redirect with signedDelegateAction
const signedDelegateAction = await new Promise<string>((resolve, reject) => {
const checkInterval = setInterval(() => {
if (popup.closed) {
clearInterval(checkInterval);
reject(new Error('Wallet popup closed'));
return;
}
try {
const url = popup.location.href;
if (url.includes('signedDelegateAction=')) {
clearInterval(checkInterval);
popup.close();
const params = new URLSearchParams(new URL(url).search);
const errorCode = params.get('errorCode');
if (errorCode) {
reject(new Error(params.get('errorMessage') || errorCode));
return;
}
resolve(params.get('signedDelegateAction')!);
}
} catch { /* cross-origin, keep waiting */ }
}, 500);
setTimeout(() => {
clearInterval(checkInterval);
if (!popup.closed) popup.close();
reject(new Error('Popup timeout'));
}, 300000); // 5 min timeout
});
// Return x402 payload
return JSON.stringify({
signedDelegateAction,
network: 'near',
});
}
// Helper: Get access key info from NEAR RPC
async function getNearAccessKeyInfo(accountId: string) {
const rpcUrls = [
'https://near.drpc.org',
'https://rpc.mainnet.near.org',
];
for (const rpcUrl of rpcUrls) {
try {
const response = await fetch(rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 'dontcare',
method: 'query',
params: {
request_type: 'view_access_key_list',
finality: 'final',
account_id: accountId,
},
}),
});
const data = await response.json();
if (data.error) continue;
const fullAccessKey = data.result.keys.find(
(k: any) => k.access_key.permission === 'FullAccess'
);
return {
nonce: fullAccessKey.access_key.nonce,
publicKey: fullAccessKey.public_key,
};
} catch { continue; }
}
throw new Error('Failed to get NEAR access key info');
}
// Helper: Get block height from NEAR RPC
async function getNearBlockHeight() {
const rpcUrls = [
'https://near.drpc.org',
'https://rpc.mainnet.near.org',
];
for (const rpcUrl of rpcUrls) {
try {
const response = await fetch(rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 'dontcare',
method: 'block',
params: { finality: 'final' },
}),
});
const data = await response.json();
if (data.error) continue;
return { blockHeight: data.result.header.height };
} catch { continue; }
}
throw new Error('Failed to get NEAR block height');
}
// Step 4: Encode payment header
function encodeNearPaymentHeader(payload: string): string {
const parsed = JSON.parse(payload);
const x402Payload = {
x402Version: 1,
scheme: 'exact',
network: 'near',
payload: {
signedDelegateAction: parsed.signedDelegateAction,
},
};
return btoa(JSON.stringify(x402Payload));
}
// Usage
const payload = await createNearPayment('10.00');
const header = encodeNearPaymentHeader(payload);
await fetch('/api/purchase', {
headers: { 'X-PAYMENT': header },
});See 402milly's full implementation for a production-ready example.
Wagmi/RainbowKit
import { useWalletClient } from 'wagmi';
import { createPaymentFromWalletClient } from 'uvd-x402-sdk/wagmi';
function PayButton() {
const { data: walletClient } = useWalletClient();
const handlePay = async () => {
const paymentHeader = await createPaymentFromWalletClient(walletClient, {
recipient: '0x...',
amount: '10.00',
chainName: 'base',
});
await fetch('/api/purchase', {
headers: { 'X-PAYMENT': paymentHeader },
});
};
return <button onClick={handlePay}>Pay $10</button>;
}Signing Wallet Adapters
Low-level signing primitives for server-side agents, CLI tools, and Open Wallet Standard wallets. These adapters implement EIP-191, EIP-712, and EIP-3009 (gasless USDC transfers).
EnvKeyAdapter (Server / CLI / Agents)
Signs with a raw private key from the environment or constructor. Never use in browser contexts.
import { EnvKeyAdapter } from 'uvd-x402-sdk';
// Option 1: Reads process.env.WALLET_PRIVATE_KEY
const wallet = new EnvKeyAdapter();
// Option 2: Explicit key
const wallet = new EnvKeyAdapter(process.env.MY_AGENT_KEY!);
console.log(wallet.getAddress()); // 0x...
// Sign EIP-3009 gasless USDC transfer
const auth = await wallet.signEIP3009({
to: '0xRecipient...',
amountUsdc: 1.00,
network: 'base',
});
// auth contains: from, to, value, nonce, v, r, s, signature
// Sign arbitrary message (EIP-191)
const sig = await wallet.signMessage('Hello x402');
// Sign EIP-712 typed data
const result = await wallet.signTypedData(JSON.stringify({
domain: { name: 'MyApp', version: '1', chainId: 8453 },
types: { Order: [{ name: 'id', type: 'uint256' }] },
primaryType: 'Order',
message: { id: 42 },
}));OWSWalletAdapter (Open Wallet Standard)
Delegates signing to any wallet that implements the Open Wallet Standard. Works with browser wallets, agent vaults, and hardware-backed signers.
npm install @open-wallet-standard/core # optional peer dependencyimport { OWSWalletAdapter } from 'uvd-x402-sdk';
const wallet = new OWSWalletAdapter(owsWalletInstance);
const auth = await wallet.signEIP3009({
to: '0xRecipient...',
amountUsdc: 0.50,
network: 'base',
});Custom Adapter
Implement the SigningWalletAdapter interface for your own signer:
import type { SigningWalletAdapter, EIP3009Params, EIP3009Authorization } from 'uvd-x402-sdk';
class MyAdapter implements SigningWalletAdapter {
getAddress(): string { /* ... */ }
async signMessage(message: string): Promise<string> { /* ... */ }
async signTypedData(typedData: string): Promise<{ signature: string; v: number; r: string; s: string }> { /* ... */ }
async signEIP3009(params: EIP3009Params): Promise<EIP3009Authorization> { /* ... */ }
}ERC-8128 Signed Requests
Authenticate HTTP requests with a wallet instead of an API key. The SDK builds the RFC 9421 signature base, signs it with EIP-191 personal_sign, and produces the Signature, Signature-Input, and (for bodies) Content-Digest headers. Used by APIs that only accept wallet signing, like Execution Market.
Wire format is pinned by golden vectors (src/erc8128.vectors.json): alg="eip191", keyid always lowercase (erc8128:{chainId}:{address}), params in the order created;expires;nonce;keyid;alg.
import { createSignedFetch, EnvKeyAdapter } from 'uvd-x402-sdk';
// Auto-signing fetch: fetches a fresh nonce and signs every request
const signedFetch = createSignedFetch({
wallet: new EnvKeyAdapter(), // or privateKey: process.env.KEY!
apiBase: 'https://api.execution.market',
chainId: 8453, // Base (default)
});
const resp = await signedFetch('/api/v1/tasks', {
method: 'POST',
body: JSON.stringify({ title: 'test' }),
});For manual control, sign a single request (the nonce is single-use — fetch one per request):
import { fetchNonce, signRequestWithWallet, EnvKeyAdapter } from 'uvd-x402-sdk';
const wallet = new EnvKeyAdapter();
const nonce = await fetchNonce('https://api.execution.market');
const headers = await signRequestWithWallet(wallet, {
method: 'POST',
url: 'https://api.execution.market/api/v1/tasks',
body: '{"title":"test"}',
nonce,
});
// headers = { Signature, 'Signature-Input', 'Content-Digest' } — merge into your requestAlso available: signRequest (raw private key) and signRequestWithSigner (callback-based, for browser wallets / out-of-process signers where the key never leaves the signer), plus buildSignatureBase / buildSignatureParams to reproduce the exact signed bytes externally.
Escrow Pre-Auth (sign-on-assignment)
Build and sign the EIP-3009 ReceiveWithAuthorization that locks a bounty in the x402r AuthCaptureEscrow, packed as the raw-JSON X-Payment-Auth wrapper the facilitator's /settle expects. Used by marketplaces on the escrow rail (e.g. Execution Market's universal escrow).
The EIP-3009 nonce is AuthCaptureEscrow.getHash(paymentInfo), which includes the receiver — the signature cryptographically commits to the chosen worker, so it can only be created AT ASSIGNMENT. The wire format is pinned by golden vectors (src/escrow-preauth.vectors.json) shared with the Python SDK and Execution Market's dashboard/mobile suites.
import { buildEscrowPreAuth, EnvKeyAdapter } from 'uvd-x402-sdk';
// Escrow config as published by the marketplace server
// (e.g. Execution Market's GET /api/v1/h2a/payment-config).
const paymentAuth = await buildEscrowPreAuth(new EnvKeyAdapter(), {
networkConfig: config.escrow.networks.base,
payerWallet: '0xPublisher...',
workerWallet: '0xWorker...', // escrow receiver — committed by the nonce
bountyAtomic: '100000', // $0.10 in 6-decimal USDC
reviewDeadlineSec: taskDeadline, // release window outlasts it
});
// Send as the X-Payment-Auth header (raw JSON, NOT base64).Any SigningWalletAdapter works as the signer (only signTypedData is used); browser wallets can pass a minimal { signTypedData } wrapper. Validation fails loud instead of falling back: incomplete network config, unknown tier, bounty outside the on-chain deposit limit ($100), or a maxFeeBps below the operator's 1300 bps all throw before anything is signed. computeEscrowNonce is exported to reproduce AuthCaptureEscrow.getHash externally.
Multi-Stablecoin (EVM)
// Pay with EURC instead of USDC
const result = await client.createPayment({
recipient: '0x...',
amount: '10.00',
tokenType: 'eurc', // 'usdc' | 'eurc' | 'ausd' | 'pyusd' | 'usdt'
});
// Check token availability
import { getSupportedTokens, isTokenSupported } from 'uvd-x402-sdk';
getSupportedTokens('ethereum'); // ['usdc', 'eurc', 'ausd', 'pyusd']
getSupportedTokens('base'); // ['usdc', 'eurc']
isTokenSupported('base', 'eurc'); // trueValidity window (validitySeconds)
An EIP-3009 authorization can only be settled until validBefore = now + validitySeconds. A seller that settles after handing over the resource needs the authorization alive long enough for that settlement to land.
| Where | Scope | Default |
|---|---|---|
| X402ClientConfig.validitySeconds | every payment this client signs | 300 |
| PaymentInfo.validitySeconds | one payment; wins over the client | — |
| maxTimeoutSeconds in the seller's 402 | client.fetch() signs the seller's declared window | — |
import { X402Client, DEFAULT_VALIDITY_SECONDS, MAX_VALIDITY_SECONDS } from 'uvd-x402-sdk';
// Every payment from this client stays settleable for 10 minutes...
const client = new X402Client({ defaultChain: 'avalanche', validitySeconds: 600 });
// ...except this one, which the seller settles in batches
const result = await client.createPayment({
recipient: '0x...',
amount: '1.00',
validitySeconds: 1800,
});
DEFAULT_VALIDITY_SECONDS; // 300
MAX_VALIDITY_SECONDS; // 3600- A value that is not a whole number between 1 and
MAX_VALIDITY_SECONDSthrowsINVALID_CONFIG: from the constructor for the client config, and before anything is signed for a single payment. client.fetch()signs themaxTimeoutSecondsthe seller's 402 declares, clamped to 1–3600 s. When the 402 declares none, the client's window applies.- The facilitator keeps a 6 s clock-skew grace, so the payer really has
validitySeconds - 6seconds. It never rejects a window for being long. - Before 2.91.0 the window was 300 s on Base and 60 s on every other EVM network, and there was no way to change it.
AUSD on Solana (Token2022)
import { SVMProvider } from 'uvd-x402-sdk/solana';
import { getChainByName } from 'uvd-x402-sdk';
const svm = new SVMProvider();
const chainConfig = getChainByName('solana')!;
// AUSD uses Token2022 program
const payload = await svm.signPayment({
recipient: '5Y32Dk...',
amount: '10.00',
token: 'ausd', // Token2022 AUSD
}, chainConfig);
const header = svm.encodePaymentHeader(payload, chainConfig);Supported Networks
EVM (16)
| Network | Chain ID | Tokens | |---------|----------|--------| | Base | 8453 | USDC, EURC | | Ethereum | 1 | USDC, EURC, AUSD, PYUSD, USDT | | Polygon | 137 | USDC, AUSD | | Arbitrum | 42161 | USDC, AUSD, USDT | | Optimism | 10 | USDC, USDT | | Avalanche | 43114 | USDC, EURC, AUSD | | Celo | 42220 | USDC, USDT | | HyperEVM | 999 | USDC | | Unichain | 130 | USDC | | Monad | 143 | USDC, AUSD | | Scroll | 534352 | USDC | | SKALE Base | 1187947933 | USDC.e | | SKALE Base Sepolia | 324705682 | USDC.e | | Robinhood Chain | 4663 | USDG | | Robinhood Chain Testnet | 46630 | USDG | | Arc | 5042 | USDC, EURC | | Arc Testnet | 5042002 | USDC, EURC |
Arc mainnet and testnet: payment amounts use 6 decimals. USDC signs with the
USDC/2domain; EURC (0xbEf5…21c1mainnet,0x89B5…D72atestnet) signs withEURC/2and its amounts are euros, not dollars (tokenType: 'eurc',usdPegged: false). Native gas uses 18 decimals on the same balance. Usearc/eip155:5042for mainnet andarc-testnet/eip155:5042002for testnet. See Arc usage and validation.
Robinhood Chain / USDG: Robinhood Chain has no USDC — the settlement stablecoin is Paxos USDG (Global Dollar, 6 decimals, EIP-3009). Its on-chain
version()getter reverts, so the EIP-712 domain{ name: "Global Dollar", version: "1" }can never be resolved on-chain. The SDK carries this domain in the chain config; when constructingPaymentRequirementsyourself, send it inextra:{ "name": "Global Dollar", "version": "1" }. UsetokenType: 'usdg'(or the default, which resolves to USDG on these networks).
SVM (2)
| Network | Tokens | Wallet | |---------|--------|--------| | Solana | USDC, AUSD | Phantom | | Fogo | USDC | Phantom |
Algorand
| Network | USDC ASA | Wallet | |---------|----------|--------| | Algorand | 31566704 | Lute, Pera |
Sui
| Network | Tokens | Wallet | |---------|--------|--------| | Sui | USDC, AUSD | Sui Wallet |
XRPL
XRP Ledger settles in native XRP (6 decimals / drops) using the t54 pre-signed Payment blob scheme. The client builds and fully signs the Payment off-chain and sends { signedTxBlob } to the facilitator. There is no stablecoin/token contract on XRPL. Use network ids xrpl-mainnet and xrpl-testnet. See XRPL (XRP Ledger) above for the uvd-x402-sdk/xrpl provider usage.
| Network | Asset | Network ID | Provider |
|---------|-------|------------|----------|
| XRP Ledger | XRP (native) | xrpl-mainnet | uvd-x402-sdk/xrpl |
| XRP Ledger Testnet | XRP (native) | xrpl-testnet | uvd-x402-sdk/xrpl |
Other
| Network | Wallet | |---------|--------| | Stellar | Freighter | | NEAR | MyNearWallet |
Facilitator Addresses
The SDK includes built-in facilitator addresses. You don't need to configure them.
import { FACILITATOR_ADDRESSES, getFacilitatorAddress } from 'uvd-x402-sdk';
// Built-in addresses
FACILITATOR_ADDRESSES.evm; // 0x103040545AC5031A11E8C03dd11324C7333a13C7
FACILITATOR_ADDRESSES.solana; // F742C4VfFLQ9zRQyithoj5229ZgtX2WqKCSFKgH2EThq
FACILITATOR_ADDRESSES.algorand; // KIMS5H6QLCUDL65L5UBTOXDPWLMTS7N3AAC3I6B2NCONEI5QIVK7LH2C2I
FACILITATOR_ADDRESSES.stellar; // GCHPGXJT2WFFRFCA5TV4G4E3PMMXLNIDUH27PKDYA4QJ2XGYZWGFZNHB
FACILITATOR_ADDRESSES.near; // uvd-facilitator.near
FACILITATOR_ADDRESSES.sui; // 0xe7bbf2b13f7d72714760aa16e024fa1b35a978793f9893d0568a4fbf356a764a
FACILITATOR_ADDRESSES['xrpl-mainnet']; // rfADKkVXBNqK3z72tVSS3LVzAR3psYkonp
// Or get by chain name
getFacilitatorAddress('algorand'); // KIMS5H6...
getFacilitatorAddress('base', 'evm'); // 0x1030...
getFacilitatorAddress('sui'); // 0xe7bbf...
getFacilitatorAddress('xrpl-mainnet'); // rfADKk...Backend
import {
FacilitatorClient,
create402Response,
extractPaymentFromHeaders,
buildPaymentRequirements,
} from 'uvd-x402-sdk/backend';
// Return 402 if no payment
app.post('/api/premium', async (req, res) => {
const payment = extractPaymentFromHeaders(req.headers);
if (!payment) {
const { status, headers, body } = create402Response({
amount: '1.00',
recipient: process.env.RECIPIENT,
resource: 'https://api.example.com/premium',
chainName: 'base',
});
return res.status(status).set(headers).json(body);
}
// Verify first, then settle when you're ready to fulfill the request
const client = new FacilitatorClient();
const requirements = buildPaymentRequirements({
amount: '1.00',
recipient: process.env.RECIPIENT,
resource: 'https://api.example.com/premium',
chainName: 'base',
x402Version: payment.x402Version,
});
const verifyResult = await client.verify(payment, requirements);
if (!verifyResult.isValid) {
return res.status(402).json({ error: verifyResult.invalidReason });
}
const settleResult = await client.settle(payment, requirements);
if (!settleResult.success) {
return res.status(500).json({ error: settleResult.error });
}
res.json({ data: 'premium content', txHash: settleResult.transactionHash });
});createPaymentMiddleware() and createHonoMiddleware() verify and settle automatically by default (before-handler). Use settlementStrategy: 'manual' if you need to control when settlement happens (e.g., settle only after confirming you can fulfill the request).
React
import { X402Provider, useX402, usePayment } from 'uvd-x402-sdk/react';
function App() {
return (
<X402Provider config={{ defaultChain: 'base' }}>
<PaymentPage />
</X402Provider>
);
}
function PaymentPage() {
const { connect, isConnected, address } = useX402();
const { pay, isPaying } = usePayment();
if (!isConnected) {
return <button onClick={() => connect('base')}>Connect</button>;
}
return (
<button onClick={() => pay({ recipient: '0x...', amount: '10.00' })} disabled={isPaying}>
{isPaying ? 'Processing...' : 'Pay $10'}
</button>
);
}Error Handling
import { X402Error } from 'uvd-x402-sdk';
try {
await client.createPayment(paymentInfo);
} catch (error) {
if (error instanceof X402Error) {
switch (error.code) {
case 'WALLET_NOT_FOUND': // Install wallet
case 'WALLET_CONNECTION_REJECTED': // User rejected
case 'INSUFFICIENT_BALANCE': // Not enough USDC
case 'SIGNATURE_REJECTED': // User cancelled
case 'CHAIN_NOT_SUPPORTED': // Unsupported network
}
}
}Buyer policy — what this buyer is allowed to sign, decided before it signs
A catalog listing is a claim somebody else made about their own price. The 402
that comes back from the actual request is the offer, and they can differ
legitimately: a seller may have repriced, and the listing may be a copy of a
copy. So the buying decision is made against the offer in hand, every time,
before anything is signed. Same contract the facilitator fixed in Rust
(x402-reqwest, release 2.25.0), so a buyer in either language refuses the same
payments for the same stated reasons.
import { X402Client, PurchasePolicy, PolicyRefusedError } from 'uvd-x402-sdk';
const USDC_BASE = {
network: 'base',
address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
};
// create() DENIES any asset it was not given a ceiling for.
const policy = PurchasePolicy.create()
.perPayment(USDC_BASE, 50_000n) // 0.05 USDC, atomic units, bigint
.cumulative(USDC_BASE, 1_000_000n) // 1 USDC total, for this policy's life
.onlyPay(['0xe4dc963c56979E0260fc146b87eE24F18220e545']);
const client = new X402Client({ defaultChain: 'base', policy });
await client.connectWithPrivateKey(process.env.PRIVATE_KEY!, 'base');
try {
const res = await client.fetch('https://api.example.com/data', {
// What the catalog advertised, if you read one. It is REPORTED, never a gate.
advertised: { asset: USDC_BASE, amount: 10_000n },
// Evaluating does not spend. This is where a settled payment gets recorded.
onPaid: (approval) => client.policy.recordSpend(approval.asset, approval.amount),
});
const data = await res.json();
} catch (err) {
if (err instanceof PolicyRefusedError) {
switch (err.refusal.code) {
case 'offer-expired': // ask the seller for new terms
case 'asset-not-budgeted': // budget that asset
case 'per-payment-limit': // one payment is too big
case 'cumulative-limit': // the budget is spent
case 'recipient-not-permitted': // not a payee you allowed
case 'no-readable-offer': // err.refusal.offered names the schemes
}
}
}Nothing changes for a caller who never writes a policy. A client without one
holds PurchasePolicy.permissive(): this SDK had no budget before 2.89.0 and
switching one on silently would refuse payments consumers are making today. The
asymmetry is deliberate — whoever sits down to write a policy gets the
deny-by-default one. maxAmount is untouched and still runs before the policy.
The fields
| field | type | meaning |
|---|---|---|
| perPayment(asset, amount) | bigint, atomic units | most this policy pays in ONE payment of that asset |
| cumulative(asset, amount) | bigint, atomic units | most it pays in that asset in TOTAL, for as long as it lives |
| spent(asset) | bigint | what has been recorded; only recordSpend moves it |
| onlyPay([...]) | addresses | permitted recipients, canonicalised by family |
| allowUnlistedAssets() | boolean, false by default | whether an asset with no declared ceiling may be paid |
An asset is a { network, address } pair, and both halves matter: the same
contract address on two networks is two different assets. Use the SDK chain name
('base'), not the CAIP-2 string — the client resolves a v2 challenge's
eip155:8453 to it, so one written policy covers both 402 dialects.
The order of evaluation is part of the contract
The FIRST failing check is the one reported, because a caller branches on it.
Reporting per-payment-limit for an expired offer to a payee nobody allowed
would tell the caller to raise a ceiling when the real fix is to ask the seller
for new terms.
no-readable-offer → offer-expired → recipient-not-permitted
→ asset-not-budgeted → per-payment-limit → cumulative-limitThe six are a closed kebab-case vocabulary (PolicyRefusalCode), typed as a
literal union so you branch without parsing English. There is no other: a
refusal a caller cannot interpret is one it will paper over. Each carries the
numbers that caused it — requested, allowed, spent, wouldTotal, asset,
payTo, validUntil, now, offered[].
asset-not-budgeted runs before the ceilings on purpose. The ceilings are a
map, and a map has no opinion about a key it does not hold — which is exactly how
an unlisted token sails past a budget that looks complete. And the EVM signer
would have signed it: it takes its EIP-712 domain from the seller's own extra,
for a token and a network it has never seen.
The seven rules
- Evaluating does not spend. Signing can fail and a settlement can be
refused; a limit that counted attempts would lock you out of money you never
spent.
recordSpendis a separate call, after the settlement resolved — useonPaidfor it. - A policy is never widened from inside an evaluation. No method raises a ceiling: every builder returns a NEW policy and leaves the receiver exactly as strict as it was.
- No human confirmation when the policy already covers the operation. A divergence from the listing is not, by itself, a refusal: an offer that costs more than the catalog said but sits inside an authorised policy is paid. Halting there would turn every ordinary reprice into a stop, and an agent that halts on ordinary commerce is one nobody can leave running. There is no confirmation hook on this path.
- A different asset is not the same price. No numbers are compared across assets. An asset with no declared ceiling is denied by default; the permissive mode has to be asked for by name.
- A network name's case never decides anything (
'Base'and'base'are one network written twice), and addresses are canonicalised by family, never withtoLowerCase(). Hex is folded; base58 (Solana, XRPL) is compared exactly. Folding a base58 address does not produce the same address spelled differently — it produces a string that is not an address, so an allowlist written in the seller's own spelling would never match. And in the dangerous direction, two distinct base58 addresses can fold to the same lowercase string, letting in one nobody listed. validUntilis read fromextensions["offer-receipt/1"].info.validUntil, in Unix seconds. Absent means no declared expiry. Unreadable means absent, never zero: "the seller said something we could not read" must not become "this offer expired in 1970".validUntil === nowstill stands — it is the last instant the offer is up. And anacceptswith one unreadable entry keeps the readable ones and counts the others by scheme name, so a refusal says what the seller offered:offered: ["batch-settlement","agent-pay"]. Discovering a service keeps working even when buying it automatically does not.
Two more worth knowing: a copy of a policy spends from the same purse (a client is copied per request, and a per-copy total would make a cumulative limit meaningless), and a corrupt purse reports the ceiling, never zero — for money the safe direction is to refuse.
The scheme decides, and so does the asset
Two checks sit beside the policy on the buyer path, because approving a payment you cannot honestly present is not an approval:
Schemes. KNOWN_SCHEMES is the vocabulary shared with the Rust facilitator's
closed Scheme enum and the Python SDK: exact, upto, escrow, commerce,
fhe-transfer. CLIENT_PAYABLE_SCHEMES is the subset this buyer path can sign —
exact alone, because the payload builder stamps scheme: 'exact' into everything
it produces. Recognising a scheme is not being able to pay it: a well-formed
escrow offer read as payable would be signed as exact, offering the seller a
payment under a scheme it never asked for. Either way the entry is excluded and
counted by its scheme name, which is what lets a refusal say where to go look.
A missing scheme is unreadable, not exact. Rust requires the field, and a
buyer that guessed would sign under a scheme the seller never named. This is
deliberately asymmetric with the seller side of this SDK, where a missing scheme
reads as exact: a seller is lenient about what it accepts, a buyer is strict about
what it signs.
Assets. The policy judges the offer's own asset, but the signature is built for
whatever tokenType resolves to on that chain, at that token's decimals. With USDC on
both sides they coincide. They do not have to — so an offer naming a different token
is refused rather than silently re-pointed, because which token to pay with is your
decision and guessing it from the seller's 402 is how a wallet signs for a token
nobody chose. Pass the tokenType that matches the offer.
Deciding without a network stack
decideOnChallenge(policy, challenge, offer, { now }) runs all six steps and is
callable directly, which is the point: a decision that can only be exercised by
driving a real HTTP client is a decision nobody tests. It takes the challenge
WHOLE — passing the offers alone is exactly what dropped a seller's validUntil
on the floor in Rust for a full commit with every unit test green. now is
passed rather than read, so a money decision can be pinned to an exact instant.
503 is not 402 — read the refusal before you re-sign
402 and 503 say opposite things, and the difference is the buyer's money:
| status | meaning | what the caller must do |
|---|---|---|
| 402 | the payment was rejected | sign a new authorization |
| 503 | no verdict was reached | resend the same credential |
| 502 upstream_rpc_unavailable | the node never answered; nothing was broadcast | resend the same credential |
| 502 settlement_unconfirmed | broadcast, and it may be mined | send nothing — look up the hash |
Charging a 503 as a 402 makes the buyer sign and broadcast a second payment
for money that was never refused — and the first authorization is still
perfectly spendable. Every facilitator edge in this SDK therefore reports the
refusal as data:
const result = await client.settle(payment, requirements);
if (!result.success) {
if (result.retryable) {
// Nothing was rejected. Do NOT ask for another signature.
// result.status -> 503
// result.reason -> 'holder_unknown' | 'forward_failed' | ...
// result.retryAfterSeconds -> already clamped, never an hour
// result.safeToReplay -> true only if the facilitator proved nothing ran
} else if (isSettlementUnconfirmed(result)) {
// NOT a refusal: the transfer was broadcast and may already be mined.
// Reconcile — never re-send.
// result.transaction -> the hash, in that chain's own encoding
// result.paymentId -> the same id a successful settle would have printed
} else {
// A real refusal. result.errorReason says why.
}
}The same fields appear on verify(), verifyAndSettle(), every Erc8004Client
write, and the gasless escrow calls. Erc8004LookupError carries them as
getters.
Why reason matters: the five are not interchangeable
The facilitator serialises every EVM write through one process — they share a
gas wallet whose nonce is allocated in memory. A task that does not hold that
lease forwards the write; when it cannot, it answers 503 + Retry-After: 5 +
a reason.
| reason | did the write run? | replay the same request? |
|---|---|---|
| holder_unknown | no | yes |
| forwarding_disabled | no | yes |
| forwarded_but_not_writer | no | yes |
| body_unreadable | no | yes |
| forward_failed | maybe | no |
forward_failed is emitted after the write was handed over: the holder may
have executed it and the response been lost coming back. It is a timeout wearing
a status code. The SDK replays the first four automatically and never that one —
resolve it by reading state, with getIdentityByOwner (respecting its
404-vs-503 distinction) or getRegisterStatus. Re-POSTing an ambiguous mint is
what once created five duplicate agents.
Automatic replay is bounded and configurable:
new FacilitatorClient({ retries: 0 }); // never replay; default is 2 extra attemptsRetry-After is honoured only up to MAX_RETRY_AFTER_SECONDS (15). A
misconfigured facilitator answering Retry-After: 3600 would otherwise hang the
request for an hour.
The two 502s mean opposite things — branch on the body, not the status
POST /settle answers 502 for two situations that call for opposite moves:
| body error | Retry-After | did the money move? | retry? |
|---|---|---|---|
| upstream_rpc_unavailable | 30 | no, nothing was broadcast | yes |
| settlement_unconfirmed | absent | maybe — it may be mined | never |
settlement_unconfirmed is emitted when the transaction went out and no receipt
ever came back. Retrying it re-signs a new authorization with a fresh
nonce, which the chain accepts as a second, perfectly valid payment for the
same purchase — the buyer pays twice, in exactly the case the facilitator emits
this error to prevent. authorizationState does not stop it: the second
authorization is genuinely new.
So the status alone cannot decide. This SDK reads the body:
import { isSettlementUnconfirmed, SETTLEMENT_UNCONFIRMED } from 'uvd-x402-sdk';
const result = await client.settle(payment, requirements);
if (!result.success && isSettlementUnconfirmed(result)) {
// result.retryable -> false. Do not resend, and do not ask for a signature.
// result.transaction -> what to look up on chain
// result.paymentId -> matches the id a successful settle prints, so a
// payment later found confirmed reconciles cleanly
await reconcileOnChain(result.transaction);
}result.transaction is not always 0x-prefixed — Algorand prints base32 and
Solana base58. Pass it through verbatim; reformatting it makes it unpasteable in
an explorer, and pasting it is the entire remedy on offer.
The same reading applies to Erc8004LookupError (POST /register goes through
the same EVM path, so a mint can come back unconfirmed too) and to every gasless
escrow call. An explicit retryable: false in a facilitator body always wins
over the status — but only ever downgrades: a body claiming retryable: true
on a 402 will not make this SDK resend a genuinely refused credential. The one
exception is named, not read from a flag: 409 authorization_in_flight is
retryable (see Portable facilitator receipts).
Three independent signals stop a retry, because the cost of missing one is a
second payment: the explicit retryable: false, the named
settlement_unconfirmed, and — the general rule — any 5xx body carrying a
transaction hash at all, under any of transaction, transaction.hash,
txHash, tx_hash or transaction_hash. A hash in a failure means the
facilitator got as far as broadcasting, whatever it called the error, so that
last rule holds for codes that do not exist yet. It is the Python SDK's
anti-double-settle guard, adopted here.
Middleware
createPaymentMiddleware and createHonoMiddleware answer 503 with a
Retry-After header — not 402, not 500 — whenever the facilitator reached
no verdict, and keep answering 402 for genuine rejections.
An unconfirmed settlement is the one 5xx that goes out as 500, with no
Retry-After: "stop" is the correct instruction when the transfer may already
be mining. The body carries transaction, paymentId and retryable: false, so
the buyer's client can reconcile instead of paying again.
An X-PAYMENT the facilitator already admitted for another request is answered
409 (authorization_already_settled, receipt_request_conflict: it was
used, the handler does not run) or 503 + Retry-After while it is
authorization_in_flight — never 402 and never 500. Both middlewares add
PAYMENT-RESPONSE to an existing Access-Control-Expose-Headers and no-store
to an existing Cache-Control instead of replacing them, and in 'manual' mode
a settle() after the handler already answered no longer touches the sent
response.
ERC-8004 Trustless Agents
Build verifiable on-chain reputation for AI agents and services. Supports 23 networks (21 EVM + 2 Solana).
Name Base as
'base'. The old'base-mainnet'spelling is rejected by the facilitator (400 Invalid network); the SDK now rewrites it for you, but new code should use'base'.
On EVM networks, agent IDs are sequential numbers. On Solana, agent IDs are base58 pubkey strings. The AgentId type (number | string) handles both.
EVM Networks (21)
ethereum, base, polygon, arbitrum, optimism, celo, bsc, monad, avalanche, scroll, skale-base, arc, ethereum-sepolia, base-sepolia, polygon-amoy, arbitrum-sepolia, optimism-sepolia, celo-sepolia, avalanche-fuji, skale-base-sepolia, arc-testnet
Arc (arc, arc-testnet) since 2.98.0, with the canonical registries on both; see
ERC-8004 on Arc.
Solana Networks (2)
solana, solana-devnet
Usage
import { Erc8004Client, AgentId } from 'uvd-x402-sdk/backend';
const erc8004 = new Erc8004Client();
// EVM: agent ID is a number
const identity = await erc8004.getIdentity('ethereum', 42);
console.log(identity.agentUri);
// Solana: agent ID is a base58 pubkey string
const solIdentity = await erc8004.getIdentity('solana', '8oo4dC4JvBLwy5...');
console.log(solIdentity.agentUri);
// Look up agent by wallet owner address
const byOwner = await erc8004.getIdentityByOwner('base-mainnet', '0xOwnerAddress...');
console.log(byOwner.agentId, byOwner.identity.agentUri);
// Get agent reputation
const reputation = await erc8004.getReputation('ethereum', 42);
console.log(`Score: ${reputation.summary.summaryValue}`);
// Submit feedback after payment
const result = await erc8004.submitFeedback({
x402Version: 1,
network: 'ethereum',
feedback: {
agentId: 42,
value: 95,
valueDecimals: 0,
tag1: 'quality',
proof: settleResponse.proofOfPayment,
},
});
// Respond to feedback (agents only)
// sealHash is required for Solana, optional for EVM
await erc8004.appendResponse('ethereum', 42, 1, 'Thank you for your feedback!');Ratings the chain attributes to the rater
submitFeedback() above works, but the registry records msg.sender as the
author -- and on that route msg.sender is the facilitator. It is why 87,2%
of the reputation on Base (1.384 of 1.587 feedbacks) is attributed to one
wallet, which can also revoke it.
EIP-7702 fixes it without touching the registry: the rater delegates their own
EOA to a FeedbackDelegate, and the transaction is sent to the rater's
address, so the registry sees the rater while the facilitator still pays the
gas.
import { Erc8004Client, supportsRelayedFeedback } from 'uvd-x402-sdk/backend';
const erc8004 = new Erc8004Client();
if (!supportsRelayedFeedback('base')) {
// fall back to submitFeedback(); the facilitator is the author there
}
const prep = await erc8004.prepareRelayedFeedback({
x402Version: 1,
network: 'base',
feedback: {
agentId: 18896,
value: 95,
tag1: 'quality',
rater: raterAddress, // who the chain will record as the author
},
});
// 1. Sign with the RATER's key. WHICH value you sign depends on HOW you sign
// it — get this wrong and you produce a well-formed signature that
// authorises nobody, and the only symptom is `relay_bad_signature`.
//
// `prep.digest` already carries the EIP-191 envelope. A raw key signs it as
// a prehash; a wallet's personal_sign would add the envelope a SECOND time,
// so wallets sign `prep.signingPayload` instead.
const signature = await account.sign({ hash: prep.digest! }); // raw key
// ...or, from a browser wallet:
// const signature = await walletClient.signMessage({
// account, message: { raw: prep.signingPayload! },
// });
// 2. Only the first time this rater rates: point their EOA at the delegate.
const authorization = prep.delegated
? undefined
: {
chainId: prep.chainId, // 0 is EIP-7702's wildcard: valid on every chain
address: prep.delegate!,
nonce: prep.accountNonce!,
...(await signAuthorization(prep.chainId, prep.delegate!, prep.accountNonce!)),
};
const result = await erc8004.submitRelayedFeedback({
x402Version: 1,
network: 'base',
feedback: { agentId: 18896, value: 95, tag1: 'quality', rater: raterAddress },
deadline: prep.deadline!, // short by design; past it, refused
nonce: prep.nonce!,
signature,
authorization,
});Pass the same feedback parameters, deadline and nonce back to
submitRelayedFeedback(). They are not redundant: the facilitator rebuilds the
registry calldata from them and refuses to relay anything the rater's signature
does not cover.
Available on the ten networks in RELAYED_FEEDBACK_NETWORKS -- the nine
mainnets with a deployed FeedbackDelegate (base, ethereum, polygon, arbitrum,
optimism, celo, bsc, monad, arc) plus base-sepolia. Avalanche is not one of them
and is not waiting to become one: its C-Chain rejects the transaction type
itself (-32000 transaction type not supported), so anchor the rating on a
chain that supports EIP-7702 -- the payment stays where it was made.
arc-testnet serves ERC-8004 reads but not this rail: no delegate is deployed
there, and prepare answers 400.
Requires facilitator v1.93.0+ for the mainnets (2.38.0+ for arc); base-sepolia since v1.74.0.
The same thing on Solana, without a delegate
Solana reaches the same place by a shorter road, so it is a different pair of
calls and a different network list. The program's give_feedback instruction
already declares account 0 as [signer, writable] client (feedback author), and
a Solana transaction carries several signatures natively: the rater signs as
client, the facilitator co-signs as fee payer. Nothing is delegated because
nothing has to be.
solana never goes in RELAYED_FEEDBACK_NETWORKS. That list drives
/feedback/evm/* and names chains with a deployed FeedbackDelegate; a Solana
rating sent there is a 400, and the entry would assert a delegate that was never
deployed and is not missing. Use SOLANA_FEEDBACK_NETWORKS /
supportsSolanaFeedback().
import { Erc8004Client, supportsSolanaFeedback } from 'uvd-x402-sdk/backend';
import { Transaction } from '@solana/web3.js';
const erc8004 = new Erc8004Client();
if (!supportsSolanaFeedback('solana')) {
// fall back to submitFeedback(); the facilitator is the author there
}
const prep = await erc8004.prepareSolanaFeedback({
x402Version: 1,
network: 'solana',
feedback: {
agentId: '7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgHkv', // the agent asset
rater: raterPubkey, // base58: who the chain will record as the author
value: 87,
valueDecimals: 0,
score: 95, // WITHOUT THIS the rating counts for nothing
tag1: 'quality',
},
});
// Sign the message as it came. Re-encoding it changes bytes the facilitator
// will compare, and it refuses to co-sign anything it did not build. It is a
// LEGACY transaction: `Transaction.from()`, not `VersionedTransaction`.
const tx = Transaction.from(Buffer.from(prep.transaction!, 'base64'));
tx.partialSign(raterKeypair); // ...or a wallet: await wallet.signTransaction(tx)
const result = await erc8004.submitSolanaFeedback({
x402Version: 1,
network: 'solana',
feedback: { /* exactly what went to prepare, rater included */ },
transaction: tx.serialize({ requireAllSignatures: false }).toString('base64'),
});prep.feePayer is the facilitator and prep.rater is the rater — that split is
the whole point. Submit before prep.lastValidBlockHeight: past it the network
drops the transaction, nothing is written and nothing is charged, and a resend
needs a fresh prepare() because the blockhash the rater signed over is gone.
Set score. It is optional on the wire and the ATOM Engine ignores an
unscored feedback: the transaction succeeds, the record lands on the agent, and
reputation stays at zero (had_impact=false) — and it is not retroactive.
Available on solana and solana-devnet. Requires facilitator v2.16.0+.
/accepts Negotiation
Discover what the facilitator can settle before constructing payment authorizations. Used by Faremeter middleware and clients.
import { FacilitatorClient } from 'uvd-x402-sdk/backend';
const client = new FacilitatorClient();
// Ask facilitator what it can settle
const enriched = await client.accepts([
{
scheme: 'exact',
network: 'base-mainnet',
maxAmountRequired: '1000000',
resource: 'https://api.example.com/data',
payTo: '0xMerchant...',
},
]);
// enriched[0].extra now has feePayer, tokens, escrow configEscrow & Refunds
Hold payments in escrow with dispute resolution.
import { EscrowClient } from 'uvd-x402-sdk/backend';
const escrow = new EscrowClient();
// Create escrow payment
const escrowPayment = await escrow.createEscrow({
paymentHeader: req.headers['x-payment'],
requirements: paymentRequirements,
escrowDuration: 86400, // 24 hours
});
// Release after service delivery
await escrow.release(escrowPayment.id);
// Or request refund if service failed
await escrow.requestRefund({
escrowId: escrowPayment.id,
reason: 'Service not delivered',
});
// Query on-chain escrow state
const state = await escrow.getEscrowState({
network: 'base-mainnet',
payer: '0xPayer...',
recipient: '0xRecipient...',
nonce: '0x1234...',
});Advanced Escrow (AdvancedEscrowClient)
Full escrow lifecycle management for EVM chains. Supports both ethers.Signer and SigningWalletAdapter (EnvKey, OWS) for signing.
Supported on 10 EVM networks: Base, Base Sepolia, Ethereum, Ethereum Sepolia, Polygon, Arbitrum, Optimism, Celo, Monad, Avalanche. Since 2.99.0 also on Arc and Arc Testnet, on the x402r canonical escrow: there release sends capture and refundInEscrow sends void, which only returns the whole capturable amount. See Escrow on Arc.
With Private Key (ethers.Signer)
import { AdvancedEscrowClient } from 'uvd-x402-sdk/backend';
const client = new AdvancedEscrowClient(process.env.PRIVATE_KEY!, {
chainId: 8453, // Base
});
await client.init();
// Build payment info
const paymentInfo = client.buildPaymentInfo(
'0xWorkerAddress...', // receiver
'5000000', // amount in atomic units ($5.00 USDC)
'standard', // tier: 'standard' | 'express' | 'premium'
);
// Authorize: lock funds in escrow
const auth = await client.authorize(paymentInfo);
// Release: capture escrowed funds to receiver
await client.release(paymentInfo);
// Or refund: return escrowed funds to payer
await client.refundInEscrow(paymentInfo);
// Query escrow state on-chain
const state = await client.queryEscrowState(paymentInfo);With SigningWalletAdapter (OWS)
import { AdvancedEscrowClient } from 'uvd-x402-sdk/backend';
import { OWSWalletAdapter } from 'uvd-x402-sdk';
const wallet = new OWSWalletAdapter(owsWalletInstance);
const client = new AdvancedEscrowClient(null, {
wallet,
rpcUrl: 'https://mainnet.base.org',
chainId: 8453,
});
await client.init();
const paymentInfo = client.buildPaymentInfo('0xWorker...', '5000000', 'standard');
const auth = await client.authorize(paymentInfo);Gasless Operations via Facilitator
Release and refund can be executed through the facilitator (no gas required):
// Gasless release
await client.releaseViaFacilitator(paymentInfo);
// Gasless refund
await client.refundViaFacilitator(paymentInfo);Recovering an EXPIRED escrow
A release attempted after authorizationExpiry reverts with
AfterAuthorizationExpiry. It is widely believed — and this SDK's own comments
said so until now — that the funds are then movable only by the payer's
reclaim(). That is false, and it is why stuck escrows were written off.
From AuthCaptureEscrow.sol:
partialVoidisonlySender(paymentInfo.operator)— the operator is the facilitator, not the payer — it sends the tokens to the payer, and it never readsauthorizationExpiry. It works before expiry and after it.reclaimisonlySender(paymentInfo.payer)and gated on expiry. It is a payer's self-service escape hatch, which is why the facilitator does not expose it — not the only exit.
refundViaFacilitator sends action: "refundInEscrow", which reaches
partialVoid. So a stuck escrow is recoverable with no gas, no payer, and no
regard for the expiry:
const state = await client.queryEscrowState(paymentInfo);
if (state.capturableAmount !== '0') {
const result = await client.refundViaFacilitator(paymentInfo, state.capturableAmount);
if (!result.success && result.retryable) {
// No verdict. The tokens are still in escrow — send it again.
}
}Widening the release window still matters: it is what pays the worker, and a refund does not.
Direct Charge (No Escrow)
// Instant payment without escrow hold
await client.charge(paymentInfo);Commerce Scheme
The 'commerce' scheme is a semantic alias for 'escrow', introduced for marketplace integrations (Execution Market, arbiter workflows). It uses the same contracts, ABI, and ERC-3009 flow as 'escrow'.
import type { X402Scheme } from 'uvd-x402-sdk';
// All three schemes are valid
const scheme: X402Scheme = 'commerce'; // or 'exact' or 'escrow'
// PaymentRequirements and X402 headers accept all schemes
const header = {
x402Version: 2,
scheme: 'commerce',
network: 'eip155:8453',
payload: { /* ... */ },
};
// Default behavior unchanged: buildPaymentRequirements() defaults to 'exact'The facilitator's /supported endpoint advertises both 'escrow' and 'commerce' entries for all 11 escrow-capable networks (14 entries each).
Bazaar Discovery
Register and discover paid x402 resources across the network.
The Bazaar is served by the facilitator itself under /discovery/*. No API key, no separate host.
import { BazaarClient, isAlive } from 'uvd-x402-sdk/backend';
const bazaar = new BazaarClient();
// List resources. Every filter is applied server-side over the whole catalog,
// so `pagination.total` is the real number of matches -- filtering one page
// locally is not the same thing and will under-report.
const page = await bazaar.listResources({
network: 'eip155:8453',
health: 'alive', // only endpoints a probe actually reached
tier: 'vip', // first_party | vip | verified | listed
limit: 20,
});
for (const r of page.items) {
console.log(r.url, r.health?.status, `${r.health?.latencyMs}ms`, r.curation?.label);
}
console.log(`${page.items.length} of ${page.pagination.total}`);
// Free-text search. The parameter is `q`; anything else is rejected with a 400.
const hits = await bazaar.listResources({ q: 'logs' });
// Walk the whole filtered catalog, one page at a time
for await (const r of bazaar.iterateResources({ health: 'alive' })) {
if (isAlive(r)) console.log(r.url);
}
// Register a resource. Registration is open and rate limited.
await bazaar.registerResource({
url: 'https://api.example.com/v1/generate',
description: 'Generate images with AI',
accepts: [{
scheme: 'exact',
network: 'eip155:8453',
asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
amount: '50000',
payTo: '0x1234...',
maxTimeoutSeconds: 60,
}],
metadata: { category: 'ai', tags: ['image'] },
});
// Aggregate catalog metrics
const stats = await bazaar.getStats();
console.log(stats.total, stats.visible, stats.byHealth.alive);Timestamps (firstSeen, lastSeen, lastUpdated, health.lastChecked) are Unix epoch seconds. Use epochToDate() to get a Date.
x402 v2 requests (buildVerifyRequestV2 / buildSettleRequestV2)
If your 402 advertises CAIP-2 networks (eip155:8453), you are speaking v2 and
must send the v2 envelope. buildVerifyRequest emits the v1 one and cannot
express v2:
import { buildVerifyRequestV2 } from 'uvd-x402-sdk';
const body = buildVerifyRequestV2(
payment.payload,
{ url: 'https://api.example.com/thing', description: 'Thing', mimeType: 'application/json' },
{ scheme: 'exact', network: 'eip155:8453', asset: '0x8335...', amount: '100000',
payTo: '0xabc...', maxTimeoutSeconds: 300 }
);| | v1 envelope | v2 envelope |
|---|---|---|
| top level | {x402Version, paymentPayload, paymentRequirements} | {x402Version, paymentPayload, resource, accepted} |
| network | plain name — base | CAIP-2 — eip155:8453 |
| amount field | maxAmountRequired | amount |
| resource | URL string | object {url, description, mimeType} |
Do not mix levels. Each version demands its own network format and its own envelope. A v2 payload inside a v1 envelope — or a plain network name inside a v2 request — matches no variant at the facilitator and fails with
data did not match any variant of untagged enum VerifyRequestEnvelope, an error that names no field. If you see it, check the envelope shape first, not the fields inside it.
The top-level x402Version names the ENVELOPE
VerifyRequest.x402Version is typed 1, not 1 | 2: it says which of the two
shapes above the body has, an
