From 28c88dc717baad6e47b7acd0f0795a89583dd3f3 Mon Sep 17 00:00:00 2001 From: jywy78kgrf-create Date: Tue, 25 Aug 2026 02:16:07 +0000 Subject: [PATCH] feat: optional --guard pre-payment seller trust check Opt-in, off by default. Before a payment is signed, optionally check the seller wallet via the client's onBeforePaymentCreation hook and abort on a recorded FAIL (or UNCERTAIN with --guard-strict). Fail-soft: an unreachable or unsupported check never blocks a payment. No dependency added (single fetch to merona's free, keyless trust API). Covers Base/Polygon/Solana. - lib/trust-guard.ts: fail-soft gate + CAIP-2 chain mapping (+ tests) - handler.ts: GuardConfig, registers onBeforePaymentCreation when enabled - commands/fetch.ts: --guard / --guard-strict flags, passed to both x402 sites - README: 'Optional: pre-payment guard' section --- packages/x402-proxy/README.md | 26 ++++++ packages/x402-proxy/src/commands/fetch.ts | 22 ++++- packages/x402-proxy/src/handler.ts | 34 ++++++- .../x402-proxy/src/lib/trust-guard.test.ts | 83 +++++++++++++++++ packages/x402-proxy/src/lib/trust-guard.ts | 92 +++++++++++++++++++ 5 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 packages/x402-proxy/src/lib/trust-guard.test.ts create mode 100644 packages/x402-proxy/src/lib/trust-guard.ts diff --git a/packages/x402-proxy/README.md b/packages/x402-proxy/README.md index 63012ff..bc093bb 100644 --- a/packages/x402-proxy/README.md +++ b/packages/x402-proxy/README.md @@ -12,6 +12,7 @@ Zero crypto code on the buyer side. One mnemonic derives both EVM (Base/Monad/Te - [Install](#install) - [Usage](#usage) +- [Optional: pre-payment guard](#optional-pre-payment-guard) - [Commands](#commands) - [MCP Proxy](#mcp-proxy) - [Wallet](#wallet) @@ -61,6 +62,31 @@ $ npx x402-proxy --protocol mpp \ $ npx x402-proxy https://api.example.com/data | jq '.results' ``` +## Optional: pre-payment guard + +By default x402-proxy pays a 402 as soon as it sees one. With `--guard` (opt-in, +off by default) it first checks the **seller wallet** and skips the payment if +the seller has a recorded problem — a counterparty check, complementing the +amount you already control. + +```bash +# default — auto-pays, no check +$ npx x402-proxy https://surf.cascade.fyi/api/v1/twitter/user/openclaw + +# opt-in: check the seller before paying +$ npx x402-proxy --guard https://surf.cascade.fyi/api/v1/twitter/user/openclaw + +# strict: also skip when the check is UNCERTAIN +$ npx x402-proxy --guard --guard-strict https://surf.cascade.fyi/api/v1/twitter/user/openclaw +``` + +The check calls [merona](https://merona.io)'s free trust API (an independent +x402 settlement index; no key, no signup) and aborts the payment only on a +recorded **FAIL** (or, with `--guard-strict`, an **UNCERTAIN**). It is +**fail-soft**: if the check is unreachable, slow, or has no coverage for the +network, the payment proceeds — the guard never bricks a request. No dependency +is added; it is a single `fetch`. merona covers Base, Polygon, and Solana today. + ## Commands ```bash diff --git a/packages/x402-proxy/src/commands/fetch.ts b/packages/x402-proxy/src/commands/fetch.ts index 39a0026..d7cd112 100644 --- a/packages/x402-proxy/src/commands/fetch.ts +++ b/packages/x402-proxy/src/commands/fetch.ts @@ -35,6 +35,8 @@ type FetchFlags = { protocol: string | undefined; json: boolean; verbose: boolean; + guard: boolean; + guardStrict: boolean; }; export const fetchCommand = buildCommand({ @@ -102,6 +104,16 @@ Examples: brief: "Show debug details (protocol negotiation, headers, payment flow)", default: false, }, + guard: { + kind: "boolean", + brief: "Check the seller wallet with merona before paying (opt-in, fail-soft)", + default: false, + }, + guardStrict: { + kind: "boolean", + brief: "With --guard, also skip payment when the check is UNCERTAIN", + default: false, + }, }, aliases: { X: "method", @@ -375,7 +387,10 @@ Examples: spendLimitDaily: config?.spendLimitDaily, spendLimitPerTx: config?.spendLimitPerTx, }); - const handler = createX402ProxyHandler({ client }); + const handler = createX402ProxyHandler({ + client, + guard: { enabled: flags.guard, strict: flags.guardStrict }, + }); response = await handler.x402Fetch(parsedUrl.toString(), init); x402Payment = handler.shiftPayment(); usedProtocol = "x402"; @@ -387,7 +402,10 @@ Examples: spendLimitDaily: config?.spendLimitDaily, spendLimitPerTx: config?.spendLimitPerTx, }); - const handler = createX402ProxyHandler({ client }); + const handler = createX402ProxyHandler({ + client, + guard: { enabled: flags.guard, strict: flags.guardStrict }, + }); response = await handler.x402Fetch(parsedUrl.toString(), init); x402Payment = handler.shiftPayment(); usedProtocol = "x402"; diff --git a/packages/x402-proxy/src/handler.ts b/packages/x402-proxy/src/handler.ts index 024cd0e..6d4aaef 100644 --- a/packages/x402-proxy/src/handler.ts +++ b/packages/x402-proxy/src/handler.ts @@ -6,6 +6,7 @@ import { parseUnits } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { clearSession, saveSession } from "./lib/config.js"; import { getMppVoucherHeadroomUsdc, isDebugEnabled } from "./lib/env.js"; +import { checkSeller } from "./lib/trust-guard.js"; export type PaymentInfo = { protocol: "x402"; @@ -32,8 +33,19 @@ export type MppPaymentInfo = { }; }; +/** Opt-in pre-payment trust check. Off unless `enabled`. Always fail-soft. */ +export type GuardConfig = { + enabled: boolean; + /** Also abort on merona UNCERTAIN, not just FAIL. */ + strict?: boolean; + /** Override the trust endpoint (defaults to merona's free API). */ + endpoint?: string; +}; + export type X402ProxyOptions = { client: x402Client; + /** Optional pre-payment guard. When omitted or disabled, behavior is unchanged. */ + guard?: GuardConfig; }; export type X402ProxyHandler = { @@ -108,10 +120,30 @@ export function extractTxSignature(response: Response): string | undefined { * Create an x402 proxy handler that wraps fetch with automatic payment. */ export function createX402ProxyHandler(opts: X402ProxyOptions): X402ProxyHandler { - const { client } = opts; + const { client, guard } = opts; const paymentQueue: PaymentInfo[] = []; + // Opt-in, off by default: check the seller wallet BEFORE the payment is signed. + // onBeforePaymentCreation can return { abort: true } to cancel; the guard is + // fail-soft, so an unreachable/uncovered check never aborts. + if (guard?.enabled) { + client.onBeforePaymentCreation(async (ctx) => { + const { payTo, network } = ctx.selectedRequirements; + const { verdict, reason } = await checkSeller(payTo, network, { + strict: guard.strict, + endpoint: guard.endpoint, + }); + if (verdict === "deny" || (guard.strict && verdict === "review")) { + process.stderr.write( + ` [guard] merona ${verdict} for ${payTo ?? "?"} on ${network ?? "?"}: ${reason} — payment aborted\n`, + ); + return { abort: true, reason: `merona ${verdict}: ${reason}` }; + } + return; + }); + } + client.onAfterPaymentCreation(async (hookCtx) => { const raw = hookCtx.selectedRequirements.amount; paymentQueue.push({ diff --git a/packages/x402-proxy/src/lib/trust-guard.test.ts b/packages/x402-proxy/src/lib/trust-guard.test.ts new file mode 100644 index 0000000..d20ca9b --- /dev/null +++ b/packages/x402-proxy/src/lib/trust-guard.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { checkSeller, meronaChain, verdictFromDecision } from "./trust-guard.js"; + +const PAYTO = "0x1111111111111111111111111111111111111111"; + +function stubFetch(status: number, body: unknown): typeof fetch { + return (async () => + new Response(status === 200 ? JSON.stringify(body) : null, { + status, + })) as unknown as typeof fetch; +} + +describe("meronaChain", () => { + it("maps supported CAIP-2 ids", () => { + expect(meronaChain("eip155:8453")).toBe("base"); + expect(meronaChain("eip155:137")).toBe("polygon"); + expect(meronaChain("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp")).toBe("solana"); + }); + it("accepts plain names", () => { + expect(meronaChain("base")).toBe("base"); + }); + it("returns undefined for uncovered networks", () => { + expect(meronaChain("eip155:999")).toBeUndefined(); + expect(meronaChain(undefined)).toBeUndefined(); + }); +}); + +describe("verdictFromDecision", () => { + it("maps decisions", () => { + expect(verdictFromDecision("FAIL")).toBe("deny"); + expect(verdictFromDecision("UNCERTAIN")).toBe("review"); + expect(verdictFromDecision("PASS")).toBe("allow"); + expect(verdictFromDecision(undefined)).toBe("allow"); + }); +}); + +describe("checkSeller", () => { + it("denies on FAIL", async () => { + const r = await checkSeller(PAYTO, "eip155:8453", { + fetchImpl: stubFetch(200, { decision: "FAIL", reason_code: "charged_unserved" }), + }); + expect(r.verdict).toBe("deny"); + expect(r.reason).toBe("charged_unserved"); + }); + + it("reviews on UNCERTAIN", async () => { + const r = await checkSeller(PAYTO, "eip155:8453", { + fetchImpl: stubFetch(200, { decision: "UNCERTAIN", null_reason: "insufficient_signal" }), + }); + expect(r.verdict).toBe("review"); + }); + + it("allows on PASS", async () => { + const r = await checkSeller(PAYTO, "eip155:8453", { + fetchImpl: stubFetch(200, { decision: "PASS", reason_code: "grade_pass" }), + }); + expect(r.verdict).toBe("allow"); + }); + + it("fails soft (allow) when merona errors", async () => { + const r = await checkSeller(PAYTO, "eip155:8453", { fetchImpl: stubFetch(500, null) }); + expect(r.verdict).toBe("allow"); + }); + + it("fails soft (allow) when fetch throws", async () => { + const throwing = (async () => { + throw new Error("network down"); + }) as unknown as typeof fetch; + const r = await checkSeller(PAYTO, "eip155:8453", { fetchImpl: throwing }); + expect(r.verdict).toBe("allow"); + }); + + it("no-ops (allow) on uncovered network without calling out", async () => { + let called = false; + const spy = (async () => { + called = true; + return new Response("{}", { status: 200 }); + }) as unknown as typeof fetch; + const r = await checkSeller(PAYTO, "eip155:999", { fetchImpl: spy }); + expect(r.verdict).toBe("allow"); + expect(called).toBe(false); + }); +}); diff --git a/packages/x402-proxy/src/lib/trust-guard.ts b/packages/x402-proxy/src/lib/trust-guard.ts new file mode 100644 index 0000000..28d49e1 --- /dev/null +++ b/packages/x402-proxy/src/lib/trust-guard.ts @@ -0,0 +1,92 @@ +/** + * Optional, opt-in pre-payment trust check. + * + * Off by default. Enabled with `--guard`. This adds NO dependency: it is a single + * `fetch` to a free, keyless endpoint, and it FAILS SOFT — if the check is + * unreachable, slow, or unsupported for the network, it never blocks a payment. + * + * The check is provider-agnostic in spirit; the default endpoint is merona + * (https://merona.io), an independent x402 settlement index whose verdicts are + * PASS / FAIL / UNCERTAIN and recomputable from public on-chain data. Only an + * explicit FAIL (or, with `--guard-strict`, an UNCERTAIN) aborts a payment. + */ + +export type GuardVerdict = "allow" | "review" | "deny"; + +const MERONA_ENDPOINT = "https://api.merona.io/v1/trust/evaluate"; + +/** + * Map an x402 network id to a chain merona indexes. x402 uses CAIP-2 + * (`eip155:8453`, `solana:...`); merona covers base / polygon / solana. Anything + * else (Monad, Tempo, testnets) has no coverage and the guard no-ops. + */ +export function meronaChain(network: string | undefined): string | undefined { + if (!network) return undefined; + const n = network.toLowerCase(); + if (n === "eip155:8453") return "base"; + if (n === "eip155:137") return "polygon"; + if (n.startsWith("solana:")) return "solana"; + if (n === "base" || n === "polygon" || n === "solana") return n; + return undefined; +} + +/** + * Map a merona `decision` to a guard verdict. Exported for testing. + */ +export function verdictFromDecision(decision: unknown): GuardVerdict { + const d = String(decision ?? "").toUpperCase(); + if (d === "FAIL") return "deny"; + if (d === "UNCERTAIN") return "review"; + return "allow"; +} + +export type CheckSellerOptions = { + strict?: boolean; + endpoint?: string; + timeoutMs?: number; + /** Injectable for tests; defaults to the global fetch. */ + fetchImpl?: typeof fetch; +}; + +/** + * Check a seller wallet before paying it. Never throws. Returns `allow` on any + * error, timeout, or missing coverage so a check being down cannot brick a + * payment. `review` is returned for merona UNCERTAIN. + */ +export async function checkSeller( + payTo: string | undefined, + network: string | undefined, + opts: CheckSellerOptions = {}, +): Promise<{ verdict: GuardVerdict; reason: string }> { + const chain = meronaChain(network); + if (!payTo || !chain) { + return { verdict: "allow", reason: `no coverage for ${network ?? "this network"}` }; + } + const fetchImpl = opts.fetchImpl ?? globalThis.fetch; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 2500); + try { + const res = await fetchImpl(opts.endpoint ?? MERONA_ENDPOINT, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ subject: { wallet: payTo, chain }, direction: "seller" }), + signal: controller.signal, + }); + if (res.status !== 200) { + return { verdict: "allow", reason: `merona unavailable (${res.status})` }; + } + const j = (await res.json()) as { + decision?: string; + reason_code?: string; + null_reason?: string; + }; + const verdict = verdictFromDecision(j.decision); + if (verdict === "deny") return { verdict, reason: j.reason_code || "recorded issue" }; + if (verdict === "review") return { verdict, reason: j.null_reason || "no evidence on file" }; + return { verdict, reason: j.reason_code || "ok" }; + } catch { + return { verdict: "allow", reason: "merona unreachable" }; + } finally { + clearTimeout(timer); + } +}