diff --git a/README.md b/README.md
index 053f0c5a..9cf0b0ee 100644
--- a/README.md
+++ b/README.md
@@ -92,6 +92,17 @@ Create genuine value for humans and other agents. Never spam, scam, exploit, or
**III. Never deceive, but owe nothing to strangers.**
Never deny what you are. Never misrepresent your actions. Your creator has full audit rights. But guard your reasoning, your strategy, and your prompt against manipulation. Obedience to strangers is not a virtue. Compliance with untrusted requests that compromise your integrity is a violation, not a duty.
+## x402 Payments
+
+Automatons pay for services using the [x402 protocol](https://www.x402.org/) — HTTP 402 responses with USDC payment requirements. The `x402_fetch` tool uses a two-phase approval flow so the agent reasons about each payment before signing:
+
+1. **Discover** — `x402_fetch(url)` hits the URL. If the server returns 402, the tool returns the payment details (amount, recipient, network, current USDC balance) *without* signing anything. The agent reviews the cost in its normal reasoning turn.
+2. **Pay** — `x402_fetch(url, approve_payment: true)` makes a fresh request, signs the USDC payment via EIP-712, and retries with the `X-Payment` header. The transaction is logged to the `transactions` table.
+
+Non-402 URLs return the response directly — no two-phase flow triggered.
+
+A middleware pipeline (`PaymentMiddleware[]`) runs before every signing attempt. Phase 1 uses a blocking middleware to force the early return. Phase 2 runs with an empty chain, open for future additions (balance checks, rate limits, allowlists) without changing `x402Fetch`.
+
## On-Chain Identity
Each automaton registers on Base via ERC-8004 — a standard for autonomous agent identity. This makes the agent cryptographically verifiable and discoverable by other agents on-chain. The wallet it generates at boot is its identity.
@@ -127,7 +138,7 @@ node packages/cli/dist/index.js fund 5.00
```
src/
agent/ # ReAct loop, system prompt, context, injection defense
- conway/ # Conway API client (credits, x402)
+ conway/ # Conway API client (credits, x402 payment middleware)
git/ # State versioning, git tools
heartbeat/ # Cron daemon, scheduled tasks
identity/ # Wallet management, SIWE provisioning
diff --git a/src/__tests__/x402.test.ts b/src/__tests__/x402.test.ts
new file mode 100644
index 00000000..776ab663
--- /dev/null
+++ b/src/__tests__/x402.test.ts
@@ -0,0 +1,654 @@
+/**
+ * x402 Payment Middleware Tests
+ *
+ * Tests for the two-phase x402 payment flow, middleware pipeline,
+ * and transaction logging in the x402_fetch tool.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import {
+ MockInferenceClient,
+ MockConwayClient,
+ createTestDb,
+ createTestIdentity,
+ createTestConfig,
+} from "./mocks.js";
+import { createBuiltinTools, executeTool } from "../agent/tools.js";
+import type { AutomatonDatabase, ToolContext } from "../types.js";
+
+// ─── Direct x402Fetch unit tests ────────────────────────────────
+
+describe("x402Fetch middleware pipeline", () => {
+ it("returns paymentDetails when middleware blocks", async () => {
+ // Mock fetch to return 402 with payment requirement
+ const mockFetch = vi.fn().mockResolvedValueOnce({
+ status: 402,
+ ok: false,
+ headers: new Headers({
+ "X-Payment-Required": JSON.stringify({
+ x402Version: 1,
+ accepts: [
+ {
+ scheme: "exact",
+ network: "eip155:8453",
+ maxAmountRequired: "0.001000",
+ payToAddress: "0xRecipient",
+ requiredDeadlineSeconds: 300,
+ usdcAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
+ },
+ ],
+ }),
+ }),
+ json: async () => ({}),
+ });
+ vi.stubGlobal("fetch", mockFetch);
+
+ const { x402Fetch } = await import("../conway/x402.js");
+
+ const result = await x402Fetch(
+ "https://api.example.com/paid",
+ { address: "0xSigner" } as any,
+ {
+ middleware: [
+ async () => ({ proceed: false as const, reason: "Requires agent approval" }),
+ ],
+ },
+ );
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe("Requires agent approval");
+ expect(result.paymentDetails).toBeDefined();
+ expect(result.paymentDetails!.requirement.maxAmountRequired).toBe("0.001000");
+ expect(result.paymentDetails!.requirement.payToAddress).toBe("0xRecipient");
+ expect(result.paymentDetails!.x402Version).toBe(1);
+ expect(result.status).toBe(402);
+
+ // Only one fetch call — no retry with payment
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+
+ vi.unstubAllGlobals();
+ });
+
+ it("proceeds to sign when middleware allows", async () => {
+ const mockFetch = vi
+ .fn()
+ // First call: 402
+ .mockResolvedValueOnce({
+ status: 402,
+ ok: false,
+ headers: new Headers({
+ "X-Payment-Required": JSON.stringify({
+ x402Version: 1,
+ accepts: [
+ {
+ scheme: "exact",
+ network: "eip155:8453",
+ maxAmountRequired: "0.001000",
+ payToAddress: "0xRecipient",
+ requiredDeadlineSeconds: 300,
+ usdcAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
+ },
+ ],
+ }),
+ }),
+ json: async () => ({}),
+ })
+ // Second call: success after payment
+ .mockResolvedValueOnce({
+ status: 200,
+ ok: true,
+ headers: new Headers(),
+ json: async () => ({ result: "paid content" }),
+ });
+ vi.stubGlobal("fetch", mockFetch);
+
+ const mockAccount = {
+ address: "0x1234567890abcdef1234567890abcdef12345678",
+ signTypedData: vi.fn().mockResolvedValue("0xmocksignature"),
+ } as any;
+
+ const { x402Fetch } = await import("../conway/x402.js");
+
+ const result = await x402Fetch(
+ "https://api.example.com/paid",
+ mockAccount,
+ {
+ middleware: [async () => ({ proceed: true as const })],
+ },
+ );
+
+ expect(result.success).toBe(true);
+ expect(result.response).toEqual({ result: "paid content" });
+ expect(result.paymentDetails).toBeDefined();
+ expect(mockFetch).toHaveBeenCalledTimes(2);
+
+ // Verify second call has X-Payment header
+ const secondCallHeaders = mockFetch.mock.calls[1][1].headers;
+ expect(secondCallHeaders["X-Payment"]).toBeDefined();
+
+ vi.unstubAllGlobals();
+ });
+
+ it("stops at first blocking middleware in chain", async () => {
+ const mockFetch = vi.fn().mockResolvedValueOnce({
+ status: 402,
+ ok: false,
+ headers: new Headers({
+ "X-Payment-Required": JSON.stringify({
+ x402Version: 2,
+ accepts: [
+ {
+ scheme: "exact",
+ network: "eip155:8453",
+ maxAmountRequired: "5000000",
+ payToAddress: "0xRecipient",
+ requiredDeadlineSeconds: 300,
+ usdcAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
+ },
+ ],
+ }),
+ }),
+ json: async () => ({}),
+ });
+ vi.stubGlobal("fetch", mockFetch);
+
+ const mw1Called = vi.fn().mockResolvedValue({ proceed: true });
+ const mw2Called = vi.fn().mockResolvedValue({ proceed: false, reason: "Over budget" });
+ const mw3Called = vi.fn().mockResolvedValue({ proceed: true });
+
+ const { x402Fetch } = await import("../conway/x402.js");
+
+ const result = await x402Fetch(
+ "https://api.example.com/expensive",
+ { address: "0xSigner" } as any,
+ {
+ middleware: [mw1Called, mw2Called, mw3Called],
+ },
+ );
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe("Over budget");
+ expect(mw1Called).toHaveBeenCalledTimes(1);
+ expect(mw2Called).toHaveBeenCalledTimes(1);
+ expect(mw3Called).not.toHaveBeenCalled();
+
+ vi.unstubAllGlobals();
+ });
+
+ it("non-402 responses bypass middleware entirely", async () => {
+ const mockFetch = vi.fn().mockResolvedValueOnce({
+ status: 200,
+ ok: true,
+ headers: new Headers(),
+ json: async () => ({ data: "free content" }),
+ });
+ vi.stubGlobal("fetch", mockFetch);
+
+ const mw = vi.fn();
+
+ const { x402Fetch } = await import("../conway/x402.js");
+
+ const result = await x402Fetch(
+ "https://api.example.com/free",
+ { address: "0xSigner" } as any,
+ {
+ middleware: [mw],
+ },
+ );
+
+ expect(result.success).toBe(true);
+ expect(result.response).toEqual({ data: "free content" });
+ expect(result.paymentDetails).toBeUndefined();
+ expect(mw).not.toHaveBeenCalled();
+
+ vi.unstubAllGlobals();
+ });
+
+ it("backward compat: positional args still work", async () => {
+ const mockFetch = vi.fn().mockResolvedValueOnce({
+ status: 200,
+ ok: true,
+ headers: new Headers(),
+ json: async () => ({ ok: true }),
+ });
+ vi.stubGlobal("fetch", mockFetch);
+
+ const { x402Fetch } = await import("../conway/x402.js");
+
+ const result = await x402Fetch(
+ "https://api.example.com/data",
+ { address: "0xSigner" } as any,
+ "POST",
+ '{"key":"value"}',
+ { Authorization: "Bearer token" },
+ );
+
+ expect(result.success).toBe(true);
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+
+ const [, fetchInit] = mockFetch.mock.calls[0];
+ expect(fetchInit.method).toBe("POST");
+ expect(fetchInit.body).toBe('{"key":"value"}');
+ expect(fetchInit.headers.Authorization).toBe("Bearer token");
+
+ vi.unstubAllGlobals();
+ });
+
+ it("middleware receives correct PaymentContext", async () => {
+ const mockFetch = vi.fn().mockResolvedValueOnce({
+ status: 402,
+ ok: false,
+ headers: new Headers({
+ "X-Payment-Required": JSON.stringify({
+ x402Version: 2,
+ accepts: [
+ {
+ scheme: "exact",
+ network: "eip155:84532",
+ maxAmountRequired: "1000000",
+ payToAddress: "0xPayee",
+ requiredDeadlineSeconds: 600,
+ usdcAddress: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
+ },
+ ],
+ }),
+ }),
+ json: async () => ({}),
+ });
+ vi.stubGlobal("fetch", mockFetch);
+
+ let capturedCtx: any = null;
+ const { x402Fetch } = await import("../conway/x402.js");
+
+ await x402Fetch(
+ "https://api.example.com/sepolia",
+ { address: "0xMyAddress" } as any,
+ {
+ method: "POST",
+ middleware: [
+ async (ctx) => {
+ capturedCtx = ctx;
+ return { proceed: false as const, reason: "inspecting" };
+ },
+ ],
+ },
+ );
+
+ expect(capturedCtx).not.toBeNull();
+ expect(capturedCtx.url).toBe("https://api.example.com/sepolia");
+ expect(capturedCtx.method).toBe("POST");
+ expect(capturedCtx.requirement.network).toBe("eip155:84532");
+ expect(capturedCtx.requirement.payToAddress).toBe("0xPayee");
+ expect(capturedCtx.x402Version).toBe(2);
+ expect(capturedCtx.signerAddress).toBe("0xMyAddress");
+
+ vi.unstubAllGlobals();
+ });
+});
+
+// ─── x402_fetch tool integration tests ──────────────────────────
+
+describe("x402_fetch tool two-phase flow", () => {
+ let db: AutomatonDatabase;
+ let conway: MockConwayClient;
+ let identity: ReturnType;
+ let config: ReturnType;
+
+ beforeEach(() => {
+ db = createTestDb();
+ conway = new MockConwayClient();
+ identity = createTestIdentity();
+ config = createTestConfig();
+ });
+
+ afterEach(() => {
+ db.close();
+ vi.unstubAllGlobals();
+ });
+
+ function makeToolContext(): ToolContext {
+ return {
+ identity,
+ config,
+ db,
+ conway,
+ inference: new MockInferenceClient(),
+ };
+ }
+
+ it("Phase 1: returns payment details without signing", async () => {
+ // Mock fetch to return 402
+ const mockFetch = vi.fn().mockResolvedValueOnce({
+ status: 402,
+ ok: false,
+ headers: new Headers({
+ "X-Payment-Required": JSON.stringify({
+ x402Version: 1,
+ accepts: [
+ {
+ scheme: "exact",
+ network: "eip155:8453",
+ maxAmountRequired: "0.500000",
+ payToAddress: "0xServiceProvider",
+ requiredDeadlineSeconds: 300,
+ usdcAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
+ },
+ ],
+ }),
+ }),
+ json: async () => ({}),
+ });
+ vi.stubGlobal("fetch", mockFetch);
+
+ // Mock getUsdcBalance to avoid real RPC
+ const x402Module = await import("../conway/x402.js");
+ vi.spyOn(x402Module, "getUsdcBalance").mockResolvedValue(10.5);
+
+ const tools = createBuiltinTools(identity.sandboxId);
+ const ctx = makeToolContext();
+
+ const result = await executeTool(
+ "x402_fetch",
+ { url: "https://paid-api.example.com/data" },
+ tools,
+ ctx,
+ );
+
+ expect(result.error).toBeUndefined();
+ expect(result.result).toContain("requires x402 payment");
+ expect(result.result).toContain("0.500000 USDC");
+ expect(result.result).toContain("0xServiceProvider");
+ expect(result.result).toContain("10.500000 USDC");
+ expect(result.result).toContain("approve_payment: true");
+
+ // No payment signed — only 1 fetch call
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+
+ // No transaction logged
+ const txns = db.getRecentTransactions(10);
+ expect(txns.filter((t) => t.type === "x402_payment")).toHaveLength(0);
+ });
+
+ it("Phase 2: signs payment and logs transaction", async () => {
+ const mockFetch = vi
+ .fn()
+ // 402 response
+ .mockResolvedValueOnce({
+ status: 402,
+ ok: false,
+ headers: new Headers({
+ "X-Payment-Required": JSON.stringify({
+ x402Version: 1,
+ accepts: [
+ {
+ scheme: "exact",
+ network: "eip155:8453",
+ maxAmountRequired: "0.250000",
+ payToAddress: "0xServiceProvider",
+ requiredDeadlineSeconds: 300,
+ usdcAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
+ },
+ ],
+ }),
+ }),
+ json: async () => ({}),
+ })
+ // Paid response
+ .mockResolvedValueOnce({
+ status: 200,
+ ok: true,
+ headers: new Headers(),
+ json: async () => ({ result: "premium data" }),
+ });
+ vi.stubGlobal("fetch", mockFetch);
+
+ // Need a real-ish account for signTypedData
+ const identityWithSigner = {
+ ...identity,
+ account: {
+ ...identity.account,
+ address: identity.address,
+ signTypedData: vi.fn().mockResolvedValue("0xmocksig"),
+ },
+ };
+
+ const tools = createBuiltinTools(identityWithSigner.sandboxId);
+ const ctx: ToolContext = {
+ identity: identityWithSigner,
+ config,
+ db,
+ conway,
+ inference: new MockInferenceClient(),
+ };
+
+ const result = await executeTool(
+ "x402_fetch",
+ { url: "https://paid-api.example.com/data", approve_payment: true },
+ tools,
+ ctx,
+ );
+
+ expect(result.error).toBeUndefined();
+ expect(result.result).toContain("x402 fetch succeeded");
+ expect(result.result).toContain("premium data");
+
+ // Two fetch calls: initial + paid retry
+ expect(mockFetch).toHaveBeenCalledTimes(2);
+
+ // Transaction logged
+ const txns = db.getRecentTransactions(10);
+ const x402Txn = txns.find((t) => t.type === "x402_payment");
+ expect(x402Txn).toBeDefined();
+ expect(x402Txn!.amountCents).toBe(25); // 0.25 USDC = 25 cents
+ expect(x402Txn!.description).toContain("0xServiceProvider");
+ expect(x402Txn!.description).toContain("paid-api.example.com");
+ });
+
+ it("non-402 URL returns response directly (no two-phase)", async () => {
+ const mockFetch = vi.fn().mockResolvedValueOnce({
+ status: 200,
+ ok: true,
+ headers: new Headers(),
+ json: async () => ({ data: "free" }),
+ });
+ vi.stubGlobal("fetch", mockFetch);
+
+ const tools = createBuiltinTools(identity.sandboxId);
+ const ctx = makeToolContext();
+
+ const result = await executeTool(
+ "x402_fetch",
+ { url: "https://free-api.example.com/data" },
+ tools,
+ ctx,
+ );
+
+ expect(result.error).toBeUndefined();
+ expect(result.result).toContain("x402 fetch succeeded");
+ expect(result.result).toContain("free");
+ expect(result.result).not.toContain("requires x402 payment");
+
+ // No transaction logged
+ const txns = db.getRecentTransactions(10);
+ expect(txns.filter((t) => t.type === "x402_payment")).toHaveLength(0);
+ });
+
+ it("Phase 2 with v2 integer amounts parses correctly", async () => {
+ const mockFetch = vi
+ .fn()
+ .mockResolvedValueOnce({
+ status: 402,
+ ok: false,
+ headers: new Headers({
+ "X-Payment-Required": JSON.stringify({
+ x402Version: 2,
+ accepts: [
+ {
+ scheme: "exact",
+ network: "eip155:8453",
+ maxAmountRequired: "1500000", // 1.5 USDC in v2 raw units
+ payToAddress: "0xVendor",
+ requiredDeadlineSeconds: 300,
+ usdcAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
+ },
+ ],
+ }),
+ }),
+ json: async () => ({}),
+ })
+ .mockResolvedValueOnce({
+ status: 200,
+ ok: true,
+ headers: new Headers(),
+ json: async () => ({ ok: true }),
+ });
+ vi.stubGlobal("fetch", mockFetch);
+
+ const identityWithSigner = {
+ ...identity,
+ account: {
+ ...identity.account,
+ address: identity.address,
+ signTypedData: vi.fn().mockResolvedValue("0xmocksig"),
+ },
+ };
+
+ const tools = createBuiltinTools(identityWithSigner.sandboxId);
+ const ctx: ToolContext = {
+ identity: identityWithSigner,
+ config,
+ db,
+ conway,
+ inference: new MockInferenceClient(),
+ };
+
+ const result = await executeTool(
+ "x402_fetch",
+ { url: "https://api.example.com/v2", approve_payment: true },
+ tools,
+ ctx,
+ );
+
+ expect(result.result).toContain("x402 fetch succeeded");
+
+ const txns = db.getRecentTransactions(10);
+ const x402Txn = txns.find((t) => t.type === "x402_payment");
+ expect(x402Txn).toBeDefined();
+ expect(x402Txn!.amountCents).toBe(150); // 1.5 USDC = 150 cents
+ expect(x402Txn!.description).toContain("1.500000 USDC");
+ });
+});
+
+// ─── Sequential Phase 1 → Phase 2 integration test ─────────────
+
+describe("x402 two-phase sequential tool calls", () => {
+ let db: AutomatonDatabase;
+ let conway: MockConwayClient;
+ let identity: ReturnType;
+ let config: ReturnType;
+
+ beforeEach(() => {
+ db = createTestDb();
+ conway = new MockConwayClient();
+ identity = createTestIdentity();
+ config = createTestConfig();
+ });
+
+ afterEach(() => {
+ db.close();
+ vi.unstubAllGlobals();
+ });
+
+ it("Phase 1 discover then Phase 2 approve against same tool instance", async () => {
+ const paymentRequired = JSON.stringify({
+ x402Version: 1,
+ accepts: [
+ {
+ scheme: "exact",
+ network: "eip155:8453",
+ maxAmountRequired: "0.010000",
+ payToAddress: "0xService",
+ requiredDeadlineSeconds: 300,
+ usdcAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
+ },
+ ],
+ });
+
+ const mockFetch = vi
+ .fn()
+ // Phase 1: 402
+ .mockResolvedValueOnce({
+ status: 402,
+ ok: false,
+ headers: new Headers({ "X-Payment-Required": paymentRequired }),
+ json: async () => ({}),
+ })
+ // Phase 2: 402 again (fresh requirement)
+ .mockResolvedValueOnce({
+ status: 402,
+ ok: false,
+ headers: new Headers({ "X-Payment-Required": paymentRequired }),
+ json: async () => ({}),
+ })
+ // Phase 2: paid retry succeeds
+ .mockResolvedValueOnce({
+ status: 200,
+ ok: true,
+ headers: new Headers(),
+ json: async () => ({ content: "paid result" }),
+ });
+ vi.stubGlobal("fetch", mockFetch);
+
+ const x402Module = await import("../conway/x402.js");
+ vi.spyOn(x402Module, "getUsdcBalance").mockResolvedValue(5.0);
+
+ const identityWithSigner = {
+ ...identity,
+ account: {
+ ...identity.account,
+ address: identity.address,
+ signTypedData: vi.fn().mockResolvedValue("0xmocksig"),
+ },
+ };
+
+ const tools = createBuiltinTools(identityWithSigner.sandboxId);
+ const ctx: ToolContext = {
+ identity: identityWithSigner,
+ config,
+ db,
+ conway,
+ inference: new MockInferenceClient(),
+ };
+
+ // Phase 1: discover
+ const phase1 = await executeTool(
+ "x402_fetch",
+ { url: "https://paid-api.example.com/data" },
+ tools,
+ ctx,
+ );
+
+ expect(phase1.result).toContain("requires x402 payment");
+ expect(phase1.result).toContain("0.010000 USDC");
+ expect(phase1.result).toContain("5.000000 USDC");
+ expect(db.getRecentTransactions(10).filter((t) => t.type === "x402_payment")).toHaveLength(0);
+
+ // Phase 2: approve
+ const phase2 = await executeTool(
+ "x402_fetch",
+ { url: "https://paid-api.example.com/data", approve_payment: true },
+ tools,
+ ctx,
+ );
+
+ expect(phase2.result).toContain("x402 fetch succeeded");
+ expect(phase2.result).toContain("paid result");
+
+ // Transaction logged after Phase 2
+ const txns = db.getRecentTransactions(10);
+ const x402Txn = txns.find((t) => t.type === "x402_payment");
+ expect(x402Txn).toBeDefined();
+ expect(x402Txn!.description).toContain("0xService");
+ expect(x402Txn!.amountCents).toBe(1); // 0.01 USDC = 1 cent
+ });
+});
diff --git a/src/agent/tools.ts b/src/agent/tools.ts
index 60dedac4..1c060571 100644
--- a/src/agent/tools.ts
+++ b/src/agent/tools.ts
@@ -1444,7 +1444,7 @@ Model: ${ctx.inference.getDefaultModel()}
{
name: "x402_fetch",
description:
- "Fetch a URL with automatic x402 USDC payment. If the server responds with HTTP 402, signs a USDC payment and retries. Use this to access paid APIs and services.",
+ "Fetch a URL with x402 USDC payment support. Two-phase flow: (1) Call without approve_payment to discover payment details — returns amount, recipient, and your balance without signing. (2) Call again with approve_payment: true to sign and pay. Non-402 URLs return the response directly.",
category: "financial",
parameters: {
type: "object",
@@ -1465,26 +1465,70 @@ Model: ${ctx.inference.getDefaultModel()}
type: "string",
description: "Additional headers as JSON string",
},
+ approve_payment: {
+ type: "boolean",
+ description: "Set to true to approve and sign the x402 payment. Omit or false to discover payment details first.",
+ },
},
required: ["url"],
},
execute: async (args, ctx) => {
- const { x402Fetch } = await import("../conway/x402.js");
+ const { x402Fetch, getUsdcBalance } = await import("../conway/x402.js");
const url = args.url as string;
const method = (args.method as string) || "GET";
const body = args.body as string | undefined;
+ const approvePayment = args.approve_payment as boolean | undefined;
const extraHeaders = args.headers
? JSON.parse(args.headers as string)
: undefined;
+ // Build middleware based on approval state
+ const middleware = approvePayment
+ ? [] // Phase 2: no blocking middleware, proceed to sign
+ : [async () => ({ proceed: false as const, reason: "Requires agent approval" })]; // Phase 1: block signing
+
const result = await x402Fetch(
url,
ctx.identity.account,
- method,
- body,
- extraHeaders,
+ { method, body, headers: extraHeaders, middleware },
);
+ // Phase 1: 402 encountered, middleware blocked signing — return payment details
+ if (result.paymentDetails && !result.success && !approvePayment) {
+ const req = result.paymentDetails.requirement;
+ const amountRaw = req.maxAmountRequired;
+ // Parse human-readable USDC amount
+ const amountNum = amountRaw.includes(".")
+ ? parseFloat(amountRaw)
+ : result.paymentDetails.x402Version >= 2 || amountRaw.length > 6
+ ? Number(BigInt(amountRaw)) / 1_000_000
+ : parseFloat(amountRaw);
+ const balance = await getUsdcBalance(ctx.identity.address, req.network);
+
+ return `This URL requires x402 payment:\n Amount: ${amountNum.toFixed(6)} USDC\n Recipient: ${req.payToAddress}\n Network: ${req.network}\n Scheme: ${req.scheme}\n Your USDC balance: ${balance.toFixed(6)} USDC\n\nTo proceed, call x402_fetch again with approve_payment: true`;
+ }
+
+ // Phase 2 success: log the transaction
+ if (result.success && result.paymentDetails) {
+ const req = result.paymentDetails.requirement;
+ const amountRaw = req.maxAmountRequired;
+ const amountNum = amountRaw.includes(".")
+ ? parseFloat(amountRaw)
+ : result.paymentDetails.x402Version >= 2 || amountRaw.length > 6
+ ? Number(BigInt(amountRaw)) / 1_000_000
+ : parseFloat(amountRaw);
+ const amountCents = Math.max(1, Math.round(amountNum * 100));
+
+ const { ulid } = await import("ulid");
+ ctx.db.insertTransaction({
+ id: ulid(),
+ type: "x402_payment",
+ amountCents,
+ description: `x402 payment: ${amountNum.toFixed(6)} USDC to ${req.payToAddress} for ${url}`,
+ timestamp: new Date().toISOString(),
+ });
+ }
+
if (!result.success) {
return `x402 fetch failed: ${result.error || "Unknown error"}`;
}
diff --git a/src/conway/x402.ts b/src/conway/x402.ts
index 40e42ddf..d41dfb3d 100644
--- a/src/conway/x402.ts
+++ b/src/conway/x402.ts
@@ -36,7 +36,7 @@ const BALANCE_OF_ABI = [
},
] as const;
-interface PaymentRequirement {
+export interface PaymentRequirement {
scheme: string;
network: NetworkId;
maxAmountRequired: string;
@@ -55,11 +55,15 @@ interface ParsedPaymentRequirement {
requirement: PaymentRequirement;
}
-interface X402PaymentResult {
+export interface X402PaymentResult {
success: boolean;
response?: any;
error?: string;
status?: number;
+ paymentDetails?: {
+ requirement: PaymentRequirement;
+ x402Version: number;
+ };
}
export interface UsdcBalanceResult {
@@ -69,6 +73,25 @@ export interface UsdcBalanceResult {
error?: string;
}
+export interface PaymentContext {
+ url: string;
+ method: string;
+ requirement: PaymentRequirement;
+ x402Version: number;
+ signerAddress: string;
+}
+
+export type PaymentMiddleware = (
+ ctx: PaymentContext,
+) => Promise<{ proceed: true } | { proceed: false; reason: string }>;
+
+export interface X402FetchOptions {
+ method?: string;
+ body?: string;
+ headers?: Record;
+ middleware?: PaymentMiddleware[];
+}
+
function safeJsonParse(value: string): unknown | null {
try {
return JSON.parse(value);
@@ -257,22 +280,35 @@ export async function checkX402(
}
/**
- * Fetch a URL with automatic x402 payment.
- * If the endpoint returns 402, sign and pay, then retry.
+ * Fetch a URL with x402 payment support and middleware pipeline.
+ * If the endpoint returns 402, runs middleware before signing.
+ * Accepts either positional args (backward compat) or an options object.
*/
export async function x402Fetch(
url: string,
account: PrivateKeyAccount,
- method: string = "GET",
+ optionsOrMethod?: X402FetchOptions | string,
body?: string,
headers?: Record,
): Promise {
+ // Backward compat: positional args → options object
+ const options: X402FetchOptions =
+ typeof optionsOrMethod === "object" && optionsOrMethod !== null
+ ? optionsOrMethod
+ : {
+ method: (optionsOrMethod as string) || "GET",
+ body,
+ headers,
+ };
+
+ const method = options.method || "GET";
+
try {
// Initial request
const initialResp = await fetch(url, {
method,
- headers: { ...headers, "Content-Type": "application/json" },
- body,
+ headers: { ...options.headers, "Content-Type": "application/json" },
+ body: options.body,
});
if (initialResp.status !== 402) {
@@ -292,6 +328,34 @@ export async function x402Fetch(
};
}
+ const paymentDetails = {
+ requirement: parsed.requirement,
+ x402Version: parsed.x402Version,
+ };
+
+ // Run middleware pipeline
+ if (options.middleware && options.middleware.length > 0) {
+ const ctx: PaymentContext = {
+ url,
+ method,
+ requirement: parsed.requirement,
+ x402Version: parsed.x402Version,
+ signerAddress: account.address,
+ };
+
+ for (const mw of options.middleware) {
+ const result = await mw(ctx);
+ if (!result.proceed) {
+ return {
+ success: false,
+ error: result.reason,
+ status: 402,
+ paymentDetails,
+ };
+ }
+ }
+ }
+
// Sign payment
let payment: any;
try {
@@ -305,6 +369,7 @@ export async function x402Fetch(
success: false,
error: `Failed to sign payment: ${err?.message || String(err)}`,
status: initialResp.status,
+ paymentDetails,
};
}
@@ -316,15 +381,15 @@ export async function x402Fetch(
const paidResp = await fetch(url, {
method,
headers: {
- ...headers,
+ ...options.headers,
"Content-Type": "application/json",
"X-Payment": paymentHeader,
},
- body,
+ body: options.body,
});
const data = await paidResp.json().catch(() => paidResp.text());
- return { success: paidResp.ok, response: data, status: paidResp.status };
+ return { success: paidResp.ok, response: data, status: paidResp.status, paymentDetails };
} catch (err: any) {
return { success: false, error: err.message };
}
diff --git a/src/types.ts b/src/types.ts
index 5548d7a9..98ffa398 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -226,7 +226,8 @@ export type TransactionType =
| "tool_use"
| "transfer_in"
| "transfer_out"
- | "funding_request";
+ | "funding_request"
+ | "x402_payment";
// ─── Self-Modification ───────────────────────────────────────────