// Node 24+. Save this file in a private working folder outside a public repository. // npm install @x402/core@2.28.0 @x402/evm@2.28.0 viem@2.57.7 // Default: node buyer-example.mjs --quote (no key, signature, or payment). // BUYER_SERVICE: extract|markdown|metadata|links|structured-data|tables|outline|seo|aeo|readability|page-audit|site-audit // Higher-priced audits require explicit BUYER_MAX_USDC (default 0.01). // site-audit requires BUYER_URLS: a JSON array of one to five same-origin URLs. // Optional BUYER_URL: public URL to inspect (defaults to https://example.com/). // For ONE real purchase: set BUYER_PAY_TO to the independently checked merchant // address and BUYER_PRIVATE_KEY in a local secret environment; run with --buy. // Never paste a seed phrase or key into chat. Never commit .agentpage402-purchase/. // --recover reuses the saved proof and original URL, without a private key. // --verify checks the saved transaction on Base, without a private key. // An exclusive marker prevents a second --buy. Never delete it to retry uncertainty. import assert from 'node:assert/strict'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { x402Client } from '@x402/core/client'; import { ExactEvmScheme } from '@x402/evm/exact/client'; import { decodePaymentRequiredHeader, encodePaymentSignatureHeader, decodePaymentResponseHeader } from '@x402/core/http'; import { privateKeyToAccount } from 'viem/accounts'; import { createPublicClient, http, erc20Abi, parseEventLogs, isAddress } from 'viem'; import { base } from 'viem/chains'; export const ORIGIN = 'https://agentpage402-demo.onrender.com'; export const NETWORK = 'eip155:8453'; export const USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; const same = (a,b) => typeof a === 'string' && typeof b === 'string' && a.toLowerCase() === b.toLowerCase(); const prices = Object.fromEntries(['extract','markdown','metadata','links','structured-data','tables','outline','seo','aeo','readability'].map(s => [`/v1/${s}`, '10000'])); prices['/v1/page-audit'] = '250000'; prices['/v1/site-audit'] = '1000000'; const paths = Object.keys(prices); const displayAmount = amount => (Number(amount) / 1e6).toFixed(2) + ' USDC'; const json = value => JSON.stringify(value, (_,v) => typeof v === 'bigint' ? v.toString() : v, 2) + '\n'; export function validateQuote(required, resource, receiver) { assert(paths.some(path => resource === ORIGIN + path), 'Unexpected merchant or service.'); assert(required?.x402Version === 2 && required.resource?.url === resource, 'Unexpected resource or protocol.'); assert(required.accepts?.length === 1, 'Expected exactly one payment option.'); const terms = required.accepts[0]; assert(terms.scheme === 'exact' && terms.network === NETWORK && same(terms.asset, USDC), 'Only exact Base USDC is allowed.'); assert(terms.amount === prices[resource.slice(ORIGIN.length)], 'Price differs from the fixed service price.'); assert(isAddress(terms.payTo || '') && !/^0x0{40}$/i.test(terms.payTo), 'Invalid merchant address.'); if (receiver) assert(same(terms.payTo, receiver), 'Merchant differs from the independently approved recipient.'); assert(Number.isInteger(terms.maxTimeoutSeconds) && terms.maxTimeoutSeconds > 0 && terms.maxTimeoutSeconds <= 300, 'Unexpected authorization lifetime.'); assert(terms.extra?.name === 'USD Coin' && terms.extra?.version === '2', 'Unexpected Base USDC domain.'); assert(!terms.extra?.assetTransferMethod || terms.extra.assetTransferMethod === 'eip3009', 'Only EIP-3009 is allowed.'); return terms; } async function main() { const mode = process.argv[2] || '--quote'; assert(['--quote','--buy','--recover','--verify'].includes(mode), 'Use --quote, --buy, --recover or --verify.'); const folder = resolve('.agentpage402-purchase'); const file = name => resolve(folder, name); const save = (name, value, exclusive = false) => writeFile(file(name), json(value), { mode: 0o600, flag: exclusive ? 'wx' : 'w' }); const load = async name => JSON.parse(await readFile(file(name), 'utf8')); const rpc = createPublicClient({ chain: base, transport: http(undefined, { timeout: 15_000, retryCount: 0 }) }); const post = (resource, body, proof) => fetch(resource, { method: 'POST', redirect: 'error', headers: { 'Content-Type': 'application/json', ...(proof ? { 'PAYMENT-SIGNATURE': encodePaymentSignatureHeader(proof) } : {}) }, body: JSON.stringify(body), signal: AbortSignal.timeout(90_000) }); const receive = async (response, filename) => { const header = response.headers.get('payment-response'); const settlement = header ? decodePaymentResponseHeader(header) : null; const record = { httpStatus: response.status, settlement, recovered: response.headers.get('x-payment-recovered') === 'true', at: new Date().toISOString() }; await save(filename, record); // Save the transaction before parsing body or making RPC calls. record.result = await response.json(); await save(filename, record); assert(response.status === 200 && settlement?.success === true && settlement.network === NETWORK, 'Result unconfirmed. Keep the saved proof; do not pay again.'); console.log(json({ httpStatus: record.httpStatus, transaction: settlement.transaction, recovered: record.recovered, savedTo: filename })); return record; }; if (mode === '--recover' || mode === '--verify') { const attempt = await load('attempt.json'), proof = await load('proof.json'); validateQuote({ x402Version: proof.x402Version, resource: proof.resource, accepts: [proof.accepted] }, attempt.resource, attempt.receiver); assert(proof.payload.authorization.from.toLowerCase() === attempt.buyer.toLowerCase() && proof.payload.authorization.value === attempt.amount && proof.accepted.amount === attempt.amount, 'Saved proof does not match the attempt.'); if (mode === '--recover') { const response = await post(attempt.resource, attempt.body, proof); return receive(response, 'recovery.json'); } let result; try { result = await load('recovery.json'); } catch (error) { if (error.code !== 'ENOENT') throw error; } if (!result?.settlement?.success) result = await load('receipt.json'); assert(result.settlement?.success && /^0x[0-9a-f]{64}$/i.test(result.settlement.transaction), 'No successful saved transaction; recover the original request first.'); assert(await rpc.getChainId() === 8453, 'RPC must be Base mainnet.'); const receipt = await rpc.getTransactionReceipt({ hash: result.settlement.transaction }); assert(receipt.status === 'success', 'Transaction did not succeed.'); const transfers = parseEventLogs({ abi: erc20Abi, eventName: 'Transfer', logs: receipt.logs.filter(log => same(log.address, USDC)) }); assert(transfers.some(log => same(log.args.from, attempt.buyer) && same(log.args.to, attempt.receiver) && log.args.value === BigInt(attempt.amount)), 'Expected exact USDC transfer was not found.'); await save('verified.json', { transaction: receipt.transactionHash, blockNumber: receipt.blockNumber, amount: displayAmount(attempt.amount), verifiedAt: new Date().toISOString() }); console.log(`Verified exact transfer: https://basescan.org/tx/${receipt.transactionHash}`); return; } const resource = `${ORIGIN}/v1/${process.env.BUYER_SERVICE || 'extract'}`; assert(paths.some(path => resource === ORIGIN + path), 'Unknown service.'); const body = resource.endsWith('/site-audit') ? { urls: JSON.parse(process.env.BUYER_URLS || '["https://example.com/"]') } : { url: process.env.BUYER_URL || 'https://example.com/' }; const response = await post(resource, body); assert(response.status === 402, 'Expected HTTP 402; no payment made.'); const required = decodePaymentRequiredHeader(response.headers.get('payment-required')); await response.arrayBuffer(); const terms = validateQuote(required, resource, process.env.BUYER_PAY_TO); if (mode === '--quote') { console.log(json({ resource, body, network: NETWORK, amount: displayAmount(terms.amount), recipient: terms.payTo, recipientPreapproved: Boolean(process.env.BUYER_PAY_TO), paid: false })); return; } assert(isAddress(process.env.BUYER_PAY_TO || ''), 'Set the independently checked BUYER_PAY_TO before buying.'); const budget = process.env.BUYER_MAX_USDC || '0.01'; assert(/^(0|1)(\.\d{1,6})?$/.test(budget) && Number(budget) <= 1 && Number(budget) * 1e6 >= Number(terms.amount), 'Set BUYER_MAX_USDC explicitly to cover this purchase (maximum 1 USDC). Nothing signed.'); assert(/^0x[0-9a-f]{64}$/i.test(process.env.BUYER_PRIVATE_KEY || ''), 'Set BUYER_PRIVATE_KEY locally for a dedicated funded buyer wallet.'); const buyer = privateKeyToAccount(process.env.BUYER_PRIVATE_KEY); assert(!same(buyer.address, terms.payTo), 'Buyer and merchant must be different wallets.'); assert(await rpc.getChainId() === 8453, 'RPC must be Base mainnet.'); const balance = await rpc.readContract({ address: USDC, abi: erc20Abi, functionName: 'balanceOf', args: [buyer.address] }); assert(balance >= BigInt(terms.amount), 'Buyer has insufficient Base USDC. Nothing signed or sent.'); await mkdir(folder, { recursive: true, mode: 0o700 }); await save('attempt.json', { resource, body, buyer: buyer.address, receiver: terms.payTo, amount: terms.amount, at: new Date().toISOString() }, true); const client = new x402Client().register(NETWORK, new ExactEvmScheme(buyer)); const proof = await client.createPaymentPayload(required); await save('proof.json', proof, true); await receive(await post(resource, body, proof), 'receipt.json'); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch(error => { console.error(error instanceof assert.AssertionError ? error.message : error.code === 'EEXIST' ? 'Purchase already attempted. Use --recover; never clear the marker to retry.' : 'Stopped. Preserve the attempt and proof; do not create another payment.'); process.exitCode = 1; });