UNPKG

run402-mcp

Version:

MCP server for Run402 — full-stack backend infrastructure for AI agents: Postgres, auth, storage, serverless functions and atomic deploys. Paid with x402/MPP. Includes $0.03 image generation.

435 lines • 22.1 kB
/** * The Lightning rail's buyer. Where the seller offers the x402 `exact` scheme * on Lightning (`lightning-x402-lnbtc.ts`) it pays that; otherwise it is a * `fetch` that selects Run402's MPP Lightning safety profile on every request, pays the one fixed BOLT11 challenge a 402 * carries from the agent's own wallet (the budgeted sub-wallet on Run402's * Hub, over NWC), presents the preimage as the credential on a byte-identical * retry, and falls back to the x402 buyer when the seller offers no Lightning * challenge (the rail is unavailable, or the surface is not charged over it). * * The all-in debit cap (invoice plus routing fee, in msat and USD) is checked * against the wallet's remaining budget before paying. The pairing secret, * the preimage, and the invoice never reach a log or an error. * * Once the invoice is paid the money has moved, so the gateway's own "come * back for the fulfilment" answers on the paid retry (`PAYMENT_RECOVERY_PENDING`, * `PAYMENT_INTENT_PENDING`, `PAYMENT_STATE_UNAVAILABLE`, `PAYMENT_EVIDENCE_UNAVAILABLE`, * each with a `retry` next action and a `Retry-After`) are honoured here: the * byte-identical paid retry is repeated after the wait, a bounded number of * times. It lands on the same payment intent and is fulfilled without a second * payment; a settled intent never receives a fresh challenge. */ import { createHash } from "node:crypto"; import { AsyncLocalStorage } from "node:async_hooks"; import { payResponseMetadata, responseSignalsReplay, } from "../namespaces/pay.js"; import { loadLightningStack } from "./_paid-stack.js"; import { explicitRequestHeaders, isX402LightningCandidate, payX402Lightning, readX402LightningOffer, x402LightningNetworks, } from "./lightning-x402-lnbtc.js"; import { NwcError, NwcWallet } from "./nwc.js"; /** The x402 network ids of Bitcoin mainnet and testnet3 (`lnbtc:` + the genesis hash prefix). */ export const LNBTC_MAINNET = "lnbtc:000000000019d6689c085ae165831e93"; export const LNBTC_TESTNET = "lnbtc:000000000933ea01ad0ee984209779ba"; /** The `lnbtc` network id for a node's reported network name, or null when the scheme defines none. */ export function lnbtcNetworkForNodeNetwork(name) { const lower = (name ?? "").toLowerCase(); if (lower === "mainnet" || lower === "bitcoin") return LNBTC_MAINNET; if (lower === "testnet" || lower === "testnet3") return LNBTC_TESTNET; return null; } export const RUN402_MPP_LIGHTNING_PROFILE = "run402-mpp-lightning-charge-draft00-safety-v1"; const ACCEPT_PAYMENT = `lightning/charge;profile=${RUN402_MPP_LIGHTNING_PROFILE}, tempo/charge;q=0.5`; /** Routing-fee headroom on top of the invoice, in sats (internal Hub payments cost 0). */ const FEE_HEADROOM_SATS = 10; /** A single Run402 charge above this is refused before paying (the spec's conservative USD ceiling in sats). */ export const LIGHTNING_MAX_DEBIT_SATS = 250_000; /** * The gateway's post-settlement "not yet" answers. Each names the retained * intent, carries a `retry` next action and a `Retry-After`, and is answered * by repeating the identical paid request — the same Idempotency-Key and the * same credential — never by paying again. Terminal answers (`PAYMENT_CREDITED`, * `PAYMENT_MANUAL_REVIEW`) carry a different action and are returned as-is. */ const POST_SETTLEMENT_RETRY_CODES = new Set([ "PAYMENT_RECOVERY_PENDING", "PAYMENT_INTENT_PENDING", "PAYMENT_STATE_UNAVAILABLE", "PAYMENT_EVIDENCE_UNAVAILABLE", ]); /** Bounded: the gateway credits a charge it cannot fulfil after three recovery cycles, well inside this. */ export const POST_SETTLEMENT_RETRY_LIMIT = 6; const POST_SETTLEMENT_RETRY_MIN_MS = 1_000; const POST_SETTLEMENT_RETRY_MAX_MS = 10_000; const POST_SETTLEMENT_RETRY_DEFAULT_MS = 2_000; /** * The paid retry's answer, when it is one of the gateway's retryable * post-settlement states from the origin that was asked: the code and how * long to wait before repeating the identical request. Null for everything * else, including a terminal 409 and any answer from another origin. */ export async function readPostSettlementRetry(response, url) { if ((response.status !== 409 && response.status !== 503) || response.redirected) return null; if (response.url) { try { if (new URL(response.url).origin !== new URL(url).origin) return null; } catch { return null; } } if (!response.headers.get("content-type")?.includes("application/json")) return null; let body; try { body = await response.clone().json(); } catch { return null; } if (!body || typeof body !== "object" || Array.isArray(body)) return null; const { code, next_actions: nextActions } = body; if (typeof code !== "string" || !POST_SETTLEMENT_RETRY_CODES.has(code)) return null; const action = Array.isArray(nextActions) ? nextActions[0] : undefined; if (!action || typeof action !== "object" || action.type !== "retry") return null; const raw = response.headers.get("retry-after"); const seconds = raw && /^\d+$/.test(raw) ? Number(raw) : null; const delayMs = seconds === null ? POST_SETTLEMENT_RETRY_DEFAULT_MS : Math.min(POST_SETTLEMENT_RETRY_MAX_MS, Math.max(POST_SETTLEMENT_RETRY_MIN_MS, seconds * 1_000)); return { code, delayMs }; } export class LightningPaymentError extends Error { code; details; constructor(code, message, details = {}) { super(message); this.code = code; this.details = details; this.name = "LightningPaymentError"; } } /** The `WWW-Authenticate: Payment …` challenge for lightning/charge, or null. */ export function readLightningChallenge(response, stack) { if (response.status !== 402) return null; const header = response.headers.get("www-authenticate"); if (!header || !/^payment\s/i.test(header)) return null; let challenge; try { challenge = stack.Challenge.deserialize(header); } catch { return null; } if (challenge.method !== "lightning" || challenge.intent !== "charge") return null; const request = (challenge.request ?? {}); const details = (request.methodDetails ?? {}); const invoice = typeof details.invoice === "string" ? details.invoice : ""; const paymentHash = typeof details.paymentHash === "string" ? details.paymentHash.toLowerCase() : ""; const amountSats = Number(request.amount); if (!invoice || !/^[0-9a-f]{64}$/.test(paymentHash) || !Number.isFinite(amountSats) || amountSats <= 0) return null; return { header, invoice, paymentHash, amountSats, expires: typeof challenge.expires === "string" ? challenge.expires : null }; } function withLightningHeaders(init, idempotencyKey) { const headers = new Headers(init?.headers ?? {}); headers.set("run402-payment-profile", RUN402_MPP_LIGHTNING_PROFILE); headers.set("accept-payment", ACCEPT_PAYMENT); if (!headers.has("idempotency-key")) headers.set("idempotency-key", idempotencyKey); return { ...init, headers }; } function sha256Hex(hex) { return createHash("sha256").update(Buffer.from(hex, "hex")).digest("hex"); } /** The Run402 surfaces charged over Lightning (`/.well-known/x402` → `paymentCapabilities[].resources`). */ const CHARGED_PATHS = [/^\/tiers\/v1\/[^/]+$/, /^\/generate-image\/v1$/]; function requestUrl(input) { return typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; } function requestMethod(input, init) { return (init?.method ?? (typeof input === "object" && "method" in input ? input.method : "GET")).toUpperCase(); } /** * Only a POST to a charged surface gets the Lightning negotiation headers * and a default Idempotency-Key. Every other request is left byte-for-byte * alone: an Idempotency-Key on an ordinary write (a room message, a project * provision) would turn a deliberate repeat into a replay. */ export function isLightningChargedRequest(input, init) { if (requestMethod(input, init) !== "POST") return false; let pathname; try { pathname = new URL(requestUrl(input)).pathname; } catch { return false; } return CHARGED_PATHS.some((pattern) => pattern.test(pathname)); } function defaultIdempotencyKey(input, init) { const url = requestUrl(input); const method = requestMethod(input, init); const body = typeof init?.body === "string" ? init.body : init?.body instanceof Uint8Array ? Buffer.from(init.body).toString("utf8") : ""; const digest = createHash("sha256").update(`${method}\n${url}\n${body}`).digest("hex"); return `lnc_${digest.slice(0, 32)}`; } /** * The all-in debit cap, shared by both Lightning protocols: the charge plus * routing-fee headroom must fit the rail's ceiling and the wallet's remaining * budget. Throws before anything is paid. */ async function checkLightningCaps(wallet, amountSats, maxSats) { const allIn = amountSats + FEE_HEADROOM_SATS; if (maxSats !== undefined && allIn > maxSats) { throw new LightningPaymentError("LIGHTNING_DEBIT_ABOVE_CAP", `A Lightning charge of ${amountSats} sats (plus ${FEE_HEADROOM_SATS} sats routing headroom) exceeds the ${maxSats}-sat cap given for this call.`, { amount_sats: amountSats, cap_sats: maxSats, cap_source: "max_sats", next_action: "raise maxSats / --max-sats, or pay over x402" }); } if (allIn > LIGHTNING_MAX_DEBIT_SATS) { throw new LightningPaymentError("LIGHTNING_DEBIT_ABOVE_CAP", `A Lightning charge of ${amountSats} sats exceeds the ${LIGHTNING_MAX_DEBIT_SATS}-sat ceiling.`, { amount_sats: amountSats, cap_sats: LIGHTNING_MAX_DEBIT_SATS, cap_source: "ceiling", next_action: "pay over x402 (run402 init --switch-rail)" }); } const budget = await wallet.getBudgetSats().catch(() => null); if (budget && budget.totalSats !== null && budget.totalSats - budget.usedSats < allIn) { throw new LightningPaymentError("LIGHTNING_BUDGET_EXHAUSTED", `The Lightning wallet's remaining budget (${budget.totalSats - budget.usedSats} sats) cannot cover ${allIn} sats.`, { remaining_sats: budget.totalSats - budget.usedSats, required_sats: allIn, next_action: "pay over x402 (run402 init --switch-rail), or ask the platform for a new wallet" }); } } export function createLightningFetch(options) { const baseFetch = options.baseFetch ?? ((input, init) => globalThis.fetch(input, init)); const wallet = options.wallet ?? new NwcWallet(options.pairingUri); const loadStack = options.stack ?? loadLightningStack; const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); const perCall = options.perCall ?? (() => undefined); let fallbackFetch; let walletNetwork; const fallback = async () => { if (fallbackFetch === undefined) fallbackFetch = await options.fallback(); return fallbackFetch; }; /** The wallet's `lnbtc` network: configured, or probed from the node once. */ const resolveWalletNetwork = () => { if (walletNetwork === undefined) { walletNetwork = options.walletNetwork === "probe" ? (wallet.getInfo ? wallet.getInfo().then((info) => lnbtcNetworkForNodeNetwork(info.network)) : Promise.resolve(null)) : Promise.resolve(options.walletNetwork ?? LNBTC_MAINNET); } return walletNetwork; }; /** Pay the 402's `lnbtc` entry on the wallet's network, or say exactly why not. */ const payOffer = async (input, init, probe) => { const offered = x402LightningNetworks(probe); if (offered.length === 0) return null; const network = await resolveWalletNetwork(); const offer = network === null ? null : readX402LightningOffer(probe, network); await probe.body?.cancel().catch(() => undefined); if (!offer) { throw new LightningPaymentError("LIGHTNING_NETWORK_MISMATCH", `The seller's Lightning offer is on ${offered.join(", ")} and this wallet is on ${network ?? "an unsupported network"}; nothing was paid.`, { offered_networks: offered, wallet_network: network, next_action: "pay with a pairing on that network: run402 pay --lightning-pairing <file|->" }); } const call = perCall(); if (call?.requireReceipt) { throw new LightningPaymentError("LIGHTNING_RECEIPT_UNAVAILABLE", "An x402 Lightning entry carries no merchant receipt; drop requireReceipt (--require-receipt) or pay over x402 USDC. Nothing was paid.", { network: offer.requirements.network }); } return payX402Lightning({ input, init, offer, wallet, baseFetch, fail: (code, message, details) => new LightningPaymentError(code, message, details), checkCaps: (amountSats) => checkLightningCaps(wallet, amountSats, call?.maxSats), onPaid: options.onPaid, ...(options.clock ? { clock: options.clock } : {}), }); }; return async (input, init) => { if (!isLightningChargedRequest(input, init)) { // Any other URL: the 402 decides. An `lnbtc` entry on the wallet's // network is paid here; one on another network is refused by name; no // entry at all hands the request to the x402 buyer (or the plain fetch). const probe = await baseFetch(input, init); if (probe.status !== 402) return probe; const paid = await payOffer(input, init, probe); if (paid) return paid; const other = (await fallback()) ?? baseFetch; return other(input, init); } // x402 `exact` on Lightning first where the seller offers it: a request // without the MPP profile reaches the seller's x402 paywall, whose 402 // names an `lnbtc` entry while its Hub can issue. No entry (the Hub is // paused, or this surface has none) falls through to MPP Lightning. if (isX402LightningCandidate(input, init)) { const probe = await baseFetch(input, { ...init, headers: explicitRequestHeaders(init) }); if (probe.status !== 402) return probe; const paid = await payOffer(input, init, probe); if (paid) return paid; } // Deterministic over the request bytes: a retry of the identical call // (after a crash, a timeout, or a gateway hiccup between payment and // fulfilment) lands on the same payment intent and is fulfilled without // a second payment. A deliberate second identical purchase passes its // own Idempotency-Key. const idempotencyKey = defaultIdempotencyKey(input, init); const firstInit = withLightningHeaders(init, idempotencyKey); const first = await baseFetch(input, firstInit); if (first.status !== 402) return first; const stack = await loadStack(); const challenge = readLightningChallenge(first, stack); if (!challenge) { // No Lightning offer: the seller wants x402 (or the rail is paused). const x402 = await fallback(); if (!x402) return first; await first.body?.cancel().catch(() => undefined); return x402(input, init); } await first.body?.cancel().catch(() => undefined); await checkLightningCaps(wallet, challenge.amountSats, perCall()?.maxSats); let preimage; try { preimage = (await wallet.payInvoice(challenge.invoice)).preimage; } catch (error) { // Unknown outcome: resolve by hash before ever paying again. if (error instanceof NwcError && (error.code === "TIMEOUT" || error.code === "RELAY_CLOSED" || error.code === "RELAY_UNREACHABLE")) { const looked = await wallet.lookupInvoice(challenge.paymentHash).catch(() => null); if (looked?.preimage) preimage = looked.preimage; else throw new LightningPaymentError("LIGHTNING_PAYMENT_OUTCOME_UNKNOWN", "The Lightning payment's outcome is unknown; the same request may be retried with the same Idempotency-Key once the wallet is reachable.", { payment_hash: challenge.paymentHash, idempotency_key: idempotencyKey }); } else if (error instanceof NwcError) { throw new LightningPaymentError(`LIGHTNING_${error.code}`, `The Lightning wallet refused the payment (${error.code}).`, { payment_hash: challenge.paymentHash, next_action: "pay over x402 (run402 init --switch-rail)" }); } else { throw error; } } if (sha256Hex(preimage) !== challenge.paymentHash) { throw new LightningPaymentError("LIGHTNING_PREIMAGE_MISMATCH", "The wallet returned a preimage that does not hash to the challenge's payment hash.", { payment_hash: challenge.paymentHash }); } options.onPaid?.(challenge.paymentHash); const credential = stack.Credential.serialize(stack.Credential.from({ challenge: stack.Challenge.deserialize(challenge.header), payload: { preimage }, })); const retryHeaders = new Headers(firstInit.headers); retryHeaders.set("authorization", credential); // Byte-identical retry: same body, same Idempotency-Key, plus the credential. const paidInit = { ...firstInit, headers: retryHeaders }; let response = await baseFetch(input, paidInit); // The money has moved. While the gateway says the settled charge's // fulfilment is still pending, repeat the identical paid request after // its Retry-After: the same intent, no second payment, a bounded wait. for (let waits = 0; waits < POST_SETTLEMENT_RETRY_LIMIT; waits += 1) { const pending = await readPostSettlementRetry(response, requestUrl(input)); if (!pending) break; await response.body?.cancel().catch(() => undefined); await sleep(pending.delayMs); response = await baseFetch(input, paidInit); } return response; }; } /** * `pay.fetch` on the Lightning rail: the buyer above, run under a per-call * context that carries the call's caps in and the payment it made out, so the * result reports the `lnbtc` network and the payment hash as its * transaction. A request that needed no payment, or that the x402 fallback * paid, is reported as the fallback reports it. */ export function createLightningPayExecutor(options) { const calls = new AsyncLocalStorage(); const fetchFn = createLightningFetch({ ...options, perCall: () => calls.getStore(), onPaid: (paymentHash, paid) => { const call = calls.getStore(); if (call) { call.paymentHash = paymentHash; if (paid) call.paid = paid; } }, }); return async (url, init, payOptions) => { const call = { ...(payOptions.maxSats !== undefined ? { maxSats: payOptions.maxSats } : {}), requireReceipt: payOptions.requireReceipt === true, }; const response = await calls.run(call, () => fetchFn(url, init)); if (!call.paymentHash || !call.paid) { return { response, payment: null, outcome: "not_required", replay: await responseSignalsReplay(response, payOptions.idempotencyKey !== undefined), ...payResponseMetadata(response), }; } const settled = readSettledPaymentResponse(response); // A Lightning entry carries no merchant receipt and no signer authorization, so these instants are absent. const noInstant = null; const payment = { protocol: "x402", method: "exact", network: call.paid.network, paymentId: null, amountUsdMicros: 0, amountMsat: call.paid.amountMsat, asset: "BTC", payer: null, payTo: call.paid.payTo, transaction: settled?.transaction ?? call.paymentHash, resourceUrl: call.paid.resourceUrl, settlement: { status: settled?.success ? "verified" : "unavailable" }, fundsMoved: true, deduplicated: false, delivery: { status: response.ok ? "fulfilled" : "failed", replay: false }, offer: { status: "absent", resourceUrl: null, validUntil: null }, merchantReceipt: { status: "absent", claim: null, issuedAt: noInstant }, signerRelationship: { kind: null, merchantRoot: null, signer: null, authorizationExpiresAt: noInstant }, policy: { requireReceipt: false, status: "not_required" }, evidence: { offer: null, merchantReceipt: null, signerAuthorization: null }, }; const result = { response, payment, outcome: "settled", replay: false, ...payResponseMetadata(response), }; return result; }; } /** The seller's `PAYMENT-RESPONSE` (x402 v2): `{ success, transaction, network }`, or null. */ function readSettledPaymentResponse(response) { const header = response.headers.get("payment-response"); if (!header) return null; try { const parsed = JSON.parse(Buffer.from(header, "base64").toString("utf8")); return { success: parsed.success === true, transaction: typeof parsed.transaction === "string" ? parsed.transaction : "", network: typeof parsed.network === "string" ? parsed.network : "", }; } catch { return null; } } //# sourceMappingURL=lightning-paid-fetch.js.map