Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/x402-proxy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions packages/x402-proxy/src/commands/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ type FetchFlags = {
protocol: string | undefined;
json: boolean;
verbose: boolean;
guard: boolean;
guardStrict: boolean;
};

export const fetchCommand = buildCommand<FetchFlags, [url?: string], CommandContext>({
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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";
Expand All @@ -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";
Expand Down
34 changes: 33 additions & 1 deletion packages/x402-proxy/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 = {
Expand Down Expand Up @@ -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({
Expand Down
83 changes: 83 additions & 0 deletions packages/x402-proxy/src/lib/trust-guard.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
92 changes: 92 additions & 0 deletions packages/x402-proxy/src/lib/trust-guard.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}