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