// Stack Facts: pay for one call with x402, no dependencies (Node 18+). // node fetch-x402.mjs [url] [json-body] // Default url: https://g5joilnmeep4ntz3nwezxt5dmu.srv.us/v1/eol/nodejs (GET); a json-body makes it a POST. // Env: MAX_ATOMIC = the most this script will pay for one call, in atomic USDC (6 decimals; default 10000 = $0.01). // // The flow (x402 v2, scheme "exact"): call the route -> 402 with the offer in the PAYMENT-REQUIRED header (base64 // JSON) -> sign an EIP-3009 TransferWithAuthorization for exactly that offer -> call again with PAYMENT-SIGNATURE // (base64 JSON) -> 200 with the answer and PAYMENT-RESPONSE (base64 JSON: the settlement transaction). // // The default signer is a placeholder that cannot pay. To pay, pass a signer shaped like a viem account // ({address, signTypedData}), e.g. privateKeyToAccount(key) from 'viem/accounts' for a wallet holding USDC. // Alternative: the @x402/fetch package (wrapFetchWithPayment with @x402/evm's ExactEvmScheme). import { realpathSync } from 'node:fs'; import { pathToFileURL } from 'node:url'; const DEFAULT_URL = 'https://g5joilnmeep4ntz3nwezxt5dmu.srv.us/v1/eol/nodejs'; const CHAIN_IDS = { 'eip155:8453': 8453 }; // Base // Standard base64 of UTF-8 JSON, both ways (the header encoding). const encode = (value) => Buffer.from(JSON.stringify(value), 'utf8').toString('base64'); const decode = (b64) => JSON.parse(Buffer.from(String(b64).trim(), 'base64').toString('utf8')); // Placeholder signer: a fixed address and a dummy 65-byte signature. Replace it to pay. export const mockSigner = { address: '0x5555555555555555555555555555555555555555', async signTypedData(_typedData) { return '0x' + 'cd'.repeat(65); }, }; // The EIP-712 typed data a real wallet signs for one "exact" offer (EIP-3009 transferWithAuthorization on USDC). export function transferAuthorization(accepted, from, { now = Math.floor(Date.now() / 1000) } = {}) { const chainId = CHAIN_IDS[accepted.network]; if (!chainId) throw new Error(`unknown network ${accepted.network}`); const nonce = '0x' + Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) => b.toString(16).padStart(2, '0')).join(''); const message = { from, to: accepted.payTo, value: accepted.amount, validAfter: '0', validBefore: String(now + accepted.maxTimeoutSeconds), nonce, }; return { domain: { name: accepted.extra.name, version: accepted.extra.version, chainId, verifyingContract: accepted.asset }, 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, }; } // fetch, plus paying one 402 offer of at most maxAtomic. -> {response, offer, accepted, paid}: offer = the decoded // PAYMENT-REQUIRED (null if the first call was not a 402), accepted = the entry paid, paid = the decoded // PAYMENT-RESPONSE (null if none). export async function payingFetch(url, init = {}, { signer = mockSigner, maxAtomic = 10000n, fetchImpl = fetch } = {}) { const first = await fetchImpl(url, init); if (first.status !== 402) return { response: first, offer: null, accepted: null, paid: null }; const offer = decode(first.headers.get('payment-required')); await first.arrayBuffer(); // the body is the same JSON as the header const accepted = offer.accepts.find((a) => a.scheme === 'exact' && CHAIN_IDS[a.network]); if (!accepted) throw new Error('no offer this client can pay (needs scheme exact on Base)'); if (BigInt(accepted.amount) > BigInt(maxAtomic)) { throw new Error(`price ${accepted.amount} atomic USDC is over the limit of ${maxAtomic}; not paying`); } const typedData = transferAuthorization(accepted, signer.address); const payment = { x402Version: offer.x402Version, resource: offer.resource, accepted, payload: { signature: await signer.signTypedData(typedData), authorization: typedData.message }, }; const headers = new Headers(init.headers); headers.set('PAYMENT-SIGNATURE', encode(payment)); const response = await fetchImpl(url, { ...init, headers }); const settled = response.headers.get('payment-response'); return { response, offer, accepted, paid: settled ? decode(settled) : null }; } // CLI: pays one call and prints one JSON object: {status, price, paid, body}. (realpath: the script may sit behind a symlink.) if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) { const [url = DEFAULT_URL, body] = process.argv.slice(2); const init = body ? { method: 'POST', headers: { 'content-type': 'application/json' }, body } : {}; try { const { response, accepted, paid } = await payingFetch(url, init, { maxAtomic: BigInt(process.env.MAX_ATOMIC || 10000) }); const text = await response.text(); let json = null; try { json = JSON.parse(text); } catch { json = text; } const price = accepted ? { amount: accepted.amount, asset: accepted.asset, network: accepted.network, payTo: accepted.payTo } : null; console.log(JSON.stringify({ status: response.status, price, paid, body: json }, null, 2)); process.exitCode = response.ok ? 0 : 1; } catch (err) { console.error(`fetch-x402: ${err.message}`); process.exitCode = 2; } }