diff --git a/test/usage.test.js b/test/usage.test.js index 4ca13c5..3f00317 100644 --- a/test/usage.test.js +++ b/test/usage.test.js @@ -39,7 +39,7 @@ async function logged(handler, request) { app.use(log.middleware(describePlainTextCall)); app.all('*', handler); const server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); }); - await fetch(`http://127.0.0.1:${server.address().port}${request.path}`, { method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': request.agent || 'node' }, body: JSON.stringify(request.body || {}) }); + await fetch(`http://127.0.0.1:${server.address().port}${request.path}`, { method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': request.agent || 'node', ...request.headers }, body: JSON.stringify(request.body || {}) }); await new Promise((r) => setTimeout(r, 30)); await log.flush(); server.close(); @@ -55,6 +55,12 @@ test('usage log: a 402 is logged as a price shown, with the caller and a visitor assert.ok(!JSON.stringify(e).includes('127.0.0.1')); }); +test('usage log: a refused x402 payment is logged with the reason the 402 gives', async () => { + const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64'); + const e = await logged((_req, res) => res.set('PAYMENT-REQUIRED', b64({ x402Version: 2, error: 'insufficient_funds', accepts: [] })).status(402).json({}), { path: '/api/check-wallet', headers: { 'payment-signature': b64({ x402Version: 2 }) } }); + assert.deepEqual([e.status, e.payment_failed, e.reason], [402, true, 'insufficient_funds']); +}); + test('usage log: an MCP reply sent as an event stream is read (the verdict is logged)', async () => { const call = { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'check_wallet_approvals', arguments: { address: '0xabc' } } }; const reply = { jsonrpc: '2.0', id: 1, result: { content: [{ type: 'text', text: 'RISK: unlimited approval to an unverified spender' }] } }; diff --git a/usage-log.js b/usage-log.js index 52b9b66..1df3531 100644 --- a/usage-log.js +++ b/usage-log.js @@ -124,6 +124,23 @@ function visitorOf(ip, secret) { return crypto.createHmac('sha256', secret).update(address).digest('hex').slice(0, 12); } +// Why an x402 payment was refused. The 402 that answers it says so: PAYMENT-RESPONSE carries +// errorReason when settlement failed, PAYMENT-REQUIRED carries error when verification failed. +// Only the first line is kept, with URLs blanked: an RPC error could name a URL with a key in it. +function x402Refusal(res) { + for (const [header, field] of [['payment-response', 'errorReason'], ['payment-required', 'error']]) { + const value = res.getHeader(header); + if (!value) continue; + try { + const reason = JSON.parse(Buffer.from(String(value), 'base64').toString('utf8'))[field]; + if (reason) return String(reason).split('\n')[0].replace(/\b[a-z][a-z0-9+.-]*:\/\/\S+/gi, '').slice(0, 160); + } catch { + // not an x402 header + } + } + return null; +} + function createUsageLog({ service, env = process.env, fetchFn = globalThis.fetch, now = () => new Date(), log = console } = {}) { const token = env.USAGE_LOG_TOKEN; const repo = env.USAGE_LOG_REPO || DEFAULT_REPO; @@ -258,6 +275,7 @@ function createUsageLog({ service, env = process.env, fetchFn = globalThis.fetch // An MPP credential (Authorization: Payment …, mpp-pay.js) counts as an offered payment too. const mppOffered = /(?:^|,)\s*Payment\s/i.test(String(req.headers.authorization || '')); const offered = Boolean(req.headers['payment-signature'] || req.headers['x-payment']) || mppOffered; + const refusal = quote && offered && !mppOffered ? x402Refusal(res) : null; record({ route: described.route, via: described.via || 'http', @@ -265,7 +283,7 @@ function createUsageLog({ service, env = process.env, fetchFn = globalThis.fetch ms: Date.now() - started, paid: Boolean(payment), ...(payment || {}), - ...(quote ? { quote: true, ...(offered ? { payment_failed: true, ...(mppOffered ? { protocol: 'mpp', ...(res.locals && res.locals.mppRefused ? { reason: res.locals.mppRefused } : {}) } : {}), ...(attemptedNetwork(req) ? { network: attemptedNetwork(req) } : {}) } : {}) } : {}), + ...(quote ? { quote: true, ...(offered ? { payment_failed: true, ...(mppOffered ? { protocol: 'mpp', ...(res.locals && res.locals.mppRefused ? { reason: res.locals.mppRefused } : {}) } : refusal ? { reason: refusal } : {}), ...(attemptedNetwork(req) ? { network: attemptedNetwork(req) } : {}) } : {}) } : {}), agent: agentOf(req.headers['user-agent']), visitor: visitorOf(req.ip || (req.socket && req.socket.remoteAddress), visitorSecret), input: described.input,