Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
2f90047
Accept MPP method tempo (push mode) when MPP_TEMPO_RECIPIENT is set
claude Oct 4, 2026
6ada795
Clearer /api/explain description for agents
claude Oct 5, 2026
e2c21e4
Merge remote branch (already merged history)
claude Oct 5, 2026
57e398c
Add glama.json to claim the Glama listing
claude Oct 5, 2026
88577ee
Merge remote branch (already merged history)
claude Oct 5, 2026
2eeeeec
MCP registry: clearer description (says when an agent would use it)
claude Oct 5, 2026
ce242d7
Merge remote branch (already merged history)
claude Oct 5, 2026
71f6a3e
Homepage: a row of prices right under the intro
claude Oct 6, 2026
fd7ae48
Merge remote branch (already merged history)
claude Oct 6, 2026
193799b
Usage log: record the network a refused payment was tried on
claude Oct 6, 2026
2d888e4
Merge branch 'claude/x402-agents-solana-payments-nceg9b' of https://g…
claude Oct 6, 2026
322505c
Pay in RLUSD on the XRP Ledger (x402 exact, xrpl:0)
claude Oct 7, 2026
aa35a9d
Merge branch 'claude/x402-agents-solana-payments-nceg9b' of https://g…
claude Oct 7, 2026
633f050
XRPL: also accept payers built on t54's SDK
claude Oct 7, 2026
3033055
Merge remote-tracking branch 'origin/main' into claude/x402-agents-so…
claude Oct 7, 2026
fdab3b3
SEO: canonical tag, robots.txt and sitemap.xml for the homepage
claude Oct 7, 2026
0027283
Merge branch 'claude/x402-agents-solana-payments-nceg9b' of https://g…
claude Oct 7, 2026
823b132
CI: run the tests on every pull request and on main
claude Oct 7, 2026
60e2caa
Merge branch 'claude/x402-agents-solana-payments-nceg9b' of https://g…
claude Oct 7, 2026
d81e01e
Usage log: record why an MPP credential was refused
claude Oct 7, 2026
7a491d3
Merge branch 'claude/x402-agents-solana-payments-nceg9b' of https://g…
claude Oct 7, 2026
84c2f4e
Pay in USDC on Algorand (ALGORAND_PAY_TO)
claude Oct 8, 2026
a6cec2a
Merge remote branch
claude Oct 8, 2026
aa499c8
Name the seller in the 402: x402-merchant extension
claude Oct 9, 2026
7ec7ce1
Merge remote branch (already in main)
claude Oct 9, 2026
9c7b5d0
MCP: output schemas and structured content on every tool
claude Oct 9, 2026
3fdf50f
Merge remote branch (already in main)
claude Oct 9, 2026
80dd8e2
Merge stale remote branch claude/x402-agents-solana-payments-nceg9b
claude Oct 13, 2026
725218e
Discovery: a name and description per resource in /.well-known/x402
claude Oct 13, 2026
97ffd96
Merge stale remote branch claude/x402-agents-solana-payments-nceg9b
claude Oct 13, 2026
775fa73
Security headers on every answer
claude Oct 13, 2026
f2ea313
Merge stale remote branch claude/x402-agents-solana-payments-nceg9b
claude Oct 13, 2026
3ff3a26
Weekly dependency audit with one issue while a finding is open
claude Oct 13, 2026
5aba932
Merge stale remote branch claude/x402-agents-solana-payments-nceg9b
claude Oct 13, 2026
95976ef
Usage log: say why an x402 payment was refused
claude Oct 13, 2026
915ba79
Merge stale remote branch claude/x402-agents-solana-payments-nceg9b
claude Oct 14, 2026
aa20d90
Usage log: write in batches (at most every 10 minutes) and read the d…
claude Oct 14, 2026
1f46f12
Merge stale remote branch claude/x402-agents-solana-payments-nceg9b
claude Oct 14, 2026
9c636a9
Usage log: the next write to the same day file skips the read
claude Oct 14, 2026
b339b31
Merge stale remote branch claude/x402-agents-solana-payments-nceg9b
claude Oct 14, 2026
b261220
Fetch the facilitators' supported kinds without x402's start-up exit
claude Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions facilitator-start.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// The facilitators' supported kinds, fetched without x402's start-up exit.
//
// paymentMiddleware() normally checks every route against the facilitators at start and exits the process when a
// network has no facilitator answering: one facilitator down during a restart kept the whole service down, free
// routes included, until it answered again. Here the middleware runs with that check off (syncFacilitatorOnStart
// false) and this module fetches the kinds instead:
// - every facilitator is asked first, at most askMs each, and initialize() then gets those answers at once, so the
// kinds the paid routes use are never empty while a slow facilitator is being asked;
// - a network no facilitator supports is left out of the 402 (the other networks still pay) and asked again every
// retryMs until it answers;
// - with nothing loaded at all, the paid routes answer 500 and the next paid call asks again.
//
// The same file is copied into each Fizzl service.

'use strict';

function createFacilitatorStart(facilitators, { log = console, askMs = 15 * 1000, retryMs = 60 * 1000 } = {}) {
const clients = facilitators.map((facilitator) => {
let answer = null;
return {
verify: (...args) => facilitator.verify(...args),
settle: (...args) => facilitator.settle(...args),
getSupported: () => answer || facilitator.getSupported(),
ask() {
let timer;
const limit = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`no answer within ${askMs / 1000} s`)), askMs);
});
answer = Promise.race([facilitator.getSupported(), limit]).finally(() => clearTimeout(timer));
return answer.catch(() => {});
},
forget() { answer = null; },
};
});

// Wraps the resource server built on `clients`; networks are the ones its routes offer.
function attach(resourceServer, networks) {
const initialize = resourceServer.initialize.bind(resourceServer);
const build = resourceServer.buildPaymentRequirements.bind(resourceServer);
const supports = (network, scheme = 'exact') => Boolean(resourceServer.getSupportedKind(2, network, scheme));
const missing = () => networks.filter((network) => !supports(network));
const anyLoaded = () => missing().length < networks.length;
let loading = null;
let refilling = null;
let timer = null;

function load() {
loading ??= (async () => {
await Promise.all(clients.map((c) => c.ask()));
// initialize() empties the kinds and asks every client again; all answers are in, so it refills them
// without waiting on the network.
refilling = initialize().catch((err) => log.warn(`[x402] facilitators: ${err.message}`));
await refilling;
refilling = null;
clients.forEach((c) => c.forget());
clearTimeout(timer);
const gone = missing();
if (gone.length) {
log.warn(`[x402] no facilitator answers for ${gone.join(', ')}; asking again in ${Math.round(retryMs / 1000)} s`);
timer = setTimeout(load, retryMs);
timer.unref?.();
}
})().finally(() => { loading = null; });
return loading;
}

resourceServer.initialize = load; // the MCP tools' initialize() goes through here too
resourceServer.buildPaymentRequirements = async (config, ...rest) => {
if (refilling) await refilling;
if (!anyLoaded()) await load();
if (anyLoaded() && !supports(config.network, config.scheme)) return [];
return build(config, ...rest);
};
const ready = load();
return { ready, missing };
}

return { clients, attach };
}

module.exports = { createFacilitatorStart };
8 changes: 6 additions & 2 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const express = require('express');
const path = require('path');
const { paymentMiddleware } = require('@x402/express');
const { x402ResourceServer, HTTPFacilitatorClient } = require('@x402/core/server');
const { createFacilitatorStart } = require('./facilitator-start');
const { ExactXrplScheme } = require('@x402/xrpl/exact/server');
const { ExactAvmScheme } = require('@x402/avm/exact/server');
const { createXrplFacilitator, XRPL, X402_SOURCE_TAG } = require('./xrpl-facilitator');
Expand Down Expand Up @@ -145,11 +146,14 @@ if (x402PayTo) {
: (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);
// Supported kinds fetched without x402's start-up exit (facilitator-start.js).
const facilitatorStart = createFacilitatorStart(facilitators, { retryMs: Number(process.env.FACILITATOR_RETRY_MS) || undefined });
const x402Server = new x402ResourceServer(facilitatorStart.clients);
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());
facilitatorStart.attach(x402Server, [x402Caip2Network, ...(x402PayToSolana ? [x402SolanaNetwork] : []), ...(xrplPayTo ? [XRPL] : []), ...(algorandPayTo ? [ALGORAND] : [])]);

// 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.
Expand Down Expand Up @@ -264,7 +268,7 @@ if (x402PayTo) {
...serviceMetadata,
extensions: { ...explainDiscovery, ...merchantExtension }
}
}, x402Server))));
}, x402Server, undefined, undefined, false /* no start-up exit: facilitator-start.js fetches the kinds */))));

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' : ''}`);
Expand Down
64 changes: 64 additions & 0 deletions test/facilitator-start.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// facilitator-start.js: the supported kinds without x402's start-up exit.
const test = require('node:test');
const assert = require('node:assert/strict');
const { x402ResourceServer } = require('@x402/express');
const { ExactEvmScheme } = require('@x402/evm/exact/server');
const { ExactSvmScheme } = require('@x402/svm/exact/server');
const { createFacilitatorStart } = require('../facilitator-start');

const BASE = 'eip155:8453';
const SOLANA = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp';
const quiet = { warn() {} };
const facilitator = (getSupported) => ({ getSupported, verify: async () => ({ isValid: true }), settle: async () => ({ success: true }) });
const supported = (network, extra) => ({ kinds: [{ x402Version: 2, scheme: 'exact', network, ...(extra ? { extra } : {}) }], extensions: [], signers: {} });
const base = { scheme: 'exact', price: '$0.01', network: BASE, payTo: '0x6B0F4651eD42893ab58139938175E4a69f175F25' };
const solana = { scheme: 'exact', price: '$0.01', network: SOLANA, payTo: 'ATWJ82T8nRdQwZnaysB68N5EpaSvLRsQP4h6eWmaJBH9' };

test('a facilitator that hangs is given up after askMs; the others load and its network is left out', async () => {
const start = createFacilitatorStart([
facilitator(async () => supported(BASE)),
facilitator(() => new Promise(() => {})), // never answers
], { log: quiet, askMs: 50, retryMs: 60000 });
const server = new x402ResourceServer(start.clients).register(BASE, new ExactEvmScheme()).register(SOLANA, new ExactSvmScheme());
const { ready, missing } = start.attach(server, [BASE, SOLANA]);
await ready;
assert.deepEqual(missing(), [SOLANA]);
assert.equal((await server.buildPaymentRequirements(base))[0].network, BASE);
assert.deepEqual(await server.buildPaymentRequirements(solana), [], 'left out, not an error');
});

test('while a missing network is asked again, the loaded kinds stay usable', async () => {
let solanaUp = false;
let slowAnswer = null;
const start = createFacilitatorStart([
facilitator(async () => supported(BASE)),
facilitator(() => (solanaUp ? new Promise((resolve) => { slowAnswer = () => resolve(supported(SOLANA, { feePayer: '2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4' })); }) : Promise.reject(new Error('down')))),
], { log: quiet, askMs: 5000, retryMs: 20 });
const server = new x402ResourceServer(start.clients).register(BASE, new ExactEvmScheme()).register(SOLANA, new ExactSvmScheme());
const { ready, missing } = start.attach(server, [BASE, SOLANA]);
await ready;
assert.deepEqual(missing(), [SOLANA]);
solanaUp = true;
await new Promise((r) => setTimeout(r, 60)); // the retry is now waiting on the slow facilitator
assert.ok(slowAnswer, 'the missing facilitator is being asked again');
assert.equal((await server.buildPaymentRequirements(base))[0].network, BASE, 'Base still builds during the retry');
slowAnswer();
await new Promise((r) => setTimeout(r, 20));
assert.deepEqual(missing(), []);
assert.equal((await server.buildPaymentRequirements(solana))[0].network, SOLANA);
});

test('nothing loaded: a paid call asks again, and initialize() (the MCP tools) goes through the same path', async () => {
let up = false;
let asked = 0;
const start = createFacilitatorStart([facilitator(async () => { asked++; if (!up) throw new Error('down'); return supported(BASE); })], { log: quiet, retryMs: 60000 });
const server = new x402ResourceServer(start.clients).register(BASE, new ExactEvmScheme());
const { ready } = start.attach(server, [BASE]);
await ready;
await assert.rejects(server.buildPaymentRequirements(base));
up = true;
assert.equal((await server.buildPaymentRequirements(base))[0].network, BASE);
const before = asked;
await server.initialize();
assert.equal(asked, before + 1);
});
Loading