diff --git a/facilitator-start.js b/facilitator-start.js new file mode 100644 index 0000000..836d6f1 --- /dev/null +++ b/facilitator-start.js @@ -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 }; diff --git a/server.js b/server.js index 785289d..9f6cd56 100644 --- a/server.js +++ b/server.js @@ -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'); @@ -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. @@ -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' : ''}`); diff --git a/test/facilitator-start.test.js b/test/facilitator-start.test.js new file mode 100644 index 0000000..870e848 --- /dev/null +++ b/test/facilitator-start.test.js @@ -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); +});