From 2f90047fce2a800d2355765b3474933061766dc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 16:57:56 +0000 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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() || {}) );