From 2f90047fce2a800d2355765b3474933061766dc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 16:57:56 +0000 Subject: [PATCH 01/19] Accept MPP method tempo (push mode) when MPP_TEMPO_RECIPIENT is set mpp-pay from x402-doctor (#139): the agent sends USDC.e on Tempo with the MPP memo and answers with the transaction hash, checked on a Tempo RPC; the 402 then carries an evm and a tempo challenge and /openapi.json lists both offers. Off until MPP_TEMPO_RECIPIENT is set. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TsD5haPKxeWjYmErHzvU48 --- mpp-pay.js | 179 ++++++++++++++++++++++++++++++++++++++++++++++------- server.js | 7 ++- 2 files changed, 162 insertions(+), 24 deletions(-) diff --git a/mpp-pay.js b/mpp-pay.js index 38a08f3..14ca5e1 100644 --- a/mpp-pay.js +++ b/mpp-pay.js @@ -10,15 +10,52 @@ // the authorization nonce must be keccak256(JSON.stringify([id, realm])). // - Like x402: verified first, settled only after a 2xx answer (a failed diagnosis is // never charged), then `Payment-Receipt` on the response. +// +// And, with `tempo`, the `tempo` charge method in "push" mode, the one nearly every MPP agent +// pays with: the agent sends USDC on Tempo itself (a TIP-20 transferWithMemo whose memo binds +// the realm and challenge id) and answers with the transaction hash. We read the receipt from +// a Tempo RPC, so no facilitator. The payment is final before the answer; a failed answer +// frees the hash, so the same credential can be sent again while the challenge lasts. const crypto = require('crypto'); const { keccak256, stringToHex, getAddress } = require('viem'); const METHOD = 'evm'; +const TEMPO = 'tempo'; const INTENT = 'charge'; const BASE_CHAIN_ID = 8453; const BASE_USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; const USDC_EIP712 = { name: 'USD Coin', version: '2' }; const EXPIRES_SECONDS = 300; +// Tempo mainnet pays in USDC.e (the mppx default), the Moderato testnet in pathUSD. Both 6 decimals. +const TEMPO_CHAINS = { + 4217: { name: 'Tempo', currency: getAddress('0x20c000000000000000000000b9537d11c60e8b50'), rpc: 'https://rpc.tempo.xyz' }, + 42431: { name: 'Tempo Moderato', currency: getAddress('0x20c0000000000000000000000000000000000000'), rpc: 'https://rpc.moderato.tempo.xyz' }, +}; +const TRANSFER_WITH_MEMO = keccak256(stringToHex('TransferWithMemo(address,address,uint256,bytes32)')); + +// The 32-byte MPP memo (mppx Attribution): keccak256("mpp")[0:4], version 1, +// keccak256(realm)[0:10], client fingerprint [10 bytes], keccak256(challenge id)[0:7]. +const MEMO_TAG = keccak256(stringToHex('mpp')).slice(0, 10); +const fingerprint = (value, bytes) => keccak256(stringToHex(String(value))).slice(2, 2 + bytes * 2); +function memoBinds(memo, realm, challengeId) { + const m = String(memo).toLowerCase(); + return m.length === 66 && m.slice(0, 10) === MEMO_TAG && m.slice(10, 12) === '01' + && m.slice(12, 32) === fingerprint(realm, 10) && m.slice(52) === fingerprint(challengeId, 7); +} +const topicAddress = (topic) => getAddress(`0x${String(topic).slice(-40)}`); + +async function rpcReceipt(rpc, hash) { + const res = await fetch(rpc, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_getTransactionReceipt', params: [hash] }), + signal: AbortSignal.timeout(10000), + }); + if (!res.ok) throw new Error(`Tempo RPC answered ${res.status}`); + const body = await res.json(); + if (body.error) throw new Error(`Tempo RPC: ${body.error.message || 'error'}`); + return body.result; +} const b64u = (s) => Buffer.from(s).toString('base64url'); const fromB64u = (s) => Buffer.from(String(s), 'base64url').toString('utf8'); @@ -41,10 +78,26 @@ const quote = (s) => `"${String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}" class PaymentError extends Error {} -// facilitator: { verify(paymentPayload, requirements), settle(paymentPayload, requirements) } (x402 v2). -function createMppPay({ secret, realm, recipient, routes, facilitator, now = () => Date.now(), log = console }) { +// facilitator: { verify(paymentPayload, requirements), settle(paymentPayload, requirements) } (x402 v2), +// for the evm method. tempo: { recipient, chainId = 4217, currency, rpc, getReceipt(hash) } turns +// on the tempo method (getReceipt replaces the RPC call, for tests). +function createMppPay({ secret, realm, recipient, routes, facilitator, tempo = null, now = () => Date.now(), log = console }) { if (!secret) throw new Error('MPP needs a secret'); - const payTo = getAddress(recipient); + const payTo = recipient ? getAddress(recipient) : null; + if (facilitator && !payTo) throw new Error('MPP evm needs a recipient'); + let tempoCfg = null; + if (tempo) { + const chainId = Number(tempo.chainId || 4217); + const known = TEMPO_CHAINS[chainId] || {}; + const rpc = tempo.rpc || known.rpc; + tempoCfg = { + chainId, + name: known.name || `Tempo ${chainId}`, + recipient: getAddress(tempo.recipient), + currency: getAddress(tempo.currency || known.currency), + getReceipt: tempo.getReceipt || ((hash) => rpcReceipt(rpc, hash)), + }; + } // "GET /signal/:pair" -> the route key and its price; a request matches by method and path. const prices = new Map(Object.entries(routes).map(([route, price]) => [route, atomicUsdc(price)])); const patterns = [...prices.keys()].map((route) => { @@ -58,23 +111,35 @@ function createMppPay({ secret, realm, recipient, routes, facilitator, now = () return hit ? hit.route : null; }; const inFlight = new Set(); // nonces being served, so one signature can't buy two answers at once + const usedHashes = new Map(); // Tempo tx hash -> until when it stays claimed (its challenge has expired by then) - const requestFor = (amount) => ({ - amount, - currency: BASE_USDC, - methodDetails: { chainId: BASE_CHAIN_ID, credentialTypes: ['authorization'], decimals: 6 }, - recipient: payTo, - }); + const requestFor = { + [METHOD]: facilitator ? (amount) => ({ + amount, + currency: BASE_USDC, + methodDetails: { chainId: BASE_CHAIN_ID, credentialTypes: ['authorization'], decimals: 6 }, + recipient: payTo, + }) : null, + // Push only: the agent broadcasts and sends the hash, so nothing is signed for us to submit. + [TEMPO]: tempoCfg ? (amount) => ({ + amount, + currency: tempoCfg.currency, + methodDetails: { chainId: tempoCfg.chainId, supportedModes: ['push'] }, + recipient: tempoCfg.recipient, + }) : null, + }; + const methods = Object.keys(requestFor).filter((m) => requestFor[m]); + if (!methods.length) throw new Error('MPP needs a facilitator (evm) or tempo'); const idOf = (c) => crypto.createHmac('sha256', secret) .update([c.realm, c.method, c.intent, b64u(canonical(c.request)), c.expires || '', c.digest || '', c.opaque || ''].join('|')) .digest('base64url'); - function challengeFor(route) { + function challengeFor(route, method = methods[0]) { const c = { realm, - method: METHOD, + method, intent: INTENT, - request: requestFor(prices.get(route)), + request: requestFor[method](prices.get(route)), expires: new Date(now() + EXPIRES_SECONDS * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z'), opaque: b64u(canonical({ route })), }; @@ -85,6 +150,8 @@ function createMppPay({ secret, realm, recipient, routes, facilitator, now = () `id=${quote(c.id)}`, `realm=${quote(c.realm)}`, `method=${quote(c.method)}`, `intent=${quote(c.intent)}`, `request=${quote(b64u(canonical(c.request)))}`, `expires=${quote(c.expires)}`, `opaque=${quote(c.opaque)}`, ].join(', ')}`; + // One challenge per method, each its own WWW-Authenticate line. + const headersFor = (route) => methods.map((m) => header(challengeFor(route, m))); const requirementsFor = (request) => ({ scheme: 'exact', @@ -114,9 +181,20 @@ function createMppPay({ secret, realm, recipient, routes, facilitator, now = () let bound; try { bound = JSON.parse(fromB64u(c.opaque)).route; } catch { bound = null; } if (bound !== route) throw new PaymentError('That challenge is for another route.'); - if (canonical(request) !== canonical(requestFor(prices.get(route)))) throw new PaymentError('The price changed. Ask again for a fresh 402.'); + if (!methods.includes(c.method) || c.intent !== INTENT) throw new PaymentError(`Only intent "charge" with method ${methods.map((m) => `"${m}"`).join(' or ')} is accepted here.`); + if (canonical(request) !== canonical(requestFor[c.method](prices.get(route)))) throw new PaymentError('The price changed. Ask again for a fresh 402.'); if (!(Date.parse(c.expires) > now())) throw new PaymentError('That challenge has expired. Ask again for a fresh 402.'); - if (c.method !== METHOD || c.intent !== INTENT) throw new PaymentError('Only method "evm", intent "charge" is accepted here.'); + if (c.method === TEMPO) { + if (p.type !== 'hash') throw new PaymentError('Only a "hash" credential (push mode) is accepted for tempo: send the transfer yourself, then the transaction hash.'); + if (!/^0x[0-9a-fA-F]{64}$/.test(String(p.hash || ''))) throw new PaymentError('The hash must be a 32-byte transaction hash.'); + let source = null; + if (cred.source !== undefined) { + const s = /^did:pkh:eip155:(\d+):(0x[0-9a-fA-F]{40})$/.exec(String(cred.source)); + if (!s || Number(s[1]) !== tempoCfg.chainId) throw new PaymentError(`source must be did:pkh:eip155:${tempoCfg.chainId}:.`); + source = getAddress(s[2]); + } + return { method: TEMPO, hash: p.hash.toLowerCase(), source, amount: request.amount, challengeId: c.id, until: Date.parse(c.expires) + 60000 }; + } if (p.type !== 'authorization') throw new PaymentError('Only an "authorization" (EIP-3009) credential is accepted.'); let from, to; try { from = getAddress(p.from); to = getAddress(p.to); } catch { throw new PaymentError('from and to must be addresses.'); } @@ -130,12 +208,66 @@ function createMppPay({ secret, realm, recipient, routes, facilitator, now = () accepted: requirementsFor(request), payload: { authorization: { from, to, value: String(p.value), validAfter: String(p.validAfter), validBefore: String(p.validBefore), nonce: p.nonce }, signature: p.signature }, }; - return { paymentPayload, requirements: paymentPayload.accepted, from, nonce: p.nonce, amount: request.amount }; + return { method: METHOD, paymentPayload, requirements: paymentPayload.accepted, from, nonce: p.nonce, amount: request.amount }; + } + + // The Tempo transaction must hold a TransferWithMemo of the asked currency and amount, to our + // recipient, from the payer, with the MPP memo for this realm and challenge. Throws PaymentError. + async function checkTempo(paid) { + const receipt = await tempoCfg.getReceipt(paid.hash); + if (!receipt) throw new PaymentError(`That transaction isn't on ${tempoCfg.name} (yet). Send the same credential again in a moment.`); + if (receipt.status !== '0x1' && receipt.status !== 'success') throw new PaymentError('That transaction failed on Tempo.'); + const payer = paid.source || getAddress(receipt.from); + const hit = (receipt.logs || []).find((l) => { + const t = l.topics || []; + if (String(t[0]).toLowerCase() !== TRANSFER_WITH_MEMO || t.length !== 4) return false; + if (String(l.address).toLowerCase() !== tempoCfg.currency.toLowerCase()) return false; + try { + return topicAddress(t[1]) === payer && topicAddress(t[2]) === tempoCfg.recipient + && BigInt(l.data).toString() === paid.amount && memoBinds(t[3], realm, paid.challengeId); + } catch { return false; } + }); + if (!hit) throw new PaymentError(`No transfer in that transaction pays ${paid.amount} units of ${tempoCfg.currency} from ${payer} to ${tempoCfg.recipient} with the MPP memo for this challenge.`); + return payer; + } + + // Serves a request paid on Tempo: the hash is claimed first, freed again if the answer fails. + function serveTempo(req, res, next, route, paid) { + const t = now(); + for (const [hash, until] of usedHashes) if (until < t) usedHashes.delete(hash); + if (usedHashes.has(paid.hash)) return refuse(req, res, route, 'That transaction has already paid for an answer.'); + usedHashes.set(paid.hash, paid.until); + const free = () => usedHashes.delete(paid.hash); + Promise.resolve().then(() => checkTempo(paid)).then((payer) => { + req.mppPaid = { payer, amount: paid.amount }; + const send = res.send, end = res.end; + const restore = () => { res.send = send; res.end = end; }; + res.end = function (...args) { + restore(); + if (res.headersSent) return end.apply(this, args); + free(); + log.warn(`MPP: ${route} answered without res.send; refused`); + return refuse(req, res, route, 'This answer could not be served over MPP. Your payment stays valid: send the same credential again.'); + }; + res.send = function (body) { + restore(); + if (res.statusCode < 200 || res.statusCode >= 300) { free(); return send.call(this, body); } + res.set('Payment-Receipt', b64u(JSON.stringify({ method: TEMPO, reference: paid.hash, status: 'success', timestamp: new Date(now()).toISOString() }))); + res.locals.mppPayment = { usd: Number(paid.amount) / 1e6, network: tempoCfg.name, payer, tx: paid.hash, protocol: 'mpp' }; + return send.call(this, body); + }; + next(); + }, (err) => { + free(); + if (err instanceof PaymentError) return refuse(req, res, route, err.message); + log.warn(`MPP tempo check failed on ${route}: ${err.message}`); + refuse(req, res, route, 'The Tempo payment could not be checked right now. Send the same credential again in a moment.'); + }); } function refuse(req, res, route, reason) { res.status(402); - res.set('WWW-Authenticate', header(challengeFor(route))); + res.set('WWW-Authenticate', headersFor(route)); res.set('Cache-Control', 'no-store'); res.type('application/problem+json').send(JSON.stringify({ type: 'https://paymentauth.org/problems/verification-failed', title: 'Payment failed', status: 402, detail: reason })); } @@ -149,7 +281,7 @@ function createMppPay({ secret, realm, recipient, routes, facilitator, now = () // No MPP credential: let x402 answer, and put the MPP challenge on its 402. const writeHead = res.writeHead; res.writeHead = function (...args) { - if (res.statusCode === 402 && !res.getHeader('WWW-Authenticate')) res.setHeader('WWW-Authenticate', header(challengeFor(route))); + if (res.statusCode === 402 && !res.getHeader('WWW-Authenticate')) res.setHeader('WWW-Authenticate', headersFor(route)); return writeHead.apply(this, args); }; return next(); @@ -159,6 +291,7 @@ function createMppPay({ secret, realm, recipient, routes, facilitator, now = () if (err instanceof PaymentError) return refuse(req, res, route, err.message); return next(err); } + if (paid.method === TEMPO) return serveTempo(req, res, next, route, paid); if (inFlight.has(paid.nonce)) return refuse(req, res, route, 'This authorization is already being used.'); inFlight.add(paid.nonce); const done = () => inFlight.delete(paid.nonce); @@ -192,7 +325,7 @@ function createMppPay({ secret, realm, recipient, routes, facilitator, now = () }, (err) => { done(); log.warn(`MPP verify failed on ${route}: ${err.message}`); refuse(req, res, route, 'The payment could not be checked right now. Try again.'); }); } - return { middleware, challengeFor, header, readCredential }; + return { middleware, challengeFor, header, readCredential, methods, tempo: tempoCfg && { chainId: tempoCfg.chainId, network: tempoCfg.name, currency: tempoCfg.currency, recipient: tempoCfg.recipient } }; } // MPP discovery (paymentauth.org draft-payment-discovery, read by MPPScan): adds the evm offer to @@ -201,7 +334,8 @@ function createMppPay({ secret, realm, recipient, routes, facilitator, now = () // include(path, method) limits it to the operations MPP really sells. protocols is written as // objects ([{x402:{}}, {mpp:{…}}]) as @agentcash/discovery (MPPScan, x402scan) reads it: next to a // price object, plain strings make it drop the payment info. -function addMppOffers(spec, { categories = [], docs = {}, contact = null, include = () => true } = {}) { +// tempo: { currency } adds the tempo offer too; evm: false leaves the evm offer out. +function addMppOffers(spec, { categories = [], docs = {}, contact = null, include = () => true, evm = true, tempo = null } = {}) { spec['x-service-info'] = { categories, docs }; if (contact && spec.info && !spec.info.contact) spec.info.contact = contact; for (const [path, methods] of Object.entries(spec.paths || {})) { @@ -209,8 +343,9 @@ function addMppOffers(spec, { categories = [], docs = {}, contact = null, includ if (!include(path, method.toUpperCase())) continue; const info = op && op['x-payment-info']; if (!info || !info.price || info.price.mode !== 'fixed' || info.price.amount === undefined) continue; - info.offers = [{ amount: atomicUsdc(info.price.amount), currency: BASE_USDC, description: op.summary || op.description || '', intent: INTENT, method: METHOD }]; - info.protocols = [{ x402: {} }, { mpp: { method: METHOD, intent: INTENT, currency: BASE_USDC } }]; + const offered = [...(evm ? [[METHOD, BASE_USDC]] : []), ...(tempo ? [[TEMPO, tempo.currency]] : [])]; + info.offers = offered.map(([method, currency]) => ({ amount: atomicUsdc(info.price.amount), currency, description: op.summary || op.description || '', intent: INTENT, method })); + info.protocols = [{ x402: {} }, ...offered.map(([method, currency]) => ({ mpp: { method, intent: INTENT, currency } }))]; } } return spec; @@ -219,4 +354,4 @@ function addMppOffers(spec, { categories = [], docs = {}, contact = null, includ // Skips the x402 paywall for a request already paid over MPP. const unlessMppPaid = (paywall) => (req, res, next) => (req.mppPaid ? next() : paywall(req, res, next)); -module.exports = { createMppPay, unlessMppPaid, addMppOffers, atomicUsdc, canonical, BASE_USDC }; +module.exports = { createMppPay, unlessMppPaid, addMppOffers, atomicUsdc, canonical, memoBinds, BASE_USDC, TEMPO_CHAINS }; diff --git a/server.js b/server.js index 6ad3c5c..1671b59 100644 --- a/server.js +++ b/server.js @@ -186,7 +186,9 @@ if (x402PayTo) { realm: 'plaintext.fizzl.eu', recipient: x402PayTo, routes: { 'POST /api/check-wallet': x402CheckPrice, 'POST /api/explain': x402ExplainPrice }, - facilitator: facilitators[0] + facilitator: facilitators[0], + // MPP_TEMPO_RECIPIENT adds the tempo method (USDC.e on Tempo, push mode; MPP_TEMPO_CHAIN 42431 = testnet). + tempo: process.env.MPP_TEMPO_RECIPIENT ? { recipient: process.env.MPP_TEMPO_RECIPIENT, chainId: Number(process.env.MPP_TEMPO_CHAIN || 4217), rpc: process.env.MPP_TEMPO_RPC || undefined } : null }) : null; if (mpp) { @@ -195,7 +197,8 @@ if (x402PayTo) { const spec = addMppOffers(JSON.parse(require('fs').readFileSync(path.join(__dirname, 'public', 'openapi.json'), 'utf8')), { categories: ['security', 'blockchain'], docs: { homepage: 'https://plaintext.fizzl.eu', apiReference: 'https://plaintext.fizzl.eu/openapi.json', llms: 'https://plaintext.fizzl.eu/skill.md' }, - contact: { name: 'Fizzl', url: 'https://fizzl.eu' } + contact: { name: 'Fizzl', url: 'https://fizzl.eu' }, + tempo: mpp.tempo }); app.get('/openapi.json', (_req, res) => res.json(spec)); } From 6ada79558bbefafcbb951d13a0496dd7b3511b5d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 17:41:55 +0000 Subject: [PATCH 02/19] Clearer /api/explain description for agents Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TsD5haPKxeWjYmErHzvU48 --- server.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server.js b/server.js index 1671b59..7a41dfe 100644 --- a/server.js +++ b/server.js @@ -214,7 +214,7 @@ if (x402PayTo) { }, 'POST /api/explain': { accepts: acceptsFor(x402ExplainPrice), - description: 'Explain a pasted approval/permission payload in plain language, with a SAFE, CAUTION or RISK verdict', + description: 'What would signing this allow? Paste a wallet approval, transaction or typed-data signature before someone signs it: returns a SAFE, CAUTION or RISK verdict with a short plain-language explanation of what it gives away', mimeType: 'application/json', ...serviceMetadata, extensions: explainDiscovery @@ -675,7 +675,7 @@ const X402_WELL_KNOWN_MANIFEST = { description: "Checks a wallet's token/NFT approvals via GoPlus and explains risk in plain language.", endpoints: [ { url: `${PUBLIC_ORIGIN}/api/check-wallet`, method: 'POST', description: 'Explain wallet token approvals in plain language' }, - { url: `${PUBLIC_ORIGIN}/api/explain`, method: 'POST', description: 'Explain a pasted approval payload in plain language' } + { url: `${PUBLIC_ORIGIN}/api/explain`, method: 'POST', description: 'What would signing this allow? A SAFE, CAUTION or RISK verdict with a plain-language explanation of a pasted approval, transaction or signature' } ], openapi: `${PUBLIC_ORIGIN}/openapi.json`, docs: `${PUBLIC_ORIGIN}/openapi.json`, From 57e398cf7d3d34d9131c41d818b783feef7d7798 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 17:55:42 +0000 Subject: [PATCH 03/19] Add glama.json to claim the Glama listing Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TsD5haPKxeWjYmErHzvU48 --- glama.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 glama.json diff --git a/glama.json b/glama.json new file mode 100644 index 0000000..30caaf9 --- /dev/null +++ b/glama.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://glama.ai/mcp/schemas/server.json", + "maintainers": ["Fizzl13"] +} From 2eeeeec1b9126d700bb423a24b30eff0a5ea6cde Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 19:38:04 +0000 Subject: [PATCH 04/19] MCP registry: clearer description (says when an agent would use it) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TsD5haPKxeWjYmErHzvU48 --- server.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server.json b/server.json index 4811e11..2352b18 100644 --- a/server.json +++ b/server.json @@ -2,8 +2,8 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.Fizzl13/plaintext-wallet-checker", "title": "PlainText Wallet Approval Checker", - "description": "Checks a wallet's token/NFT approvals via GoPlus and explains risk in plain language.", - "version": "1.1.1", + "description": "Explains wallet approvals and signature requests in plain language: SAFE, CAUTION or RISK.", + "version": "1.1.2", "remotes": [ { "type": "streamable-http", From 71f6a3e44eaa12d7c005a20d48a2c99aa2a9634d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 03:43:18 +0000 Subject: [PATCH 05/19] Homepage: a row of prices right under the intro Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TsD5haPKxeWjYmErHzvU48 --- public/index.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/public/index.html b/public/index.html index 33d9a12..8f8fd82 100644 --- a/public/index.html +++ b/public/index.html @@ -146,6 +146,9 @@ .video h2 { font-family: 'Space Grotesk', sans-serif; font-weight: 700; letter-spacing: -.03em; font-size: 22px; margin: 0 0 12px; } .video video { display: block; width: 100%; height: auto; border-radius: 12px; border: 1px solid var(--line); background: #000; } footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid var(--line); color: var(--ink-soft); font-size: 12.5px; line-height: 1.6; } + .prices { display: flex; flex-wrap: wrap; gap: 10px; margin: 18px 0 0; } + .prices .pill { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 9px 13px; font-size: 14px; color: var(--ink-soft); } + .prices .pill b { color: var(--accent); font-weight: 600; } @@ -156,6 +159,11 @@
plaintext-wallet · self-hosted

Before you sign,
know what you're signing.

Check a real wallet's approvals, or try a sample scenario — either way, get a plain-language explanation of what each permission actually does.

+
+ 3 free checks a day + $0.03 explain a signature + $0.04 wallet check +

For agents and developers: the verdicts come from presign-guard (the same checks and a signed receipt); PlainText adds the explanation in plain language. If you only need the verdict and reason codes, call presign-guard directly: GET /v1/approvals?chain=base&address=… ($0.02) or POST /v1/check ($0.01).

From 193799b30985dcfb16baf6240658d5f4327c1a22 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 21:54:57 +0000 Subject: [PATCH 06/19] Usage log: record the network a refused payment was tried on A 402 with a payment that was refused now logs network (from the x402 payment header, or the MPP credential's method: evm = base, tempo = Tempo), so the usage log can show which network traffic comes from. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TsD5haPKxeWjYmErHzvU48 --- usage-log.js | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/usage-log.js b/usage-log.js index ccfd513..47556fa 100644 --- a/usage-log.js +++ b/usage-log.js @@ -43,6 +43,7 @@ const NETWORK_NAMES = { 'eip155:84532': 'base-sepolia', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': 'solana', 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1': 'solana-devnet', + 'eip155:4217': 'Tempo', }; function decodeBase64Json(value) { @@ -72,6 +73,24 @@ function paymentOf(req, res) { }; } +// The network a refused payment was tried on: the x402 payment header's network, or for an MPP +// credential (Authorization: Payment …) its method, "evm" (Base) or "tempo". Null when unreadable. +function attemptedNetwork(req) { + const payload = decodeBase64Json(req.headers['payment-signature'] || req.headers['x-payment']); + const accepted = payload && (payload.accepted || payload.requirements); + const network = (accepted && accepted.network) || (payload && payload.network); + if (network) return NETWORK_NAMES[network] || clip(String(network), 60); + const m = /(?:^|,)\s*Payment\s+([A-Za-z0-9_-]+)/i.exec(String(req.headers.authorization || '')); + if (!m) return null; + try { + const cred = JSON.parse(Buffer.from(m[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); + const method = cred && cred.challenge && cred.challenge.method; + return method === 'tempo' ? 'Tempo' : method === 'evm' ? 'base' : method ? clip(String(method), 30) : null; + } catch { + return null; + } +} + // Who is calling, in a few words and without personal data: "browser" for a // web browser, otherwise the first product token of the User-Agent (e.g. // "SmitheryBot/1.0", "python-requests/2.32", "node"). No IP addresses. @@ -234,7 +253,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' } : {}) } : {}) } : {}), + ...(quote ? { quote: true, ...(offered ? { payment_failed: true, ...(mppOffered ? { protocol: 'mpp' } : {}), ...(attemptedNetwork(req) ? { network: attemptedNetwork(req) } : {}) } : {}) } : {}), agent: agentOf(req.headers['user-agent']), visitor: visitorOf(req.ip || (req.socket && req.socket.remoteAddress), visitorSecret), input: described.input, @@ -273,4 +292,4 @@ function mcpPayment(requestBody, responseBody) { }; } -module.exports = { createUsageLog, paymentOf, mcpToolCall, mcpPayment, clip, agentOf, visitorOf }; +module.exports = { createUsageLog, paymentOf, attemptedNetwork, mcpToolCall, mcpPayment, clip, agentOf, visitorOf }; From 322505c659be49b9192ba04342c1fd302594d7e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:31:15 +0000 Subject: [PATCH 07/19] Pay in RLUSD on the XRP Ledger (x402 exact, xrpl:0) On mainnet the paid routes also accept the same dollar price in RLUSD on the XRP Ledger, to XRPL_PAY_TO (default Frits's account with the RLUSD trust line; XRPL_PAY_TO=off turns it off), verified and submitted by an in-process facilitator from @x402/xrpl, as in the other Fizzl services. The usage log counts RLUSD in dollars. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TsD5haPKxeWjYmErHzvU48 --- package-lock.json | 157 ++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + server.js | 12 +++- usage-log.js | 19 ++++-- xrpl-facilitator.js | 23 +++++++ 5 files changed, 207 insertions(+), 5 deletions(-) create mode 100644 xrpl-facilitator.js diff --git a/package-lock.json b/package-lock.json index 64498d9..05f1043 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@x402/express": "^2.27.0", "@x402/extensions": "^2.27.0", "@x402/svm": "^2.27.0", + "@x402/xrpl": "^2.28.0", "dotenv": "^16.4.5", "express": "^4.19.2", "viem": "^2.56.8", @@ -1319,6 +1320,70 @@ "@solana/kit": ">=5.1.0" } }, + "node_modules/@x402/xrpl": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/@x402/xrpl/-/xrpl-2.28.0.tgz", + "integrity": "sha512-zAt5E1uEskr0P42Co6fBjWIK0OTMedfNcAjb7GK5gK4aZuvfU78UD5QkwxC92gDb1rqMTYmJigg9tznWK8YBTA==", + "license": "Apache-2.0", + "dependencies": { + "@x402/core": "~2.28.0", + "xrpl": "^4.6.0" + } + }, + "node_modules/@x402/xrpl/node_modules/@x402/core": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.28.0.tgz", + "integrity": "sha512-Da/7zKxCKqpBoB4Msc9tFgkAv7tJTD8nG+DSSm+QdXAyJS4PQsmgVSKe17rDJTtlu0pwNbLcOje6MyQ6kaDkig==", + "license": "Apache-2.0", + "dependencies": { + "zod": "^3.24.2" + } + }, + "node_modules/@x402/xrpl/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@xrplf/isomorphic": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@xrplf/isomorphic/-/isomorphic-1.0.2.tgz", + "integrity": "sha512-ncZUdMXr6VlSXtdoiDi0jTH+gBrgGxwVeEidhoegII3PmyErbQsyj6e+j7acmR4LW/lvBkPkzb9QzRfJH0n3rA==", + "license": "ISC", + "dependencies": { + "@noble/hashes": "^2.0.1", + "eventemitter3": "5.0.1", + "ws": "^8.20.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@xrplf/isomorphic/node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@xrplf/secret-numbers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@xrplf/secret-numbers/-/secret-numbers-2.0.0.tgz", + "integrity": "sha512-z3AOibRTE9E8MbjgzxqMpG1RNaBhQ1jnfhNCa1cGf2reZUJzPMYs4TggQTc7j8+0WyV3cr7y/U8Oz99SXIkN5Q==", + "license": "ISC", + "dependencies": { + "@xrplf/isomorphic": "^1.0.1", + "ripple-keypairs": "^2.0.0" + } + }, "node_modules/abitype": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", @@ -1452,6 +1517,15 @@ "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", "license": "MIT" }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/body-parser": { "version": "1.20.8", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", @@ -1806,6 +1880,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", @@ -2376,6 +2456,62 @@ "node": ">=0.10.0" } }, + "node_modules/ripple-address-codec": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ripple-address-codec/-/ripple-address-codec-5.0.1.tgz", + "integrity": "sha512-JQHLKuVJV8lv9Qobmn4aUM2Dpv9WRRLKnNWfM8tN02fAbUtG8mUPsu9q9UYX8P76G4qzytEc5ZKMp/3JggNYmw==", + "license": "ISC", + "dependencies": { + "@scure/base": "^2.0.0", + "@xrplf/isomorphic": "^1.0.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ripple-address-codec/node_modules/@scure/base": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.4.0.tgz", + "integrity": "sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ripple-binary-codec": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/ripple-binary-codec/-/ripple-binary-codec-2.11.0.tgz", + "integrity": "sha512-qad61CYAqIi8H7DuPnuDpj40wrXY8wYrc1YbuDcA9G/NZG0Uu8unHNbTC6Itz8SlOSL0P/nF/8HTFW/1caVlTg==", + "license": "ISC", + "dependencies": { + "@xrplf/isomorphic": "^1.0.2", + "bignumber.js": "^10.0.2", + "ripple-address-codec": "^5.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ripple-binary-codec/node_modules/bignumber.js": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-10.0.2.tgz", + "integrity": "sha512-E8Wp9O06QA6lneJ4aRUXKYf/1GIomqUEmUMwtIOMtDxf1U52ffJY+y7JBk/8wRafA8qOIqLnXQGqonYXZdBnFQ==", + "license": "MIT" + }, + "node_modules/ripple-keypairs": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ripple-keypairs/-/ripple-keypairs-2.1.0.tgz", + "integrity": "sha512-B+Pf4A2EOQhU8LavUVz+ilQsS8yLXivmDFrJ0FswmCjL3bMHNvFovfqQVdffsJOZnIVHQDNehy9uiKJBfQSnbw==", + "license": "ISC", + "dependencies": { + "@noble/curves": "^1.0.0", + "@xrplf/isomorphic": "^1.0.1", + "ripple-address-codec": "^5.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2667,6 +2803,27 @@ } } }, + "node_modules/xrpl": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/xrpl/-/xrpl-4.7.0.tgz", + "integrity": "sha512-HCGf6Kk8dcJZmk4Fc42P2jb3SKRLcznr5fEtHsOzMy2TDze/EOuX93lWgrnFKb9q0Nxqoz5Z96WYcC6p1+7+sg==", + "license": "ISC", + "dependencies": { + "@scure/bip32": "^1.3.1", + "@scure/bip39": "^1.2.1", + "@xrplf/isomorphic": "^1.0.1", + "@xrplf/secret-numbers": "^2.0.0", + "bignumber.js": "^9.0.0", + "eventemitter3": "^5.0.1", + "fast-json-stable-stringify": "^2.1.0", + "ripple-address-codec": "^5.0.0", + "ripple-binary-codec": "^2.7.0", + "ripple-keypairs": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/zod": { "version": "4.6.5", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", diff --git a/package.json b/package.json index 1331c8d..67dc53c 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@x402/express": "^2.27.0", "@x402/extensions": "^2.27.0", "@x402/svm": "^2.27.0", + "@x402/xrpl": "^2.28.0", "dotenv": "^16.4.5", "express": "^4.19.2", "viem": "^2.56.8", diff --git a/server.js b/server.js index 7a41dfe..3b01642 100644 --- a/server.js +++ b/server.js @@ -3,6 +3,8 @@ const express = require('express'); const path = require('path'); const { paymentMiddleware } = require('@x402/express'); const { x402ResourceServer, HTTPFacilitatorClient } = require('@x402/core/server'); +const { ExactXrplScheme } = require('@x402/xrpl/exact/server'); +const { createXrplFacilitator, XRPL } = require('./xrpl-facilitator'); const { ExactEvmScheme } = require('@x402/evm/exact/server'); const { ExactSvmScheme } = require('@x402/svm/exact/server'); const { createFacilitatorConfig } = require('@coinbase/x402'); @@ -104,9 +106,16 @@ if (x402PayTo) { if (usingCdp) facilitators.push(new HTTPFacilitatorClient(createFacilitatorConfig(process.env.CDP_API_KEY_ID, process.env.CDP_API_KEY_SECRET))); facilitators.push(new HTTPFacilitatorClient({ url: x402FacilitatorUrl })); + // RLUSD on the XRP Ledger (mainnet only), the same dollar price: XRPL_PAY_TO, else Frits's account (it has the + // RLUSD trust line); XRPL_PAY_TO=off turns it off. Verified and submitted in this process (xrpl-facilitator.js). + const xrplPayTo = x402Caip2Network !== CAIP2_NETWORKS.base || process.env.XRPL_PAY_TO === 'off' ? null + : (process.env.XRPL_PAY_TO || '').trim() || 'r9xmBsRr8Ao7jRgjjxreMiAwGiCK2FGwqw'; + if (xrplPayTo) facilitators.push(createXrplFacilitator({ wsUrl: process.env.XRPL_WS_URL || 'wss://xrplcluster.com' })); + const x402Server = new x402ResourceServer(facilitators); x402Server.register('eip155:*', new ExactEvmScheme()); if (x402PayToSolana) x402Server.register(x402SolanaNetwork, new ExactSvmScheme()); + if (xrplPayTo) x402Server.register(XRPL, new ExactXrplScheme()); // Diagnostic logging only — doesn't change behavior. The 402 response a client // sees on verify/settle failure carries no detail, so log the real reason here. @@ -175,7 +184,8 @@ if (x402PayTo) { }); const acceptsFor = (price) => [ { scheme: 'exact', price, network: x402Caip2Network, payTo: x402PayTo }, - ...(x402PayToSolana ? [{ scheme: 'exact', price, network: x402SolanaNetwork, payTo: x402PayToSolana }] : []) + ...(x402PayToSolana ? [{ scheme: 'exact', price, network: x402SolanaNetwork, payTo: x402PayToSolana }] : []), + ...(xrplPayTo ? [{ scheme: 'exact', price, network: XRPL, payTo: xrplPayTo, extra: { invoiceId: 'plaintext.fizzl.eu' } }] : []) ]; // MPP (mpp-pay.js): the same Base USDC payment for agents that speak MPP, through the diff --git a/usage-log.js b/usage-log.js index 47556fa..851adaf 100644 --- a/usage-log.js +++ b/usage-log.js @@ -44,7 +44,19 @@ const NETWORK_NAMES = { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': 'solana', 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1': 'solana-devnet', 'eip155:4217': 'Tempo', + 'xrpl:0': 'xrpl', + 'xrpl:1': 'xrpl-testnet', }; +const RLUSD_HEX = '524C555344000000000000000000000000000000'; + +// USD value of a payment requirement: USDC in atomic units (6 decimals), or RLUSD on the XRP Ledger, +// where amounts are decimal values ("0.02"). Null for anything else. +function usdOf(accepted) { + const amount = accepted && (accepted.amount || accepted.maxAmountRequired); + if (amount === undefined || amount === null) return null; + if (/^xrpl:/.test(String(accepted.network || ''))) return accepted.asset === RLUSD_HEX && /^\d+(\.\d+)?$/.test(String(amount)) ? Number(amount) : null; + return /^\d+$/.test(String(amount)) ? Number(amount) / 1e6 : null; +} function decodeBase64Json(value) { if (!value) return null; @@ -64,9 +76,8 @@ function paymentOf(req, res) { const payload = decodeBase64Json(req.headers['payment-signature'] || req.headers['x-payment']); const accepted = payload && (payload.accepted || payload.requirements); const network = settlement.network || (accepted && accepted.network) || null; - const atomic = accepted && (accepted.amount || accepted.maxAmountRequired); return { - usd: atomic !== undefined && atomic !== null && /^\d+$/.test(String(atomic)) ? Number(atomic) / 1e6 : null, + usd: usdOf(accepted), network: NETWORK_NAMES[network] || network, payer: settlement.payer || null, tx: settlement.transaction || null, @@ -285,11 +296,11 @@ function mcpPayment(requestBody, responseBody) { const accepted = paid && paid.accepted; const network = settlement.network || (accepted && accepted.network) || null; return { - usd: accepted && /^\d+$/.test(String(accepted.amount)) ? Number(accepted.amount) / 1e6 : null, + usd: usdOf(accepted), network: NETWORK_NAMES[network] || network, payer: settlement.payer || null, tx: settlement.transaction || null, }; } -module.exports = { createUsageLog, paymentOf, attemptedNetwork, mcpToolCall, mcpPayment, clip, agentOf, visitorOf }; +module.exports = { createUsageLog, paymentOf, attemptedNetwork, usdOf, mcpToolCall, mcpPayment, clip, agentOf, visitorOf }; diff --git a/xrpl-facilitator.js b/xrpl-facilitator.js new file mode 100644 index 0000000..497ad60 --- /dev/null +++ b/xrpl-facilitator.js @@ -0,0 +1,23 @@ +// In-process facilitator for x402 payments on the XRP Ledger (xrpl:0, RLUSD), as in ichimoku-signal and +// presign-guard. The payer signs a complete XRPL Payment and pays the XRPL fee itself; the facilitator verifies +// it (signature, destination, amount, sequence, InvoiceID, simulation) and submits it, so no key or balance is +// needed here. t54's hosted facilitator rejected a payment that @x402/xrpl accepts (testnet, Oct 2026), so it is +// not used. getSupported() needs no network: the service starts even when the XRPL node is down, and only XRPL +// payments fail then (nothing is charged). +'use strict'; + +const { x402Facilitator } = require('@x402/core/facilitator'); +const { ExactXrplScheme } = require('@x402/xrpl/exact/facilitator'); + +const XRPL = 'xrpl:0'; + +function createXrplFacilitator({ network = XRPL, wsUrl = 'wss://xrplcluster.com', scheme } = {}) { + const facilitator = new x402Facilitator().register(network, scheme || new ExactXrplScheme({ wsUrlByNetwork: { [network]: wsUrl } })); + return { + getSupported: async () => facilitator.getSupported(), + verify: (payload, requirements) => facilitator.verify(payload, requirements), + settle: (payload, requirements) => facilitator.settle(payload, requirements), + }; +} + +module.exports = { createXrplFacilitator, XRPL }; From 633f050360420f26618f4047e8f43acb6fd210b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:48:32 +0000 Subject: [PATCH 08/19] XRPL: also accept payers built on t54's SDK Agents built on t54's x402-xrpl / Xrpl.X402 SDK (most of the XRPL AI Hub) sign with the x402 SourceTag 804681468, bind the invoice in Memos and send { signedTxBlob, invoiceId }. @x402/xrpl refuses Memos, so those payments now go to t54's hosted facilitator, which verified and settled such a payment on testnet; standard @x402/xrpl payments stay in-process (also re-tested on testnet with the SourceTag in the requirements). Every XRPL accept now carries extra.sourceTag. XRPL_T54_URL=off keeps everything local. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TsD5haPKxeWjYmErHzvU48 --- server.js | 4 ++-- xrpl-facilitator.js | 23 +++++++++++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/server.js b/server.js index 3b01642..ce8eab1 100644 --- a/server.js +++ b/server.js @@ -4,7 +4,7 @@ const path = require('path'); const { paymentMiddleware } = require('@x402/express'); const { x402ResourceServer, HTTPFacilitatorClient } = require('@x402/core/server'); const { ExactXrplScheme } = require('@x402/xrpl/exact/server'); -const { createXrplFacilitator, XRPL } = require('./xrpl-facilitator'); +const { createXrplFacilitator, XRPL, X402_SOURCE_TAG } = require('./xrpl-facilitator'); const { ExactEvmScheme } = require('@x402/evm/exact/server'); const { ExactSvmScheme } = require('@x402/svm/exact/server'); const { createFacilitatorConfig } = require('@coinbase/x402'); @@ -185,7 +185,7 @@ if (x402PayTo) { const acceptsFor = (price) => [ { scheme: 'exact', price, network: x402Caip2Network, payTo: x402PayTo }, ...(x402PayToSolana ? [{ scheme: 'exact', price, network: x402SolanaNetwork, payTo: x402PayToSolana }] : []), - ...(xrplPayTo ? [{ scheme: 'exact', price, network: XRPL, payTo: xrplPayTo, extra: { invoiceId: 'plaintext.fizzl.eu' } }] : []) + ...(xrplPayTo ? [{ scheme: 'exact', price, network: XRPL, payTo: xrplPayTo, extra: { invoiceId: 'plaintext.fizzl.eu', sourceTag: X402_SOURCE_TAG } }] : []) ]; // MPP (mpp-pay.js): the same Base USDC payment for agents that speak MPP, through the diff --git a/xrpl-facilitator.js b/xrpl-facilitator.js index 497ad60..9bfd5fb 100644 --- a/xrpl-facilitator.js +++ b/xrpl-facilitator.js @@ -1,23 +1,34 @@ // In-process facilitator for x402 payments on the XRP Ledger (xrpl:0, RLUSD), as in ichimoku-signal and // presign-guard. The payer signs a complete XRPL Payment and pays the XRPL fee itself; the facilitator verifies // it (signature, destination, amount, sequence, InvoiceID, simulation) and submits it, so no key or balance is -// needed here. t54's hosted facilitator rejected a payment that @x402/xrpl accepts (testnet, Oct 2026), so it is -// not used. getSupported() needs no network: the service starts even when the XRPL node is down, and only XRPL +// needed here. Payments in t54's format (from its SDK) go to t54's hosted facilitator, see below. +// getSupported() needs no network: the service starts even when the XRPL node is down, and only XRPL // payments fail then (nothing is charged). 'use strict'; const { x402Facilitator } = require('@x402/core/facilitator'); const { ExactXrplScheme } = require('@x402/xrpl/exact/facilitator'); +const { HTTPFacilitatorClient } = require('@x402/core/server'); + +// The XRPL SourceTag for x402 payments: t54-built payers sign with it, @x402/xrpl payers may. +const X402_SOURCE_TAG = 804681468; +// Two kinds of XRPL payers exist. Clients built on @x402/xrpl send { signedTxBlob } and are verified and +// submitted here. Clients built on t54's SDK (x402-xrpl, Xrpl.X402: most agents in the XRPL AI Hub) send +// { signedTxBlob, invoiceId } and bind the invoice in Memos, which @x402/xrpl refuses; those payments go to +// t54's hosted facilitator, which accepts them (both paths settled on testnet, Oct 2026). t54Url "off" = local only. +const isT54Payload = (payload) => Boolean(payload && payload.payload && typeof payload.payload.invoiceId === 'string'); + const XRPL = 'xrpl:0'; -function createXrplFacilitator({ network = XRPL, wsUrl = 'wss://xrplcluster.com', scheme } = {}) { +function createXrplFacilitator({ network = XRPL, wsUrl = 'wss://xrplcluster.com', scheme, t54Url = process.env.XRPL_T54_URL || 'https://xrpl-facilitator-mainnet.t54.ai', t54 } = {}) { const facilitator = new x402Facilitator().register(network, scheme || new ExactXrplScheme({ wsUrlByNetwork: { [network]: wsUrl } })); + const remote = t54 || (t54Url && t54Url !== 'off' ? new HTTPFacilitatorClient({ url: t54Url }) : null); return { getSupported: async () => facilitator.getSupported(), - verify: (payload, requirements) => facilitator.verify(payload, requirements), - settle: (payload, requirements) => facilitator.settle(payload, requirements), + verify: (payload, requirements) => (remote && isT54Payload(payload) ? remote.verify(payload, requirements) : facilitator.verify(payload, requirements)), + settle: (payload, requirements) => (remote && isT54Payload(payload) ? remote.settle(payload, requirements) : facilitator.settle(payload, requirements)), }; } -module.exports = { createXrplFacilitator, XRPL }; +module.exports = { createXrplFacilitator, XRPL, X402_SOURCE_TAG, isT54Payload }; From fdab3b30fe0432c7914a37ebc39cccfb73a96385 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 08:22:40 +0000 Subject: [PATCH 09/19] SEO: canonical tag, robots.txt and sitemap.xml for the homepage Google crawled the homepage but has not indexed it yet; these give it a clear signal which URL to index. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TsD5haPKxeWjYmErHzvU48 --- public/index.html | 1 + public/robots.txt | 4 ++++ public/sitemap.xml | 2 ++ 3 files changed, 7 insertions(+) create mode 100644 public/robots.txt create mode 100644 public/sitemap.xml diff --git a/public/index.html b/public/index.html index 8f8fd82..53c7aa9 100644 --- a/public/index.html +++ b/public/index.html @@ -8,6 +8,7 @@ + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..8b3ebf5 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://plaintext.fizzl.eu/sitemap.xml diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 0000000..fd78a91 --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,2 @@ + +https://plaintext.fizzl.eu/ From 823b1328ef5a7b6c22d5d31b7b2d8b9abd493ea3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 08:33:56 +0000 Subject: [PATCH 10/19] CI: run the tests on every pull request and on main Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TsD5haPKxeWjYmErHzvU48 --- .github/workflows/ci.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e4f0997 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +# Runs the tests on every pull request and on main, so a PR shows a green check before merging. +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm test From d81e01e5883d3a24f5598e52f21adf2e90dfd5d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 09:41:05 +0000 Subject: [PATCH 11/19] Usage log: record why an MPP credential was refused A refused MPP credential is logged as payment_failed with protocol mpp; it now also carries reason (the problem detail sent back, max 160 characters), so a client that can't pay us shows what went wrong. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TsD5haPKxeWjYmErHzvU48 --- mpp-pay.js | 1 + usage-log.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/mpp-pay.js b/mpp-pay.js index 14ca5e1..9d7f4a2 100644 --- a/mpp-pay.js +++ b/mpp-pay.js @@ -266,6 +266,7 @@ function createMppPay({ secret, realm, recipient, routes, facilitator, tempo = n } function refuse(req, res, route, reason) { + res.locals.mppRefused = String(reason).slice(0, 160); // for the usage log: why the credential was refused res.status(402); res.set('WWW-Authenticate', headersFor(route)); res.set('Cache-Control', 'no-store'); diff --git a/usage-log.js b/usage-log.js index 851adaf..1813e26 100644 --- a/usage-log.js +++ b/usage-log.js @@ -264,7 +264,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' } : {}), ...(attemptedNetwork(req) ? { network: attemptedNetwork(req) } : {}) } : {}) } : {}), + ...(quote ? { quote: true, ...(offered ? { payment_failed: true, ...(mppOffered ? { protocol: 'mpp', ...(res.locals && res.locals.mppRefused ? { reason: res.locals.mppRefused } : {}) } : {}), ...(attemptedNetwork(req) ? { network: attemptedNetwork(req) } : {}) } : {}) } : {}), agent: agentOf(req.headers['user-agent']), visitor: visitorOf(req.ip || (req.socket && req.socket.remoteAddress), visitorSecret), input: described.input, From 84c2f4e5e3a72bd4f951444509a5826950aab225 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 19:40:13 +0000 Subject: [PATCH 12/19] Pay in USDC on Algorand (ALGORAND_PAY_TO) With ALGORAND_PAY_TO set (an Algorand account with the USDC opt-in), both paid routes also offer the same dollar price in USDC (ASA 31566704) on Algorand mainnet, settled by GoPlausible's public facilitator, which pays the Algorand fee. It sits before the URL facilitator and offers only Algorand, so PayAI never claims it. Off without the variable or with "off". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TsD5haPKxeWjYmErHzvU48 --- package-lock.json | 249 ++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + render.yaml | 2 + server.js | 25 ++++- test/algorand.test.js | 69 ++++++++++++ usage-log.js | 1 + 6 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 test/algorand.test.js diff --git a/package-lock.json b/package-lock.json index 05f1043..738636d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@coinbase/x402": "^2.1.0", "@modelcontextprotocol/server": "^2.0.0", + "@x402/avm": "^2.28.0", "@x402/core": "^2.27.0", "@x402/evm": "^2.27.0", "@x402/express": "^2.27.0", @@ -31,6 +32,108 @@ "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", "license": "MIT" }, + "node_modules/@algorandfoundation/algokit-utils": { + "version": "10.0.0-alpha.46", + "resolved": "https://registry.npmjs.org/@algorandfoundation/algokit-utils/-/algokit-utils-10.0.0-alpha.46.tgz", + "integrity": "sha512-Lx56ET6RmrDCFGwx6qyIr7YRX3Q3MyKgqkNeumLkFHXVkqMnXnK1aLeEbV59EOK8UHQ5UPJofSj1zvRMm9Du7w==", + "license": "MIT", + "dependencies": { + "@algorandfoundation/xhd-wallet-api": "2.0.0-canary.1", + "@noble/curves": "^2.0.1", + "@noble/ed25519": "^3.0.0", + "@noble/hashes": "^2.0.1", + "algorand-msgpack": "^1.1.0", + "buffer": "^6.0.3", + "dotenv": "^16.4.5", + "hi-base32": "^0.5.1", + "json-bigint": "^1.0.0", + "tweetnacl": "^1.0.3", + "vlq": "^2.0.4" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@algorandfoundation/algokit-utils/node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@algorandfoundation/algokit-utils/node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@algorandfoundation/xhd-wallet-api": { + "version": "2.0.0-canary.1", + "resolved": "https://registry.npmjs.org/@algorandfoundation/xhd-wallet-api/-/xhd-wallet-api-2.0.0-canary.1.tgz", + "integrity": "sha512-U6rSWMO6FKDLvMxWtfKyW5ctqSy2NrONWTU/jOA+BX0SGru0GPXsxwzFdcPa5w+X055V/xVVsr8eA3xA+m/5jA==", + "license": "Apache-2.0", + "dependencies": { + "@noble/ciphers": "^2.0.0", + "@noble/curves": "^2.0.0", + "@noble/hashes": "^2.0.0", + "ajv": "^8.17.1", + "algo-msgpack-with-bigint": "^2.1.1", + "bn.js": "^5.2.2" + } + }, + "node_modules/@algorandfoundation/xhd-wallet-api/node_modules/@noble/ciphers": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz", + "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@algorandfoundation/xhd-wallet-api/node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@algorandfoundation/xhd-wallet-api/node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@coinbase/cdp-sdk": { "version": "1.56.0", "resolved": "https://registry.npmjs.org/@coinbase/cdp-sdk/-/cdp-sdk-1.56.0.tgz", @@ -184,6 +287,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@noble/ed25519": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.2.0.tgz", + "integrity": "sha512-criDgRlnUA09hchYrTy/JUWPIEap5rZxQe6wDWzRx51oWWpDRcUpuNzlgPxDJaOK6AsW9c0wKcj3rKRv6t+bPQ==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -1221,6 +1333,34 @@ } } }, + "node_modules/@x402/avm": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/@x402/avm/-/avm-2.28.0.tgz", + "integrity": "sha512-ww6EPNQvWKsQdjDFzAA2w15rGDFo+5sRZk9iFerLIMM0URa/A70nPoh4FerH9Bss/lBjUcKG01wYI+i6HBK8/g==", + "license": "Apache-2.0", + "dependencies": { + "@algorandfoundation/algokit-utils": "10.0.0-alpha.46", + "@x402/core": "~2.28.0" + } + }, + "node_modules/@x402/avm/node_modules/@x402/core": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.28.0.tgz", + "integrity": "sha512-Da/7zKxCKqpBoB4Msc9tFgkAv7tJTD8nG+DSSm+QdXAyJS4PQsmgVSKe17rDJTtlu0pwNbLcOje6MyQ6kaDkig==", + "license": "Apache-2.0", + "dependencies": { + "zod": "^3.24.2" + } + }, + "node_modules/@x402/avm/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@x402/core": { "version": "2.27.0", "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.27.0.tgz", @@ -1469,6 +1609,24 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/algo-msgpack-with-bigint": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/algo-msgpack-with-bigint/-/algo-msgpack-with-bigint-2.1.1.tgz", + "integrity": "sha512-F1tGh056XczEaEAqu7s+hlZUDWwOBT70Eq0lfMpBP2YguSQVyxRbprLq5rELXKQOyOaixTWYhMeMQMzP0U5FoQ==", + "license": "ISC", + "engines": { + "node": ">= 10" + } + }, + "node_modules/algorand-msgpack": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/algorand-msgpack/-/algorand-msgpack-1.1.0.tgz", + "integrity": "sha512-08k7pBQnkaUB5p+jL7f1TRaUIlTSDE0cesFu1mD7llLao+1cAhtvvZmGE3OnisTd0xOn118QMw74SRqddqaYvw==", + "license": "ISC", + "engines": { + "node": ">= 14" + } + }, "node_modules/apg-js": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/apg-js/-/apg-js-4.4.0.tgz", @@ -1517,6 +1675,26 @@ "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -1526,6 +1704,12 @@ "node": "*" } }, + "node_modules/bn.js": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.5.tgz", + "integrity": "sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.8", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", @@ -1559,6 +1743,30 @@ "base-x": "^5.0.0" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2071,6 +2279,12 @@ "node": ">= 0.4" } }, + "node_modules/hi-base32": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/hi-base32/-/hi-base32-0.5.1.tgz", + "integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==", + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2139,6 +2353,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2196,6 +2430,15 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -2782,6 +3025,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/vlq": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/vlq/-/vlq-2.0.4.tgz", + "integrity": "sha512-aodjPa2wPQFkra1G8CzJBTHXhgk3EVSwxSWXNPr1fgdFLUb8kvLV1iEb6rFgasIsjP82HWI6dsb5Io26DDnasA==", + "license": "MIT" + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", diff --git a/package.json b/package.json index 67dc53c..ef06245 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dependencies": { "@coinbase/x402": "^2.1.0", "@modelcontextprotocol/server": "^2.0.0", + "@x402/avm": "^2.28.0", "@x402/core": "^2.27.0", "@x402/evm": "^2.27.0", "@x402/express": "^2.27.0", diff --git a/render.yaml b/render.yaml index d3668bd..62c28cc 100644 --- a/render.yaml +++ b/render.yaml @@ -16,6 +16,8 @@ services: sync: false - key: X402_PAY_TO_SOLANA sync: false + - key: ALGORAND_PAY_TO + sync: false - key: X402_NETWORK sync: false - key: X402_CHECK_PRICE diff --git a/server.js b/server.js index ce8eab1..95930f3 100644 --- a/server.js +++ b/server.js @@ -4,6 +4,7 @@ const path = require('path'); const { paymentMiddleware } = require('@x402/express'); const { x402ResourceServer, HTTPFacilitatorClient } = require('@x402/core/server'); const { ExactXrplScheme } = require('@x402/xrpl/exact/server'); +const { ExactAvmScheme } = require('@x402/avm/exact/server'); const { createXrplFacilitator, XRPL, X402_SOURCE_TAG } = require('./xrpl-facilitator'); const { ExactEvmScheme } = require('@x402/evm/exact/server'); const { ExactSvmScheme } = require('@x402/svm/exact/server'); @@ -79,6 +80,7 @@ function mirrorChallengeIntoBody(req, res, next) { next(); } +const ALGORAND = 'algorand:wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8='; const x402PayTo = process.env.X402_PAY_TO; if (x402PayTo) { const x402Network = process.env.X402_NETWORK || 'base-sepolia'; @@ -104,6 +106,23 @@ if (x402PayTo) { const usingCdp = Boolean(process.env.CDP_API_KEY_ID && process.env.CDP_API_KEY_SECRET); const facilitators = []; if (usingCdp) facilitators.push(new HTTPFacilitatorClient(createFacilitatorConfig(process.env.CDP_API_KEY_ID, process.env.CDP_API_KEY_SECRET))); + + // USDC on Algorand (ASA 31566704), the same dollar price, settled by GoPlausible's public facilitator (it also pays + // the Algorand fee). Mainnet only, and only with ALGORAND_PAY_TO: an Algorand account with the USDC opt-in; "off" + // turns it off. Put before the URL facilitator, offering only Algorand, so that one never claims Algorand. + const algorandPayTo = x402Caip2Network !== CAIP2_NETWORKS.base || !(process.env.ALGORAND_PAY_TO || '').trim() || process.env.ALGORAND_PAY_TO.trim() === 'off' ? null + : process.env.ALGORAND_PAY_TO.trim(); + if (algorandPayTo) { + const goplausible = new HTTPFacilitatorClient({ url: process.env.ALGORAND_FACILITATOR_URL || 'https://facilitator.goplausible.xyz' }); + facilitators.push({ + verify: (...args) => goplausible.verify(...args), + settle: (...args) => goplausible.settle(...args), + async getSupported() { + const supported = await goplausible.getSupported(); + return { ...supported, kinds: supported.kinds.filter((k) => String(k.network).startsWith('algorand:')) }; + } + }); + } facilitators.push(new HTTPFacilitatorClient({ url: x402FacilitatorUrl })); // RLUSD on the XRP Ledger (mainnet only), the same dollar price: XRPL_PAY_TO, else Frits's account (it has the @@ -116,6 +135,7 @@ if (x402PayTo) { x402Server.register('eip155:*', new ExactEvmScheme()); if (x402PayToSolana) x402Server.register(x402SolanaNetwork, new ExactSvmScheme()); if (xrplPayTo) x402Server.register(XRPL, new ExactXrplScheme()); + if (algorandPayTo) x402Server.register(ALGORAND, new ExactAvmScheme()); // Diagnostic logging only — doesn't change behavior. The 402 response a client // sees on verify/settle failure carries no detail, so log the real reason here. @@ -185,7 +205,8 @@ if (x402PayTo) { const acceptsFor = (price) => [ { scheme: 'exact', price, network: x402Caip2Network, payTo: x402PayTo }, ...(x402PayToSolana ? [{ scheme: 'exact', price, network: x402SolanaNetwork, payTo: x402PayToSolana }] : []), - ...(xrplPayTo ? [{ scheme: 'exact', price, network: XRPL, payTo: xrplPayTo, extra: { invoiceId: 'plaintext.fizzl.eu', sourceTag: X402_SOURCE_TAG } }] : []) + ...(xrplPayTo ? [{ scheme: 'exact', price, network: XRPL, payTo: xrplPayTo, extra: { invoiceId: 'plaintext.fizzl.eu', sourceTag: X402_SOURCE_TAG } }] : []), + ...(algorandPayTo ? [{ scheme: 'exact', price, network: ALGORAND, payTo: algorandPayTo }] : []) ]; // MPP (mpp-pay.js): the same Base USDC payment for agents that speak MPP, through the @@ -231,7 +252,7 @@ if (x402PayTo) { } }, x402Server)))); - const networks = [x402Caip2Network, ...(x402PayToSolana ? [x402SolanaNetwork] : [])].join(' + '); + const networks = [x402Caip2Network, ...(x402PayToSolana ? [x402SolanaNetwork] : []), ...(xrplPayTo ? [XRPL] : []), ...(algorandPayTo ? [ALGORAND] : [])].join(' + '); console.log(`x402 paywall enabled for /api/check-wallet and /api/explain on ${networks} using facilitator ${usingCdp ? `Coinbase CDP (fallback ${x402FacilitatorUrl})` : x402FacilitatorUrl}${mpp ? ', MPP on' : ''}`); } else { console.log('x402 paywall disabled: set X402_PAY_TO in your environment to enable it.'); diff --git a/test/algorand.test.js b/test/algorand.test.js new file mode 100644 index 0000000..d58023f --- /dev/null +++ b/test/algorand.test.js @@ -0,0 +1,69 @@ +// Paying in USDC on Algorand: offered on both paid routes only with ALGORAND_PAY_TO (mainnet), settled by the +// Algorand facilitator (GoPlausible in production, a stand-in here), never by the URL facilitator. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); +const { usdOf } = require('../usage-log'); + +const ALGORAND = 'algorand:wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8='; +const PAY_TO = 'SGLTUPAC7TKGKNNXKNPQ2QZCC7NJSLAKYZ7O7NOGGAPXWBFZTOLTPMSPPI'; +const FEE_PAYER = 'ZMFK2OI7ZBD2U27ISERZC4S6LKM6WMFJPZQ4MYNJDZ2VNBNMBA67RA22AA'; + +// A facilitator that says it supports the given networks. +function standInFacilitator(kinds) { + return new Promise((ok) => { + const server = http.createServer((req, res) => { + res.setHeader('content-type', 'application/json'); + if (req.url.endsWith('/supported')) return res.end(JSON.stringify({ kinds, extensions: [], signers: {} })); + res.end(JSON.stringify({ isValid: false, success: false })); + }).listen(0, () => ok(server)); + }); +} + +// Starts server.js with the env, asks a paid route for its 402 and returns the payment options. +async function acceptsWith(env) { + const payai = await standInFacilitator([{ x402Version: 2, scheme: 'exact', network: 'eip155:8453' }, { x402Version: 2, scheme: 'exact', network: ALGORAND, extra: { feePayer: 'NOT-THIS-ONE' } }]); + const algo = await standInFacilitator([{ x402Version: 2, scheme: 'exact', network: ALGORAND, extra: { feePayer: FEE_PAYER } }, { x402Version: 2, scheme: 'exact', network: 'eip155:8453' }]); + const port = 20000 + Math.floor(Math.random() * 20000); + const child = spawn(process.execPath, ['server.js'], { + cwd: path.join(__dirname, '..'), + env: { PATH: process.env.PATH, PORT: String(port), X402_PAY_TO: '0x6B0F4651eD42893ab58139938175E4a69f175F25', X402_NETWORK: 'base', XRPL_PAY_TO: 'off', X402_FACILITATOR_URL: `http://127.0.0.1:${payai.address().port}`, ALGORAND_FACILITATOR_URL: `http://127.0.0.1:${algo.address().port}`, ...env }, + stdio: 'ignore', + }); + try { + for (let i = 0; i < 50; i++) { + try { + const res = await fetch(`http://127.0.0.1:${port}/api/check-wallet`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }); + if (res.status === 402) return JSON.parse(Buffer.from(res.headers.get('payment-required'), 'base64').toString()).accepts; + } catch { /* not up yet */ } + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error('no 402'); + } finally { + child.kill(); + payai.close(); + algo.close(); + } +} + +test('with ALGORAND_PAY_TO: $0.04 in USDC (ASA 31566704) on Algorand, with the Algorand facilitator\'s fee payer', async () => { + const accepts = await acceptsWith({ ALGORAND_PAY_TO: PAY_TO }); + const algo = accepts.find((a) => a.network === ALGORAND); + assert.ok(algo, 'Algorand offered'); + assert.equal(algo.payTo, PAY_TO); + assert.equal(algo.asset, '31566704'); + assert.equal(algo.amount, '40000'); + assert.equal(algo.extra.feePayer, FEE_PAYER, 'not the URL facilitator\'s'); + assert.ok(accepts.some((a) => a.network === 'eip155:8453'), 'Base still offered'); +}); + +test('without ALGORAND_PAY_TO, or "off": no Algorand', async () => { + assert.ok(!(await acceptsWith({})).some((a) => a.network === ALGORAND)); + assert.ok(!(await acceptsWith({ ALGORAND_PAY_TO: 'off' })).some((a) => a.network === ALGORAND)); +}); + +test('usage log: Algorand USDC counts in dollars', () => { + assert.equal(usdOf({ network: ALGORAND, asset: '31566704', amount: '40000' }), 0.04); +}); diff --git a/usage-log.js b/usage-log.js index 1813e26..52b9b66 100644 --- a/usage-log.js +++ b/usage-log.js @@ -45,6 +45,7 @@ const NETWORK_NAMES = { 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1': 'solana-devnet', 'eip155:4217': 'Tempo', 'xrpl:0': 'xrpl', + 'algorand:wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=': 'algorand', 'xrpl:1': 'xrpl-testnet', }; const RLUSD_HEX = '524C555344000000000000000000000000000000'; From aa499c8ec6e368e42275ee738ba4b4780d3a8b94 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 04:28:29 +0000 Subject: [PATCH 13/19] Name the seller in the 402: x402-merchant extension GoPlausible's x402 catalog reads the x402-merchant extension (name, website, logo, categories) when it settles a payment and shows it on the merchant, one per pay-to address. Both paid routes now carry it, the same on every fizzl.eu service. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TsD5haPKxeWjYmErHzvU48 --- merchant.js | 30 ++++++++++++++++++++++++++++++ server.js | 5 +++-- test/algorand.test.js | 8 +++++++- 3 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 merchant.js diff --git a/merchant.js b/merchant.js new file mode 100644 index 0000000..c4ba2b3 --- /dev/null +++ b/merchant.js @@ -0,0 +1,30 @@ +// Who sells this, for x402 discovery lists. GoPlausible's catalog reads the +// "x402-merchant" extension of a 402 challenge (name, website, logo, categories) +// when it settles a payment, and shows it on the merchant: one per pay-to +// address, which all four fizzl.eu services share on Algorand. So the info is +// the same on every fizzl.eu service. +const MERCHANT = { + name: 'Fizzl', + website: 'https://fizzl.eu', + logo: 'https://fizzl.eu/logo-512.png', + categories: ['ai-agents', 'security', 'trading-signals', 'developer-tools', 'x402', 'algorand'], +}; + +const merchantExtension = { + 'x402-merchant': { + info: MERCHANT, + schema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + required: ['name'], + properties: { + name: { type: 'string' }, + website: { type: 'string' }, + logo: { type: 'string' }, + categories: { type: 'array', items: { type: 'string' } }, + }, + }, + }, +}; + +module.exports = { MERCHANT, merchantExtension }; diff --git a/server.js b/server.js index 95930f3..c845a65 100644 --- a/server.js +++ b/server.js @@ -11,6 +11,7 @@ const { ExactSvmScheme } = require('@x402/svm/exact/server'); const { createFacilitatorConfig } = require('@coinbase/x402'); const { fizzlCors } = require('./fizzl-cors'); const { declareDiscoveryExtension } = require('@x402/extensions/bazaar'); +const { merchantExtension } = require('./merchant'); const { McpServer, createMcpHandler } = require('@modelcontextprotocol/server'); const { z } = require('zod/v4'); const { createMediaCache } = require('./media'); @@ -241,14 +242,14 @@ if (x402PayTo) { description: "Check an EVM wallet's live token/NFT approvals and get a plain-language verdict (SAFE, CAUTION or RISK) with an explanation", mimeType: 'application/json', ...serviceMetadata, - extensions: checkWalletDiscovery + extensions: { ...checkWalletDiscovery, ...merchantExtension } }, 'POST /api/explain': { accepts: acceptsFor(x402ExplainPrice), description: 'What would signing this allow? Paste a wallet approval, transaction or typed-data signature before someone signs it: returns a SAFE, CAUTION or RISK verdict with a short plain-language explanation of what it gives away', mimeType: 'application/json', ...serviceMetadata, - extensions: explainDiscovery + extensions: { ...explainDiscovery, ...merchantExtension } } }, x402Server)))); diff --git a/test/algorand.test.js b/test/algorand.test.js index d58023f..ea05fd0 100644 --- a/test/algorand.test.js +++ b/test/algorand.test.js @@ -36,7 +36,10 @@ async function acceptsWith(env) { for (let i = 0; i < 50; i++) { try { const res = await fetch(`http://127.0.0.1:${port}/api/check-wallet`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }); - if (res.status === 402) return JSON.parse(Buffer.from(res.headers.get('payment-required'), 'base64').toString()).accepts; + if (res.status === 402) { + const challenge = JSON.parse(Buffer.from(res.headers.get('payment-required'), 'base64').toString()); + return Object.assign(challenge.accepts, { extensions: challenge.extensions }); + } } catch { /* not up yet */ } await new Promise((r) => setTimeout(r, 200)); } @@ -57,6 +60,9 @@ test('with ALGORAND_PAY_TO: $0.04 in USDC (ASA 31566704) on Algorand, with the A assert.equal(algo.amount, '40000'); assert.equal(algo.extra.feePayer, FEE_PAYER, 'not the URL facilitator\'s'); assert.ok(accepts.some((a) => a.network === 'eip155:8453'), 'Base still offered'); + // GoPlausible names the seller (one merchant per Algorand pay-to) from this. + assert.equal(accepts.extensions['x402-merchant'].info.name, 'Fizzl'); + assert.equal(accepts.extensions['x402-merchant'].info.logo, 'https://fizzl.eu/logo-512.png'); }); test('without ALGORAND_PAY_TO, or "off": no Algorand', async () => { From 9c7b5d0256a2fc8bcfd54e77962378a6e426ff93 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 09:22:11 +0000 Subject: [PATCH 14/19] MCP: output schemas and structured content on every tool check_wallet_approvals and explain_approval declare an outputSchema (verdict, explanation) and answer with structuredContent next to the same "VERDICT: explanation" text (which the usage log reads); feedback does too. Extra fields are allowed, so new ones never break a call. MCP directories such as Smithery score output schemas. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TsD5haPKxeWjYmErHzvU48 --- feedback.js | 13 ++++++++++++- server.js | 26 +++++++++++++++++++------- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/feedback.js b/feedback.js index 0a55a8b..6eb79ad 100644 --- a/feedback.js +++ b/feedback.js @@ -12,6 +12,7 @@ // submit(input, { ip, userAgent, via }) the same for an MCP tool; { ok, status, body } // mcpTool name, title, description and JSON schema for the MCP tool // mcpShape(z) the input as a zod shape, for McpServer.registerTool +// mcpOutputShape(z) the answer to a received report as a zod object (outputSchema) // mcpCall(args, { ip, userAgent }) the tool's CallToolResult // The usage log's own MCP describer should skip the "feedback" tool: submit() // already records it, with the full message. @@ -141,13 +142,23 @@ function createFeedback({ service, record = () => {}, agentOf = (ua) => String(u function mcpCall(args, { ip, userAgent } = {}) { const out = submit(args, { ip, userAgent, via: 'mcp' }); - return { content: [{ type: 'text', text: JSON.stringify(out.body) }], ...(out.ok ? {} : { isError: true }) }; + return { content: [{ type: 'text', text: JSON.stringify(out.body) }], ...(out.ok ? { structuredContent: out.body } : { isError: true }) }; } + // The answer to a report that was received, as a zod object (the tool's outputSchema). + const mcpOutputShape = (z) => z.object({ + received: z.boolean().describe('true: a person will read it'), + id: z.string().describe('Report id, to refer to it later'), + service: z.string().optional(), + type: z.string().optional(), + note: z.string().optional(), + }).passthrough(); + return { router, submit, mcpShape, + mcpOutputShape, mcpCall, mcpTool: { name: 'feedback', title: 'Send feedback (free)', description: DESCRIPTION, inputSchema: SCHEMA }, }; diff --git a/server.js b/server.js index c845a65..ce86c4e 100644 --- a/server.js +++ b/server.js @@ -590,6 +590,17 @@ app.post('/api/demo-explain', async (req, res) => { // --- Free MCP tools, rate-limited (see /api/check-wallet and /api/explain // above for the paid HTTP equivalents) --- +// What both tools answer: the verdict and the explanation, as text ("VERDICT: explanation", which the usage log +// reads) and as structuredContent matching this schema. Extra fields are allowed, so new ones never break a call. +const VERDICT_OUTPUT = z.object({ + verdict: z.string().describe('SAFE, CAUTION or RISK'), + explanation: z.string().describe('Plain-language explanation of what the approval or payload allows') +}).passthrough(); +const verdictResult = ({ verdict, explanation }) => ({ + content: [{ type: 'text', text: `${verdict}: ${explanation}` }], + structuredContent: { verdict, explanation } +}); + function buildMcpServer() { const server = new McpServer({ name: 'plaintext-wallet-checker', version: '1.0.0' }); @@ -602,12 +613,12 @@ function buildMcpServer() { address: z.string().regex(ADDRESS_RE).describe('EVM wallet address to check, e.g. 0x...'), chain: z.enum(Object.keys(CHAINS)).optional().describe('Chain to check approvals on (default: ethereum)'), kind: z.enum(['token', 'nft']).optional().describe('Approval type to check (default: token)') - }) + }), + outputSchema: VERDICT_OUTPUT }, async ({ address, chain, kind }) => { try { - const result = await getWalletVerdict({ address, chain, kind }); - return { content: [{ type: 'text', text: `${result.verdict}: ${result.explanation}` }] }; + return verdictResult(await getWalletVerdict({ address, chain, kind })); } catch (err) { return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true }; } @@ -621,7 +632,8 @@ function buildMcpServer() { description: "Explain an arbitrary approval/permission JSON payload in plain language, when you already have the data instead of needing an on-chain lookup.", inputSchema: z.object({ data: z.record(z.string(), z.any()).describe('Arbitrary approval/permission JSON payload to explain') - }) + }), + outputSchema: VERDICT_OUTPUT }, async ({ data }) => { try { @@ -629,8 +641,7 @@ function buildMcpServer() { if (size > MAX_EXPLAIN_PAYLOAD_CHARS) { return { content: [{ type: 'text', text: `Error: payload too large (${size} chars, max ${MAX_EXPLAIN_PAYLOAD_CHARS}).` }], isError: true }; } - const { verdict, explanation } = await explainPayload(data); - return { content: [{ type: 'text', text: `${verdict}: ${explanation}` }] }; + return verdictResult(await explainPayload(data)); } catch (err) { return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true }; } @@ -642,7 +653,8 @@ function buildMcpServer() { { title: feedback.mcpTool.title, description: feedback.mcpTool.description, - inputSchema: z.object(feedback.mcpShape(z)) + inputSchema: z.object(feedback.mcpShape(z)), + ...(feedback.mcpOutputShape ? { outputSchema: feedback.mcpOutputShape(z) } : {}) }, async (args) => feedback.mcpCall(args, mcpCaller.getStore() || {}) ); From 725218e3ceff4598422f509efe5d6c33609d5c80 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 12:29:49 +0000 Subject: [PATCH 15/19] Discovery: a name and description per resource in /.well-known/x402 The XRPL AI Hub (xrpl-ai.org) ingests /.well-known/x402 and lists each resource by its name and description; without them it shows the long 402 text. The manifest moves to well-known.js, with resources as { url, method, name, description } objects (the endpoints list stays) and a description that names the payment options. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TsD5haPKxeWjYmErHzvU48 --- server.js | 22 ++-------------------- test/well-known.test.js | 19 +++++++++++++++++++ well-known.js | 30 ++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 20 deletions(-) create mode 100644 test/well-known.test.js create mode 100644 well-known.js diff --git a/server.js b/server.js index ce86c4e..feb9507 100644 --- a/server.js +++ b/server.js @@ -22,6 +22,7 @@ const { describePlainTextCall } = require('./usage'); const { trustProxyHops } = require('./proxy'); const { onPublicHost } = require('./public-host'); const { createMppPay, unlessMppPaid, addMppOffers } = require('./mpp-pay'); +const { wellKnown } = require('./well-known'); const app = express(); const PORT = process.env.PORT || 3000; @@ -706,26 +707,7 @@ app.all('/mcp', async (req, res) => { // x402scan and similar indexers read {version: 1, resources: [url, ...]}; // methods, prices and schemas come from /openapi.json (x-payment-info). const PUBLIC_ORIGIN = 'https://plaintext.fizzl.eu'; -const X402_WELL_KNOWN_MANIFEST = { - version: 1, - resources: [`${PUBLIC_ORIGIN}/api/check-wallet`, `${PUBLIC_ORIGIN}/api/explain`], - x402Version: 2, - kind: 'resource-server', - name: 'PlainText — wallet approval translator', - serviceName: 'PlainText', - category: 'security', - iconUrl: `${PUBLIC_ORIGIN}/icon.png`, - tags: ['wallet-security', 'approvals', 'crypto', 'explainer'], - description: "Checks a wallet's token/NFT approvals via GoPlus and explains risk in plain language.", - endpoints: [ - { url: `${PUBLIC_ORIGIN}/api/check-wallet`, method: 'POST', description: 'Explain wallet token approvals in plain language' }, - { url: `${PUBLIC_ORIGIN}/api/explain`, method: 'POST', description: 'What would signing this allow? A SAFE, CAUTION or RISK verdict with a plain-language explanation of a pasted approval, transaction or signature' } - ], - openapi: `${PUBLIC_ORIGIN}/openapi.json`, - docs: `${PUBLIC_ORIGIN}/openapi.json`, - contact: 'Fizzl13@protonmail.com', - updated: '2026-09-23T00:00:00Z' -}; +const X402_WELL_KNOWN_MANIFEST = wellKnown(PUBLIC_ORIGIN); // Instructions an AI agent can read and follow ("Connect to plaintext.fizzl.eu/skill.md"). app.get('/skill.md', (_req, res) => res.set('cache-control', 'public, max-age=300').type('text/markdown; charset=utf-8').sendFile(path.join(__dirname, 'public', 'skill.md'))); diff --git a/test/well-known.test.js b/test/well-known.test.js new file mode 100644 index 0000000..ce0898c --- /dev/null +++ b/test/well-known.test.js @@ -0,0 +1,19 @@ +// /.well-known/x402 (well-known.js): the paid resources as { url, method, name, description }, as the XRPL AI Hub +// reads them, and the endpoints list for existing readers. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { wellKnown } = require('../well-known'); + +test('/.well-known/x402: each paid resource with a short name and a one-line description', () => { + const doc = wellKnown('https://plaintext.fizzl.eu'); + assert.equal(doc.version, 1); + assert.equal(doc.name, 'PlainText — wallet approval translator'); + assert.deepEqual(doc.resources.map((r) => [r.url, r.method]), [['https://plaintext.fizzl.eu/api/check-wallet', 'POST'], ['https://plaintext.fizzl.eu/api/explain', 'POST']]); + assert.equal(new Set(doc.resources.map((r) => r.name)).size, doc.resources.length, 'names are unique'); + for (const r of doc.resources) { + assert.ok(r.name.length > 0 && r.name.length <= 40, r.url); + assert.ok(r.description.length >= 40 && r.description.length <= 200, r.url); + } + assert.deepEqual(doc.endpoints.map((e) => e.url), doc.resources.map((r) => r.url), 'the endpoints list stays'); + assert.match(doc.description, /XRP Ledger/); +}); diff --git a/well-known.js b/well-known.js new file mode 100644 index 0000000..7309d0d --- /dev/null +++ b/well-known.js @@ -0,0 +1,30 @@ +// /.well-known/x402: the paid resources on this origin, for x402 indexers. Each resource carries a short name and a +// one-line description: the XRPL AI Hub (xrpl-ai.org) lists each resource by those (without them it shows the long +// 402 text). The endpoints list stays for existing readers. +function wellKnown(origin) { + return { + version: 1, + resources: [ + { url: `${origin}/api/check-wallet`, method: 'POST', name: 'Wallet approval check', description: "A wallet's open token and NFT approvals (via GoPlus), with a SAFE, CAUTION or RISK verdict explained in plain language." }, + { url: `${origin}/api/explain`, method: 'POST', name: 'Signature explainer', description: 'What would signing this allow? A SAFE, CAUTION or RISK verdict and a plain-language explanation of a pasted approval, transaction or signature.' }, + ], + x402Version: 2, + kind: 'resource-server', + name: 'PlainText — wallet approval translator', + serviceName: 'PlainText', + category: 'security', + iconUrl: `${origin}/icon.png`, + tags: ['wallet-security', 'approvals', 'crypto', 'explainer'], + description: "Checks a wallet's token/NFT approvals via GoPlus and explains risk in plain language. Pay in USDC on Base, Solana or Algorand, or RLUSD on the XRP Ledger.", + endpoints: [ + { url: `${origin}/api/check-wallet`, method: 'POST', description: 'Explain wallet token approvals in plain language' }, + { url: `${origin}/api/explain`, method: 'POST', description: 'What would signing this allow? A SAFE, CAUTION or RISK verdict with a plain-language explanation of a pasted approval, transaction or signature' }, + ], + openapi: `${origin}/openapi.json`, + docs: `${origin}/openapi.json`, + contact: 'Fizzl13@protonmail.com', + updated: '2026-10-13T00:00:00Z', + }; +} + +module.exports = { wellKnown }; From 775fa73426b5826b0b905bfe01a6241321ca5e45 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 17:36:27 +0000 Subject: [PATCH 16/19] Security headers on every answer HSTS (one year), nosniff, a Referrer-Policy and X-Frame-Options, and no x-powered-by header. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TsD5haPKxeWjYmErHzvU48 --- server.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/server.js b/server.js index feb9507..785289d 100644 --- a/server.js +++ b/server.js @@ -31,6 +31,18 @@ const PORT = process.env.PORT || 3000; // req.ip is Render's address for everyone, too many (true) and the caller can // pick their own req.ip with an X-Forwarded-For header. app.set('trust proxy', trustProxyHops()); +app.disable('x-powered-by'); + +// Security headers on every answer (browsers ignore HSTS over plain http, so local runs are unaffected). +app.use((_req, res, next) => { + res.set({ + 'strict-transport-security': 'max-age=31536000', + 'x-content-type-options': 'nosniff', + 'referrer-policy': 'strict-origin-when-cross-origin', + 'x-frame-options': 'SAMEORIGIN', + }); + next(); +}); app.use(express.json()); From 3ff3a266143f582fa656e2c02e13b16d8383ff09 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 20:09:10 +0000 Subject: [PATCH 17/19] Weekly dependency audit with one issue while a finding is open Dependabot alerts are off here, so a new advisory like this week's MCP SDK one went unnoticed. Every Monday npm audit now checks PlainText's production dependencies. A high or critical finding the server can reach opens one issue labelled dependency-audit (GitHub emails the owner) or adds the new list to the open one, and the next clean run closes it. Checked against the lockfile from before the MCP SDK update: it reports exactly that advisory, and nothing on the current lockfile. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TsD5haPKxeWjYmErHzvU48 --- .github/scripts/audit-check.mjs | 51 +++++++++++++++++++++++++++++++++ .github/workflows/audit.yml | 43 +++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 .github/scripts/audit-check.mjs create mode 100644 .github/workflows/audit.yml diff --git a/.github/scripts/audit-check.mjs b/.github/scripts/audit-check.mjs new file mode 100644 index 0000000..c0a92d9 --- /dev/null +++ b/.github/scripts/audit-check.mjs @@ -0,0 +1,51 @@ +// Weekly dependency audit (Dependabot alerts are off). Reads `npm audit --json` and the lockfile and +// prints, as Markdown for an issue, the high and critical findings a production install can reach. +// Findings reachable only through a package named in IGNORE (comma-separated) are left out: @x402/paywall +// bundles browser wallet libraries (WalletConnect, Reown) that the server never runs. +// Usage: node audit-check.mjs audit.json package-lock.json > report.md (empty output = nothing to report) +import { readFileSync } from "node:fs"; + +const [auditPath, lockPath] = process.argv.slice(2); +const audit = JSON.parse(readFileSync(auditPath, "utf8")); +if (audit.error) throw new Error(`npm audit failed: ${audit.error.summary || JSON.stringify(audit.error)}`); +const pkgs = JSON.parse(readFileSync(lockPath, "utf8")).packages || {}; +const ignore = new Set((process.env.IGNORE || "").split(",").map((s) => s.trim()).filter(Boolean)); + +// Where Node finds `name` when `from` requires it: the nearest node_modules on the way up to the root. +const resolve = (from, name) => { + for (let dir = from; ; ) { + const path = (dir ? `${dir}/` : "") + `node_modules/${name}`; + if (pkgs[path]) return path; + if (!dir) return null; + const i = dir.lastIndexOf("/node_modules/"); + dir = i === -1 ? "" : dir.slice(0, i); + } +}; + +// Every installed path a production install reaches from the root, skipping ignored packages. +const reached = new Set(); +const root = pkgs[""] || {}; +const queue = Object.keys({ ...root.dependencies, ...root.optionalDependencies }).map((n) => [n, ""]); +while (queue.length) { + const [name, from] = queue.pop(); + if (ignore.has(name)) continue; + const path = resolve(from, name); + if (!path || reached.has(path)) continue; + reached.add(path); + const e = pkgs[path]; + for (const dep of Object.keys({ ...e.dependencies, ...e.optionalDependencies, ...e.peerDependencies })) queue.push([dep, path]); +} + +const rank = { critical: 0, high: 1 }; +const found = Object.values(audit.vulnerabilities || {}) + .filter((v) => v.severity in rank && (v.nodes || []).some((n) => reached.has(n))) + .sort((a, b) => rank[a.severity] - rank[b.severity] || a.name.localeCompare(b.name)); +if (found.length) { + const fix = (f) => (f === true ? "update available" : f && f.name ? `fix needs ${f.name}@${f.version}${f.isSemVerMajor ? " (major)" : ""}` : "no fix yet"); + const lines = found.map((v) => { + const advisories = [...new Map(v.via.filter((x) => typeof x === "object").map((x) => [x.url, `[${x.title}](${x.url})`])).values()]; + const through = v.via.filter((x) => typeof x === "string"); + return `- **${v.name}** ${v.severity}, ${v.range}: ${advisories.join("; ") || `through ${through.join(", ")}`}. ${fix(v.fixAvailable)}.`; + }); + console.log(`The weekly \`npm audit\` found ${found.length} high or critical finding(s) in production dependencies${ignore.size ? ` (not counting what only ${[...ignore].join(", ")} pulls in)` : ""}:\n\n${lines.join("\n")}\n\nFix with \`npm install @\` or \`npm audit fix --omit=dev\`, run the tests, and open a PR. This issue closes itself after a clean weekly run.`); +} diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..bba1f3b --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,43 @@ +name: Dependency audit +# Every Monday: npm audit on PlainText's production dependencies (Dependabot alerts are off on this repo). +# A high or critical finding the server can reach opens one issue labelled dependency-audit (GitHub +# emails the owner), or adds the new list to the open one; the next clean run closes it. +on: + schedule: + - cron: "17 5 * * 1" + workflow_dispatch: {} + push: + branches: ['claude/**'] + paths: ['.github/scripts/audit-check.mjs', '.github/workflows/audit.yml'] +permissions: + contents: read + issues: write +jobs: + audit: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 22 + # npm audit exits non-zero whenever it finds anything; the next step decides what counts. + - run: npm audit --omit=dev --package-lock-only --json > "$RUNNER_TEMP/audit.json" || true + - name: High and critical findings the server can reach + run: node "$GITHUB_WORKSPACE/.github/scripts/audit-check.mjs" "$RUNNER_TEMP/audit.json" package-lock.json > "$RUNNER_TEMP/report.md" + - name: Keep one issue open while findings remain + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + report="$RUNNER_TEMP/report.md" + num=$(gh issue list --label dependency-audit --state open --json number --jq '.[0].number // empty') + if [ -s "$report" ]; then + cat "$report" >> "$GITHUB_STEP_SUMMARY" + if [ -n "$num" ]; then gh issue comment "$num" --body-file "$report"; exit 0; fi + gh label create dependency-audit --color B60205 --description "Weekly npm audit found a high or critical advisory" 2>/dev/null || true + gh issue create --title "Dependency audit: high or critical advisory" --label dependency-audit --body-file "$report" + else + echo "No high or critical findings the server can reach." >> "$GITHUB_STEP_SUMMARY" + if [ -n "$num" ]; then gh issue close "$num" --comment "The weekly audit is clean again."; fi + fi From 95976efd6f3dec35f98fbb2cf5d74dc4099fa466 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 20:30:10 +0000 Subject: [PATCH 18/19] Usage log: say why an x402 payment was refused A refused MPP credential was logged with its reason, a refused x402 payment only as payment_failed. The 402 that answers it already says why: PAYMENT-REQUIRED carries error when verification failed, PAYMENT-RESPONSE carries errorReason when settlement failed. The log now keeps that as reason (first line only, URLs blanked so an RPC error can never put a keyed URL in the log). All 32 tests pass, including a new one that fails without this change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TsD5haPKxeWjYmErHzvU48 --- test/usage.test.js | 8 +++++++- usage-log.js | 20 +++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) 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, From aa20d90b357a4fd10d51eb7e9cc356deca3f6b29 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 06:21:23 +0000 Subject: [PATCH 19/19] Usage log: write in batches (at most every 10 minutes) and read the day file raw PlainText wrote the usage log once per call: 2,700 commits a day, each downloading and re-uploading the whole day file. That is about 1.9 GB a day of Render bandwidth, billed above the 5 GB the plan includes. It now batches like the other services: one write per 10 minutes (USAGE_LOG_FLUSH_MS), the queue written first on SIGTERM (a deploy or the free plan going to sleep). The day file is also read raw, as in the other services: the JSON form has no content for files over 1 MB, which would have overwritten the day with only the new lines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TsD5haPKxeWjYmErHzvU48 --- test/usage.test.js | 24 ++++++++++++++++++++++++ usage-log.js | 33 +++++++++++++++++++++++++++------ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/test/usage.test.js b/test/usage.test.js index 3f00317..4b2e4c9 100644 --- a/test/usage.test.js +++ b/test/usage.test.js @@ -71,3 +71,27 @@ test('usage log: an MCP reply sent as an event stream is read (the verdict is lo assert.equal(e.route, 'check_wallet_approvals'); assert.deepEqual(e.result, { verdict: 'RISK' }); }); + +test('usage log: writes are batched (one read and one write for several calls), and a day file over 1 MB keeps its lines', async () => { + const { createUsageLog } = require('../usage-log'); + const path = 'events/plaintext/2026-10-14.jsonl'; + const old = `${'x'.repeat(1100 * 1024)}\n`; + let file = old; + const calls = []; + const fetchFn = async (url, opts = {}) => { + const method = opts.method || 'GET'; + calls.push(`${method} ${new URL(url).pathname}`); + // Like GitHub: the JSON form has no content for files over 1 MB, the raw form has the bytes. + if (method === 'GET') return /raw/.test(opts.headers?.accept || '') ? new Response(file) : Response.json({ sha: 'x', content: '' }); + file = Buffer.from(JSON.parse(opts.body).content, 'base64').toString('utf8'); + return Response.json({}, { status: 200 }); + }; + const log = createUsageLog({ service: 'plaintext', env: { USAGE_LOG_TOKEN: 't' }, fetchFn, now: () => new Date('2026-10-14T10:00:00Z'), log: { warn() {}, error() {} } }); + log.record({ route: 'check-wallet', status: 402 }); + log.record({ route: 'explain', status: 200 }); + assert.equal(calls.length, 0, 'nothing written until the flush'); + await log.flush(); + assert.ok(file.startsWith(old), 'the old lines are still there'); + assert.equal(file.slice(old.length).trim().split('\n').length, 2); + assert.deepEqual(calls, [`GET /repos/Fizzl13/usage-log/contents/${path}`, `PUT /repos/Fizzl13/usage-log/contents/${path}`]); +}); diff --git a/usage-log.js b/usage-log.js index 1df3531..0e6ed33 100644 --- a/usage-log.js +++ b/usage-log.js @@ -12,6 +12,12 @@ const crypto = require('node:crypto'); +// Git's blob id of a file's bytes: what the contents API calls its sha. +const blobSha = (buf) => crypto.createHash('sha1').update(`blob ${buf.length}\0`).update(buf).digest('hex'); +const MAX_QUEUE = 200; +// Tests pass their own fetch; only the real service exits on SIGTERM. +const fetchFnGiven = (fn) => fn !== globalThis.fetch; + const DEFAULT_REPO = 'Fizzl13/usage-log'; const API = 'https://api.github.com'; const MAX_TEXT = 300; @@ -141,12 +147,20 @@ function x402Refusal(res) { return null; } -function createUsageLog({ service, env = process.env, fetchFn = globalThis.fetch, now = () => new Date(), log = console } = {}) { +function createUsageLog({ service, env = process.env, fetchFn = globalThis.fetch, now = () => new Date(), log = console, flushDelayMs = 10 * 60 * 1000, exitOnSignal = !fetchFnGiven(fetchFn) } = {}) { const token = env.USAGE_LOG_TOKEN; const repo = env.USAGE_LOG_REPO || DEFAULT_REPO; const visitorSecret = env.USAGE_LOG_SALT || token; const queue = []; let flushing = null; + let timer = null; + // Every write re-sends the whole day file to GitHub, so writes are batched: at most one per flushMs + // (USAGE_LOG_FLUSH_MS, default 10 minutes). On shutdown (a deploy, or the free plan going to sleep) the queue is + // written first. + const flushMs = Number(env.USAGE_LOG_FLUSH_MS) || flushDelayMs; + if (token && exitOnSignal) { + process.once('SIGTERM', () => { flush().finally(() => process.exit(0)); }); + } let warned = false; const headers = { @@ -160,13 +174,15 @@ function createUsageLog({ service, env = process.env, fetchFn = globalThis.fetch async function append(path, lines) { for (let attempt = 0; attempt < 4; attempt++) { const url = `${API}/repos/${repo}/contents/${path}`; - const current = await fetchFn(url, { headers }); + // The raw file: the JSON form leaves out the content of files over 1 MB, which would then be overwritten + // with only the new lines. The sha GitHub wants back is the git blob sha of exactly these bytes. + const current = await fetchFn(url, { headers: { ...headers, accept: 'application/vnd.github.raw+json' } }); let sha; let text = ''; if (current.status === 200) { - const file = await current.json(); - sha = file.sha; - text = Buffer.from(file.content || '', 'base64').toString('utf8'); + const raw = Buffer.from(await current.arrayBuffer()); + sha = blobSha(raw); + text = raw.toString('utf8'); } else if (current.status !== 404) { throw new Error(`read ${path}: HTTP ${current.status}`); } @@ -184,6 +200,7 @@ function createUsageLog({ service, env = process.env, fetchFn = globalThis.fetch } async function flush() { + if (timer) { clearTimeout(timer); timer = null; } if (flushing) return flushing; flushing = (async () => { while (queue.length) { @@ -215,7 +232,11 @@ function createUsageLog({ service, env = process.env, fetchFn = globalThis.fetch return; } queue.push({ t: now().toISOString(), service, ...event, input: clipAll(event.input), result: clipAll(event.result) }); - flush(); + if (queue.length >= MAX_QUEUE) flush(); + else if (!timer) { + timer = setTimeout(() => { timer = null; flush(); }, flushMs); + timer.unref?.(); + } } // Express middleware. describe(req, res, body) returns { route, input, result, via, payment? }