/** * MoneyTools x402 payment guard — copy-paste snippet (not an npm package). * * Plug Pay Precheck + Settle spend caps into any x402 client as a beforePay * hook. wrapFetch / wrapAxios sit *inside* your payment wrapper so a 402 is * gated before anything is signed: * * const guarded = wrapFetch(fetch, { payer, maxUsd: 0.05 }); * const paid = wrapFetchWithPayment(guarded, wallet); * * This file never loads a key, never signs, and never sends a payment. * * Live free tiers (UTC day) and prices, Nov 2026: * Pay Precheck /v1/precheck — HTTP 5/IP/endpoint/day, then 0.005 USDC * Settle /v1/spend/check — trial 50 calls/7 days (1/client/day), then 0.005 USDC * Approval Relay /v1/approvals — 3/day, then 0.005 USDC * * Download: https://moneytools.dev/products/payment-safety/examples/x402-guard.ts */ export const VERSION = "0.1.0"; export const DEFAULTS = { precheckUrl: "https://api.moneytools.dev/v1/precheck", spendCheckUrl: "https://settle.moneytools.dev/v1/spend/check", relayUrl: "https://relay.moneytools.dev/v1/approvals", userAgent: `moneytools-x402-guard/${VERSION}`, } as const; export type Risk = "low" | "med" | "high"; export type Escalate = false | "off" | "high" | "deny" | "always"; export type GuardConfig = { payer?: string; maxUsd?: number; allowWarn?: boolean; precheck?: boolean; spendCheck?: boolean; reserve?: boolean; sessionId?: string; risk?: Risk; escalate?: Escalate; apiKey?: string; precheckUrl?: string; spendCheckUrl?: string; relayUrl?: string; userAgent?: string; headers?: Record; /** Fetch used only for MoneyTools APIs (never the wrapped client). */ mtFetch?: typeof fetch; /** fail closed unless set to "allow". */ onPrecheckError?: "deny" | "allow"; }; export type BeforePayInput = { sellerUrl: string; method?: string; paymentRequired?: string; amountUsd?: string | number; host?: string; payTo?: string; network?: string; risk?: Risk; }; export type BeforePayOk = { proceed: true; step: "ok"; amountUsd?: string; host?: string; payTo?: string; pre?: unknown; cap?: unknown; }; export type BeforePayBlocked = { proceed: false; step: "precheck" | "spend_check" | "budget" | "approval_relay" | "upstream"; reason: string; pre?: unknown; cap?: unknown; approval?: { approval_id?: string; approve_url?: string; poll_url?: string; status?: string }; }; export type BeforePayResult = BeforePayOk | BeforePayBlocked; export class PaymentBlockedError extends Error { readonly result: BeforePayBlocked; constructor(result: BeforePayBlocked) { super(result.reason); this.name = "PaymentBlockedError"; this.result = result; } } export class MoneyToolsApiError extends Error { readonly status: number; readonly body: unknown; constructor(status: number, message: string, body?: unknown) { super(message); this.name = "MoneyToolsApiError"; this.status = status; this.body = body; } } const PAYMENT_HDRS = ["payment-signature", "payment-signature-header", "x-payment"]; export function hasPaymentHeader(headers: Headers | Record | undefined | null): boolean { if (!headers) return false; const get = (name: string) => { if (typeof (headers as Headers).get === "function") return (headers as Headers).get(name); const h = headers as Record; for (const [k, v] of Object.entries(h)) if (k.toLowerCase() === name.toLowerCase()) return v == null ? null : String(v); return null; }; return PAYMENT_HDRS.some((n) => { const v = get(n); return typeof v === "string" && v.trim().length > 0; }); } export function hostOf(url: string): string | undefined { try { return new URL(url).hostname.toLowerCase(); } catch { return undefined; } } export function pickAmountUsd(pre: any, fallback?: string | number): string | undefined { const recIdx = pre?.recommendedOption; const options = Array.isArray(pre?.options) ? pre.options : []; const rec = typeof recIdx === "number" ? options[recIdx] : null; const valid = options.filter((o: any) => o && o.valid && !o?.sanctions?.hit); const hit = rec && rec.valid && !rec?.sanctions?.hit ? rec : valid[0]; const raw = hit?.amountUsd ?? fallback; if (raw == null || raw === "") return undefined; return String(raw); } function requestUrl(input: RequestInfo | URL): string { if (typeof input === "string") return input; if (input instanceof URL) return input.href; return input.url; } function mergeHeaders(input: RequestInfo | URL, init?: RequestInit): Headers { const h = new Headers(init?.headers); if (typeof Request !== "undefined" && input instanceof Request) { input.headers.forEach((v, k) => { if (!h.has(k)) h.set(k, v); }); } return h; } async function mtCall(cfg: GuardConfig, url: string, init: RequestInit = {}): Promise { const fetchImpl = cfg.mtFetch ?? fetch; const headers = new Headers(init.headers); if (!headers.has("user-agent")) headers.set("user-agent", cfg.userAgent ?? DEFAULTS.userAgent); if (!headers.has("accept")) headers.set("accept", "application/json"); if (cfg.apiKey && !headers.has("x-api-key")) headers.set("x-api-key", cfg.apiKey); if (cfg.headers) for (const [k, v] of Object.entries(cfg.headers)) if (!headers.has(k)) headers.set(k, v); const res = await fetchImpl(url, { ...init, headers }); const text = await res.text(); let body: any = text; try { body = text ? JSON.parse(text) : null; } catch { /* keep text */ } if (res.status === 402 || res.status === 401) { throw new MoneyToolsApiError(res.status, `MoneyTools API ${url} returned HTTP ${res.status} (free tier exhausted or key required; pass apiKey or wait for the daily reset). This guard does not pay MoneyTools automatically.`, body); } if (!res.ok) { throw new MoneyToolsApiError(res.status, `MoneyTools API ${url} returned HTTP ${res.status}`, body); } return body; } function shouldEscalate(cfg: GuardConfig, input: BeforePayInput, capDenied: boolean): boolean { const mode = cfg.escalate ?? "off"; if (!mode || mode === "off") return false; if (mode === "always") return true; if (mode === "deny" && capDenied) return true; if (mode === "high" && (input.risk ?? cfg.risk) === "high") return true; return false; } async function maybeRelay(cfg: GuardConfig, input: BeforePayInput, reason: string): Promise { const amount = input.amountUsd != null ? String(input.amountUsd) : "?"; const host = input.host ?? hostOf(input.sellerUrl) ?? "unknown host"; const body = { action_summary: `Pay ${amount} USDC to ${host} at ${input.sellerUrl}`.slice(0, 500), risk: input.risk ?? cfg.risk ?? "med", expires_in_s: 3600, }; try { const appr = await mtCall(cfg, cfg.relayUrl ?? DEFAULTS.relayUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); return { proceed: false, step: "approval_relay", reason, approval: { approval_id: appr?.approval_id, approve_url: appr?.approve_url, poll_url: appr?.poll_url, status: appr?.status, }, }; } catch (err) { return { proceed: false, step: "approval_relay", reason: `${reason} (Approval Relay failed: ${err instanceof Error ? err.message : String(err)})`, }; } } export async function beforePay(input: BeforePayInput, cfg: GuardConfig = {}): Promise { const host = input.host ?? hostOf(input.sellerUrl); const doPre = cfg.precheck !== false; const doCap = cfg.spendCheck !== false; let pre: any; if (doPre) { try { const u = new URL(cfg.precheckUrl ?? DEFAULTS.precheckUrl); u.searchParams.set("url", input.sellerUrl); u.searchParams.set("method", (input.method || "GET").toUpperCase()); pre = await mtCall(cfg, u.toString()); } catch (err) { if (err instanceof MoneyToolsApiError) { return { proceed: false, step: "upstream", reason: err.message }; } if (cfg.onPrecheckError === "allow") { /* fall through to spend check / local budget */ } else { return { proceed: false, step: "precheck", reason: `precheck failed (fail closed): ${err instanceof Error ? err.message : String(err)}` }; } } if (pre) { const verdict = pre.verdict; if (verdict === "block" || pre.ok === false || verdict === "not_402") { return { proceed: false, step: "precheck", reason: pre.summary || `precheck verdict=${verdict}`, pre }; } if (verdict === "warn" && !cfg.allowWarn) { return { proceed: false, step: "precheck", reason: pre.summary || "precheck verdict=warn (set allowWarn to proceed)", pre }; } } } const amountUsd = pickAmountUsd(pre, input.amountUsd); const payTo = input.payTo ?? pre?.options?.find((o: any) => o?.valid && !o?.sanctions?.hit)?.payTo; const scoped: BeforePayInput = { ...input, amountUsd, host, payTo }; if (cfg.maxUsd != null && amountUsd != null && Number(amountUsd) > cfg.maxUsd) { const blocked: BeforePayBlocked = { proceed: false, step: "budget", reason: `amount ${amountUsd} USDC exceeds local maxUsd ${cfg.maxUsd}`, pre, }; if (shouldEscalate(cfg, scoped, false)) return maybeRelay(cfg, scoped, blocked.reason); return blocked; } let cap: any; if (doCap) { if (!cfg.payer) { return { proceed: false, step: "spend_check", reason: "spendCheck is on but cfg.payer is missing", pre }; } cap = await mtCall(cfg, cfg.spendCheckUrl ?? DEFAULTS.spendCheckUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ payer: cfg.payer, paymentRequired: input.paymentRequired, amountUsd, payTo, host, network: input.network, sessionId: cfg.sessionId, reserve: cfg.reserve === true, }), }); if (cap?.allow === false) { const reason = Array.isArray(cap.reasons) && cap.reasons[0] ? (cap.reasons[0].message || cap.reasons[0].code || "spend cap denied") : "spend cap denied"; if (shouldEscalate(cfg, scoped, true)) { const relayed = await maybeRelay(cfg, scoped, reason); relayed.pre = pre; relayed.cap = cap; return relayed; } return { proceed: false, step: "spend_check", reason, pre, cap }; } } if (shouldEscalate(cfg, scoped, false)) { const relayed = await maybeRelay(cfg, scoped, "escalated to Approval Relay"); relayed.pre = pre; relayed.cap = cap; return relayed; } return { proceed: true, step: "ok", amountUsd, host, payTo, pre, cap }; } export async function assertAllowed(input: BeforePayInput, cfg: GuardConfig): Promise { const result = await beforePay(input, cfg); if (!result.proceed) throw new PaymentBlockedError(result); return result; } export function wrapFetch(baseFetch: typeof fetch, cfg: GuardConfig): typeof fetch { const wrapped = (async (input: RequestInfo | URL, init?: RequestInit) => { const headers = mergeHeaders(input, init); if (hasPaymentHeader(headers)) return baseFetch(input, init); const res = await baseFetch(input, init); if (res.status !== 402) return res; const paymentRequired = res.headers.get("PAYMENT-REQUIRED") || res.headers.get("payment-required") || await res.clone().text(); await assertAllowed({ sellerUrl: requestUrl(input), method: init?.method || (typeof Request !== "undefined" && input instanceof Request ? input.method : "GET"), paymentRequired, host: hostOf(requestUrl(input)), }, cfg); return res; }) as typeof fetch; return wrapped; } /** * Gates 402 responses before other interceptors / adapters pay. * Prefer composing like wrapFetch: payment wrapper outside, this inside. * wrapAxios unshifts its interceptor so it still runs first under axios LIFO. */ export function wrapAxios(instance: T, cfg: GuardConfig): T { const onSuccess = async (res: any) => { const status = res?.status; const headers = res?.config?.headers || res?.headers; if (status === 402 && !hasPaymentHeader(headers)) { const hdr = headerGet(res?.headers, "payment-required"); const sellerUrl = res?.config?.url || res?.request?.responseURL; await assertAllowed({ sellerUrl: String(sellerUrl), method: res?.config?.method, paymentRequired: hdr || (typeof res?.data === "string" ? res.data : JSON.stringify(res?.data ?? "")), host: hostOf(String(sellerUrl || "")), }, cfg); } return res; }; const onError = async (err: any) => { const res = err?.response; if (res?.status === 402 && !hasPaymentHeader(res?.config?.headers || res?.headers)) { const hdr = headerGet(res?.headers, "payment-required"); const sellerUrl = res?.config?.url || ""; await assertAllowed({ sellerUrl: String(sellerUrl), method: res?.config?.method, paymentRequired: hdr || (typeof res?.data === "string" ? res.data : JSON.stringify(res?.data ?? "")), host: hostOf(String(sellerUrl || "")), }, cfg); } throw err; }; instance.interceptors.response.use(onSuccess, onError); const handlers = instance.interceptors.response.handlers; if (Array.isArray(handlers) && handlers.length > 1) { const last = handlers.pop(); handlers.unshift(last); } return instance; } function headerGet(headers: any, name: string): string | undefined { if (!headers) return undefined; if (typeof headers.get === "function") return headers.get(name) || headers.get(name.toUpperCase()) || undefined; for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === name.toLowerCase()) return v == null ? undefined : String(v); return undefined; }