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.

195 lines • 9.12 kB
/** * The Lightning rail's x402 buyer: pays the x402 Foundation's `exact` scheme on * Bitcoin Lightning (`lnbtc`) from the agent's own Lightning wallet. * * The seller's 402 carries an `accepts[]` entry on an `lnbtc:` network whose * `extra.invoice` is a BOLT11 invoice committing (by description hash) to the * exact request. The vendored client scheme recomputes that request hash from * the request this buyer is about to send, refuses to pay anything bound to * another request or signed by another key, pays, and checks the preimage. * The paid retry is the same request plus `PAYMENT-SIGNATURE`. * * Run402 offers it on `POST /generate-image/v1` (spec `x402-lightning-exact`); * the Lightning fetch prefers it there over MPP Lightning. The preimage and the * pairing secret never reach a log or an error. */ import { decodeInvoice } from "./vendor/x402-lnbtc/bolt11.js"; import { httpRequestBinding } from "./vendor/x402-lnbtc/binding.js"; import { LnbtcError } from "./vendor/x402-lnbtc/constants.js"; import { ExactLnbtcScheme } from "./vendor/x402-lnbtc/exact/client/scheme.js"; import { NwcError } from "./nwc.js"; /** Run402 surfaces that offer x402 `exact` on `lnbtc`. */ const X402_LNBTC_PATHS = [/^\/generate-image\/v1$/]; /** The fetch default for a string body without an explicit content type. */ const STRING_BODY_CONTENT_TYPE = "text/plain;charset=UTF-8"; function requestUrl(input) { return typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; } /** A POST to a Run402 surface that offers x402 on Lightning. */ export function isX402LightningCandidate(input, init) { const method = (init?.method ?? (typeof input === "object" && "method" in input ? input.method : "GET")).toUpperCase(); if (method !== "POST") return false; try { return X402_LNBTC_PATHS.some((pattern) => pattern.test(new URL(requestUrl(input)).pathname)); } catch { return false; } } /** The x402 v2 challenge's `exact` entry on an `lnbtc:` network with an invoice, or null. */ /** The x402 network ids of every payable `lnbtc` entry in a 402 (empty when there is none). */ export function x402LightningNetworks(response) { const paymentRequired = decodePaymentRequired(response); if (!paymentRequired) return []; return [...new Set(paymentRequired.accepts.filter(isPayableLnbtcEntry).map((entry) => entry.network))]; } function decodePaymentRequired(response) { if (response.status !== 402) return null; const header = response.headers.get("payment-required"); if (!header) return null; let decoded; try { decoded = JSON.parse(Buffer.from(header, "base64").toString("utf8")); } catch { return null; } const paymentRequired = decoded; if (!paymentRequired || paymentRequired.x402Version !== 2 || !Array.isArray(paymentRequired.accepts)) return null; return paymentRequired; } function isPayableLnbtcEntry(entry) { return Boolean(entry) && entry.scheme === "exact" && typeof entry.network === "string" && entry.network.startsWith("lnbtc:") && typeof entry.extra?.invoice === "string" && entry.extra.invoice.length > 0; } /** * The payable `lnbtc` entry of a 402: on `network` when one is named (the * wallet's), else the first one. Null when the 402 carries none. */ export function readX402LightningOffer(response, network) { const paymentRequired = decodePaymentRequired(response); if (!paymentRequired) return null; const requirements = paymentRequired.accepts.find((entry) => isPayableLnbtcEntry(entry) && (network === undefined || entry.network === network)); return requirements ? { paymentRequired, requirements } : null; } /** The body bytes exactly as fetch will send them, or null when they cannot be known up front. */ function bodyBytes(body) { if (body === undefined || body === null) return new Uint8Array(0); if (typeof body === "string") return new TextEncoder().encode(body); if (body instanceof Uint8Array) return body; if (body instanceof ArrayBuffer) return new Uint8Array(body); return null; } /** * The request headers made explicit: a string body without a content type is * sent by fetch as `text/plain;charset=UTF-8`, and the request hash must bind * the value actually sent, so it is set here rather than left to fetch. */ export function explicitRequestHeaders(init) { const headers = new Headers(init?.headers ?? {}); if (typeof init?.body === "string" && !headers.has("content-type")) { headers.set("content-type", STRING_BODY_CONTENT_TYPE); } return headers; } /** Pay the offer and send the paid retry. */ export async function payX402Lightning(options) { const { input, init, offer, wallet, fail } = options; const url = requestUrl(input); const method = (init?.method ?? "POST").toUpperCase(); const bytes = bodyBytes(init?.body); if (!bytes) { throw fail("LIGHTNING_BODY_NOT_REPLAYABLE", "An x402 Lightning payment binds the exact body bytes; a streamed body cannot be paid for."); } const headers = explicitRequestHeaders(init); const params = offer.requirements.extra.requestBindingParams; const boundHeaders = Array.isArray(params?.headers) ? params.headers.filter((name) => typeof name === "string") : []; const amountMsat = BigInt(offer.requirements.amount); await options.checkCaps(Number((amountMsat + 999n) / 1000n)); const payer = { payInvoice: async (invoice) => { const decoded = decodeInvoice(invoice); let preimage; try { preimage = (await wallet.payInvoice(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(decoded.paymentHash).catch(() => null); if (!looked?.preimage) { throw fail("LIGHTNING_PAYMENT_OUTCOME_UNKNOWN", "The Lightning payment's outcome is unknown; check the wallet before paying again.", { payment_hash: decoded.paymentHash }); } preimage = looked.preimage; } else if (error instanceof NwcError) { throw fail(`LIGHTNING_${error.code}`, `The Lightning wallet refused the payment (${error.code}).`, { payment_hash: decoded.paymentHash, next_action: "pay over x402 (run402 init --switch-rail)" }); } else { throw error; } } return { invoice, paymentHash: decoded.paymentHash, amountMsat: decoded.amountMsat, status: "paid", preimage }; }, }; const scheme = new ExactLnbtcScheme({ payer, ...(options.clock ? { clock: options.clock } : {}), requestBinding: () => httpRequestBinding({ method, url, body: bytes, boundHeaders, getHeader: (name) => (headers.has(name) ? [headers.get(name)] : undefined), }), }); let payload; try { const abort = await scheme.schemeHooks.onBeforePaymentCreation?.({ paymentRequired: offer.paymentRequired, selectedRequirements: offer.requirements, }); if (abort && "abort" in abort && abort.abort) { throw new LnbtcError(abort.reason); } const result = await scheme.createPaymentPayload(2, offer.requirements); payload = result.payload; } catch (error) { if (error instanceof LnbtcError) { const mismatch = error.reason === "invalid_exact_lnbtc_request_mismatch" || error.reason === "invalid_exact_lnbtc_invoice_request_mismatch"; throw fail(mismatch ? "LIGHTNING_REQUEST_BINDING_MISMATCH" : "LIGHTNING_X402_INVOICE_REFUSED", mismatch ? "The seller's Lightning invoice is bound to a different request than the one being sent; nothing was paid." : `The seller's x402 Lightning offer failed validation (${error.reason}); nothing was paid.`, { reason: error.reason }); } throw error; } const paymentHash = decodeInvoice(String(offer.requirements.extra.invoice)).paymentHash; options.onPaid?.(paymentHash, { network: offer.requirements.network, amountMsat: Number(offer.requirements.amount), payTo: offer.requirements.payTo, resourceUrl: url, }); const signature = Buffer.from(JSON.stringify({ x402Version: 2, resource: offer.paymentRequired.resource, accepted: offer.requirements, payload, })).toString("base64"); headers.set("payment-signature", signature); return options.baseFetch(input, { ...init, headers }); } //# sourceMappingURL=lightning-x402-lnbtc.js.map