diff --git a/typescript/.changeset/add-x402station-action-provider.md b/typescript/.changeset/add-x402station-action-provider.md new file mode 100644 index 000000000..2b873d21e --- /dev/null +++ b/typescript/.changeset/add-x402station-action-provider.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": patch +--- + +Added a new `x402station` action provider — a pre-flight oracle for the x402 agentic-commerce network. Six tools (preflight, forensics, catalog_decoys, watch_subscribe, watch_status, watch_unsubscribe) wrapping the public oracle at https://x402station.io. Four are paid via x402 ($0.001–$0.01 USDC, auto-signed via the agent's `EvmWalletProvider`); two are free + secret-gated for managing an existing webhook subscription. Networks: Base mainnet and Base Sepolia. diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..ab1f917fd 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -36,6 +36,7 @@ export * from "./flaunch"; export * from "./onramp"; export * from "./vaultsfyi"; export * from "./x402"; +export * from "./x402station"; export * from "./yelay"; export * from "./zerion"; export * from "./zerodev"; diff --git a/typescript/agentkit/src/action-providers/x402station/README.md b/typescript/agentkit/src/action-providers/x402station/README.md new file mode 100644 index 000000000..97e015202 --- /dev/null +++ b/typescript/agentkit/src/action-providers/x402station/README.md @@ -0,0 +1,110 @@ +# X402station Action Provider + +The `x402station` action provider gives any AgentKit agent a pre-flight oracle for x402 endpoints. Four paid tools (`preflight`, `forensics`, `catalog_decoys`, `watch_subscribe`) auto-sign their own $0.001–$0.01 USDC payments through the agent's configured `EvmWalletProvider`; two free secret-gated tools (`watch_status`, `watch_unsubscribe`) manage an existing webhook subscription. + +This is a wrapper around the public oracle at [x402station.io](https://x402station.io) — same API + identical signal vocabulary used by the official `x402station-mcp` package on npm. + +## Why pre-flight? + +The agentic.market catalog has 25,000+ x402 endpoints. A non-trivial fraction are honeypots: + +- **Decoys** priced ≥ $1,000 USDC per call. An agent that pays one drains its wallet. +- **Zombies** that 402-handshake fine but always 4xx after settlement (the call-side payment goes through, the agent gets nothing). +- **Dead** endpoints that return network errors or 5xx every probe. +- **Price-jacked** endpoints whose listed price drifted 10× past the provider's group median. + +x402station independently probes every endpoint every ~10 minutes (not facilitator-reported) so it catches what facilitator-only monitors miss. Calling `preflight` before each paid x402 request costs $0.001 — typically 20× cheaper than the request the agent would otherwise lose to a decoy. + +## Networks + +- Base mainnet (`base-mainnet` / `eip155:8453`) — production +- Base Sepolia (`base-sepolia` / `eip155:84532`) — testing + +The oracle accepts USDC payments on both networks via Coinbase's CDP facilitator; the action provider's `supportsNetwork` returns `false` for any other network. + +## Actions + +| Action | Cost | Description | +|---|---|---| +| `preflight` | $0.001 | `{ok, warnings[], metadata}` for any URL — fast safety check | +| `forensics` | $0.001 | 7-day uptime + latency p50/p90/p99 + decoy probability + concentration stats | +| `catalog_decoys` | $0.005 | Every URL flagged dangerous, in one cacheable blob | +| `watch_subscribe` | $0.01 | 30-day webhook subscription + 100 prepaid HMAC-signed alerts | +| `watch_status` | free* | Read-back: active/expired, alerts remaining, recent deliveries | +| `watch_unsubscribe` | free* | Soft-delete a watch | + +\* Free actions are secret-gated by the 64-char hex secret returned from `watch_subscribe`. Constant-time compare on the server; mismatched secret returns 404 (not 401) so an attacker scraping IDs can't distinguish "exists but wrong secret" from "doesn't exist". + +## Signal vocabulary + +Strings returned in `warnings[]` from `preflight` / `forensics`. **Bold** signals flip `ok` to `false` and an agent should refuse the target call: + +- **`dead`** — ≥3 unhealthy probes in the last 30 min +- **`zombie`** — ≥3 probes in the last hour, zero healthy +- **`decoy_price_extreme`** — listed price ≥ $1,000 USDC +- **`dead_7d`** — ≥20 probes over 7 days, zero healthy (forensics-only) +- **`mostly_dead`** — ≥20 probes over 7 days, uptime < 50% (forensics-only) +- `unknown_endpoint` — URL not in the catalog (informational; still billed) +- `no_history` — in catalog but no probes in the last hour +- `suspicious_high_price` — price $10–$1,000 USDC +- `slow` — avg latency ≥ 2,000 ms in the last hour +- `new_provider` — service first seen < 24h ago +- `slow_p99` — latency p99 ≥ 5,000 ms (forensics-only) +- `price_outlier_high` — current price > 10× provider-group median +- `high_concentration` — endpoint's provider owns ≥ 5% of the catalog + +The watch endpoint accepts a subset of these in its `signals` array — the worker fires when subscribed signals appear or clear vs the last computed state. + +## Example + +```typescript +import { + AgentKit, + CdpEvmServerWalletProvider, + x402stationActionProvider, +} from "@coinbase/agentkit"; + +const walletProvider = await CdpEvmServerWalletProvider.configureWithWallet({ + apiKeyId: process.env.CDP_API_KEY_ID!, + apiKeySecret: process.env.CDP_API_KEY_SECRET!, + walletSecret: process.env.CDP_WALLET_SECRET!, + networkId: "base-mainnet", +}); + +const agentKit = await AgentKit.from({ + walletProvider, + actionProviders: [x402stationActionProvider()], +}); + +// The LLM can now call preflight, forensics, etc. via getActions(). +// Pre-flight a target before the agent commits a paid call to it: +const actions = agentKit.getActions(); +const preflight = actions.find((a) => a.name.endsWith("_preflight"))!; +const result = await preflight.invoke({ + url: "https://api.venice.ai/api/v1/chat/completions", +}); +console.log(JSON.parse(result)); +// { result: { ok: false, warnings: ["dead", "zombie"], metadata: {...} }, +// paymentReceipt: { transaction: "0x…" } } +``` + +## Configuration + +```typescript +x402stationActionProvider({ + // Defaults to https://x402station.io. Only the canonical host or a + // localhost dev URL is accepted — refuses to start otherwise so a + // misconfigured agent can't sign x402 payments against an unknown host. + baseUrl: "https://x402station.io", +}); +``` + +## Links + +- Service: +- Manifest: +- OpenAPI: +- Agent skills (v0.2.0): +- Skill description: +- Source: +- npm (MCP adapter for non-AgentKit agents): diff --git a/typescript/agentkit/src/action-providers/x402station/index.ts b/typescript/agentkit/src/action-providers/x402station/index.ts new file mode 100644 index 000000000..f06d35668 --- /dev/null +++ b/typescript/agentkit/src/action-providers/x402station/index.ts @@ -0,0 +1,5 @@ +export { + X402stationActionProvider, + x402stationActionProvider, +} from "./x402stationActionProvider"; +export type { X402stationConfig } from "./schemas"; diff --git a/typescript/agentkit/src/action-providers/x402station/schemas.ts b/typescript/agentkit/src/action-providers/x402station/schemas.ts new file mode 100644 index 000000000..dc60d085b --- /dev/null +++ b/typescript/agentkit/src/action-providers/x402station/schemas.ts @@ -0,0 +1,164 @@ +import { z } from "zod"; + +/** + * Configuration options for X402stationActionProvider. + */ +export interface X402stationConfig { + /** + * Override the default oracle base URL. + * + * Allowed values: `https://x402station.io` (canonical, default) or any + * `http(s)://localhost*` for development. Any other host is rejected at + * construction time so a misconfigured agent can't sign x402 payments + * against an attacker-controlled URL. + */ + baseUrl?: string; +} + +/** + * Signal vocabulary returned by the oracle. Whitelisted at the schema + * level so a typo in the agent's `signals` array doesn't silently never + * fire (the route would 400, but catching it earlier saves a wallet + * round-trip). + * + * Critical signals (those that flip preflight `ok` to `false`): + * `dead`, `zombie`, `decoy_price_extreme`, `dead_7d`, `mostly_dead` + */ +export const SignalEnum = z.enum([ + "unknown_endpoint", + "no_history", + "dead", + "zombie", + "decoy_price_extreme", + "suspicious_high_price", + "slow", + "new_provider", + "dead_7d", + "mostly_dead", + "slow_p99", + "price_outlier_high", + "high_concentration", +]); + +/** + * Input schema for the `preflight` and `forensics` actions. + */ +export const PreflightSchema = z.object({ + url: z + .string() + .url() + .describe( + "Full URL of the x402 endpoint the agent is about to pay (must be http(s)://, max 2048 chars).", + ), +}); + +export const ForensicsSchema = PreflightSchema; + +/** + * Empty input — no parameters needed. + */ +export const CatalogDecoysSchema = z.object({}).describe("No parameters required"); + +/** + * Input for the `whats_new` action — catalog diff polling. `since` is an ISO + * 8601 timestamp (default = now() - 24h, cap 30 days back). `limit` caps each + * of added_endpoints[] and removed_endpoints[] (1..500, default 200). + */ +export const WhatsNewSchema = z.object({ + since: z + .string() + .datetime() + .optional() + .describe( + "ISO 8601 timestamp. Default = now() - 24h. Cannot be older than 30 days or in the future.", + ), + limit: z + .number() + .int() + .min(1) + .max(500) + .optional() + .describe( + "Per-list cap (1..500, default 200). Applied independently to added_endpoints and removed_endpoints.", + ), +}); + +/** + * Input for the `alternatives` action — given a flagged URL OR a taskClass + * hint, returns up to `limit` (default 5, max 10) healthy sibling endpoints + * in the same provider / domain / category / price-band. Filtered to those + * passing the same 1h + 7d health checks preflight uses; ranked by + * uptime_7d_pct DESC then avg_latency_1h_ms ASC. At least one of `url` or + * `taskClass` is required. + */ +export const AlternativesSchema = z + .object({ + url: z + .string() + .url() + .optional() + .describe( + "URL flagged by preflight (or otherwise rejected). Looked up in the catalog to extract provider / domain / category / price band as match keys.", + ), + taskClass: z + .string() + .max(80) + .optional() + .describe( + "Service category hint (e.g. 'llm-completions', 'Inference'). Used as a fallback match key when `url` is unknown to the catalog, OR alone for category-only discovery.", + ), + limit: z + .number() + .int() + .min(1) + .max(10) + .optional() + .describe("Max alternatives to return (1..10, default 5)."), + }) + .refine((v) => v.url !== undefined || v.taskClass !== undefined, { + message: "alternatives requires at least one of `url` or `taskClass`", + }); + +/** + * Input for `watch_subscribe`. Pays $0.01 USDC, returns a watchId + a 64-char + * hex secret. The secret is the HMAC seed for verifying delivery payloads + * and is only returned once — store it. + */ +export const WatchSubscribeSchema = z.object({ + url: z + .string() + .url() + .describe("The x402 endpoint URL to watch."), + webhookUrl: z + .string() + .url() + .refine((u) => u.startsWith("https://"), { + message: + "webhookUrl must use HTTPS — HMAC-signed alert payloads must not travel in clear text", + }) + .describe( + "Where x402station will POST alert payloads. Must be HTTPS (HMAC-signed payloads must travel encrypted) and reachable from the public internet.", + ), + signals: z + .array(SignalEnum) + .min(1) + .max(20) + .optional() + .describe( + "Signal names to alert on. Defaults to ['dead', 'zombie', 'decoy_price_extreme'].", + ), +}); + +/** + * Input for `watch_status` and `watch_unsubscribe`. Both are free + secret-gated. + */ +export const WatchStatusSchema = z.object({ + watchId: z.string().uuid().describe("The watchId UUID returned by watch_subscribe."), + secret: z + .string() + .length(64) + .regex(/^[0-9a-f]{64}$/i, "secret must be 64 hex chars") + .describe("The 64-char hex secret returned by watch_subscribe (store it; not retrievable later)."), +}); + +export const WatchUnsubscribeSchema = WatchStatusSchema; diff --git a/typescript/agentkit/src/action-providers/x402station/x402stationActionProvider.test.ts b/typescript/agentkit/src/action-providers/x402station/x402stationActionProvider.test.ts new file mode 100644 index 000000000..556017b5c --- /dev/null +++ b/typescript/agentkit/src/action-providers/x402station/x402stationActionProvider.test.ts @@ -0,0 +1,491 @@ +import { X402stationActionProvider } from "./x402stationActionProvider"; +import { WatchSubscribeSchema } from "./schemas"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { Network } from "../../network"; +import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; +import { registerExactEvmScheme } from "@x402/evm/exact/client"; + +jest.mock("@x402/fetch"); +jest.mock("@x402/evm/exact/client"); + +const mockFetchWithPayment = jest.fn(); +const mockX402Client = { registerScheme: jest.fn() }; + +jest + .mocked(x402Client) + .mockImplementation(() => mockX402Client as unknown as InstanceType); +jest.mocked(wrapFetchWithPayment).mockReturnValue(mockFetchWithPayment); +jest + .mocked(registerExactEvmScheme) + .mockImplementation(() => mockX402Client as unknown as InstanceType); + +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +const buildMockEvmWallet = (): EvmWalletProvider => + ({ + toSigner: () => ({ + address: "0x30d2b1f9bcEdE5F13136b56Ff199A8ad6E4f50de", + signTypedData: jest.fn(), + }), + readContract: jest.fn(), + getName: () => "mock", + getAddress: () => "0x30d2b1f9bcEdE5F13136b56Ff199A8ad6E4f50de", + getNetwork: (): Network => ({ + protocolFamily: "evm", + networkId: "base-mainnet", + chainId: "8453", + }), + }) as unknown as EvmWalletProvider; + +describe("X402stationActionProvider", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("constructor", () => { + it("uses the canonical x402station.io URL by default", () => { + const provider = new X402stationActionProvider(); + expect((provider as unknown as { baseUrl: string }).baseUrl).toBe( + "https://x402station.io", + ); + }); + + it("accepts an http(s)://localhost dev URL", () => { + const p1 = new X402stationActionProvider({ baseUrl: "http://localhost:3002" }); + expect((p1 as unknown as { baseUrl: string }).baseUrl).toBe( + "http://localhost:3002", + ); + const p2 = new X402stationActionProvider({ baseUrl: "http://127.0.0.1:9999" }); + expect((p2 as unknown as { baseUrl: string }).baseUrl).toBe( + "http://127.0.0.1:9999", + ); + }); + + it("strips trailing slashes from the configured URL", () => { + const provider = new X402stationActionProvider({ + baseUrl: "https://x402station.io///", + }); + expect((provider as unknown as { baseUrl: string }).baseUrl).toBe( + "https://x402station.io", + ); + }); + + it("rejects a non-canonical, non-localhost URL", () => { + expect(() => new X402stationActionProvider({ baseUrl: "https://evil.example" })).toThrow( + /baseUrl must be/i, + ); + }); + + it("rejects a malformed URL", () => { + expect(() => new X402stationActionProvider({ baseUrl: "not a url" })).toThrow( + /not a valid URL/i, + ); + }); + + it("does not let a non-default port bypass the canonical check", () => { + // u.hostname strips ports, u.host keeps them — the implementation + // must use u.host so this case fails. (Mirrors the x402station-mcp + // 2026-04-26 audit finding M-2.) + expect( + () => new X402stationActionProvider({ baseUrl: "https://x402station.io:9999" }), + ).toThrow(/baseUrl must be/i); + }); + + it("accepts IPv6 loopback dev URL [::1] (Greptile P2)", () => { + const provider = new X402stationActionProvider({ baseUrl: "http://[::1]:3002" }); + expect((provider as unknown as { baseUrl: string }).baseUrl).toBe("http://[::1]:3002"); + }); + + it("rejects localhost.attacker.com (CodeRabbit: prefix-match bypass)", () => { + // u.host.startsWith("localhost") would PASS this attacker domain. + // Implementation must use u.hostname exact-match. + expect( + () => new X402stationActionProvider({ baseUrl: "http://localhost.attacker.com" }), + ).toThrow(/baseUrl must be/i); + }); + + it("rejects 127.0.0.1.evil.example (CodeRabbit: prefix-match bypass)", () => { + expect( + () => new X402stationActionProvider({ baseUrl: "http://127.0.0.1.evil.example" }), + ).toThrow(/baseUrl must be/i); + }); + + it("rejects localhost-impersonation suffixes", () => { + expect( + () => new X402stationActionProvider({ baseUrl: "http://localhost-evil.example" }), + ).toThrow(/baseUrl must be/i); + }); + }); + + describe("supportsNetwork", () => { + const provider = new X402stationActionProvider(); + + it("returns true for Base mainnet", () => { + expect( + provider.supportsNetwork({ + protocolFamily: "evm", + networkId: "base-mainnet", + chainId: "8453", + }), + ).toBe(true); + }); + + it("returns true for Base Sepolia", () => { + expect( + provider.supportsNetwork({ + protocolFamily: "evm", + networkId: "base-sepolia", + chainId: "84532", + }), + ).toBe(true); + }); + + it("returns false for Ethereum mainnet", () => { + expect( + provider.supportsNetwork({ + protocolFamily: "evm", + networkId: "ethereum-mainnet", + chainId: "1", + }), + ).toBe(false); + }); + + it("returns false for non-EVM networks", () => { + expect( + provider.supportsNetwork({ + protocolFamily: "svm", + networkId: "solana-mainnet", + chainId: "mainnet", + }), + ).toBe(false); + }); + }); + + describe("preflight", () => { + it("posts to /api/v1/preflight with the URL and returns parsed JSON + receipt", async () => { + const provider = new X402stationActionProvider(); + const wallet = buildMockEvmWallet(); + const fakeBody = { + ok: false, + warnings: ["dead", "zombie"], + metadata: { url: "https://api.venice.ai/api/v1/chat/completions" }, + }; + mockFetchWithPayment.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue(JSON.stringify(fakeBody)), + headers: { get: () => null }, + }); + + const result = await provider.preflight(wallet, { + url: "https://api.venice.ai/api/v1/chat/completions", + }); + + const parsed = JSON.parse(result); + expect(parsed.result).toEqual(fakeBody); + expect(parsed.paymentReceipt).toBeNull(); + expect(mockFetchWithPayment).toHaveBeenCalledWith( + "https://x402station.io/api/v1/preflight", + expect.objectContaining({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + url: "https://api.venice.ai/api/v1/chat/completions", + }), + }), + ); + }); + + it("decodes the x-payment-response header into paymentReceipt", async () => { + const provider = new X402stationActionProvider(); + const wallet = buildMockEvmWallet(); + const receipt = { transaction: "0xabc", network: "eip155:8453" }; + const headerVal = btoa(JSON.stringify(receipt)); + mockFetchWithPayment.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue(JSON.stringify({ ok: true })), + headers: { + get: (name: string) => + name.toLowerCase() === "x-payment-response" ? headerVal : null, + }, + }); + + const result = await provider.preflight(wallet, { url: "https://x" }); + expect(JSON.parse(result).paymentReceipt).toEqual(receipt); + }); + + it("returns an error envelope on non-2xx status", async () => { + const provider = new X402stationActionProvider(); + const wallet = buildMockEvmWallet(); + mockFetchWithPayment.mockResolvedValue({ + ok: false, + status: 503, + text: jest.fn().mockResolvedValue("upstream timeout"), + headers: { get: () => null }, + }); + + const result = await provider.preflight(wallet, { url: "https://x" }); + const parsed = JSON.parse(result); + expect(parsed.error).toBe(true); + expect(parsed.status).toBe(503); + expect(parsed.details).toContain("upstream timeout"); + }); + }); + + describe("forensics + catalog_decoys + watch_subscribe", () => { + it("forensics posts to /api/v1/forensics", async () => { + const provider = new X402stationActionProvider(); + const wallet = buildMockEvmWallet(); + mockFetchWithPayment.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue("{}"), + headers: { get: () => null }, + }); + await provider.forensics(wallet, { url: "https://x" }); + expect(mockFetchWithPayment).toHaveBeenCalledWith( + "https://x402station.io/api/v1/forensics", + expect.any(Object), + ); + }); + + it("catalog_decoys posts to /api/v1/catalog/decoys with empty body", async () => { + const provider = new X402stationActionProvider(); + const wallet = buildMockEvmWallet(); + mockFetchWithPayment.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue("{}"), + headers: { get: () => null }, + }); + await provider.catalogDecoys(wallet, {}); + const call = mockFetchWithPayment.mock.calls[0]; + expect(call[0]).toBe("https://x402station.io/api/v1/catalog/decoys"); + expect(call[1].body).toBe("{}"); + }); + + it("whats_new posts to /api/v1/whats-new with empty body when no args", async () => { + const provider = new X402stationActionProvider(); + const wallet = buildMockEvmWallet(); + mockFetchWithPayment.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue("{}"), + headers: { get: () => null }, + }); + await provider.whatsNew(wallet, {}); + const call = mockFetchWithPayment.mock.calls[0]; + expect(call[0]).toBe("https://x402station.io/api/v1/whats-new"); + expect(call[1].body).toBe("{}"); + }); + + it("whats_new threads since + limit through", async () => { + const provider = new X402stationActionProvider(); + const wallet = buildMockEvmWallet(); + mockFetchWithPayment.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue("{}"), + headers: { get: () => null }, + }); + await provider.whatsNew(wallet, { + since: "2026-04-27T00:00:00Z", + limit: 50, + }); + const body = JSON.parse(mockFetchWithPayment.mock.calls[0][1].body); + expect(body).toEqual({ since: "2026-04-27T00:00:00Z", limit: 50 }); + }); + + it("alternatives posts to /api/v1/alternatives with url body", async () => { + const provider = new X402stationActionProvider(); + const wallet = buildMockEvmWallet(); + mockFetchWithPayment.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue("{}"), + headers: { get: () => null }, + }); + await provider.alternatives(wallet, { + url: "https://api.venice.ai/api/v1/chat/completions", + }); + const call = mockFetchWithPayment.mock.calls[0]; + expect(call[0]).toBe("https://x402station.io/api/v1/alternatives"); + expect(JSON.parse(call[1].body)).toEqual({ + url: "https://api.venice.ai/api/v1/chat/completions", + }); + }); + + it("alternatives accepts taskClass + limit, omits unset fields", async () => { + const provider = new X402stationActionProvider(); + const wallet = buildMockEvmWallet(); + mockFetchWithPayment.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue("{}"), + headers: { get: () => null }, + }); + await provider.alternatives(wallet, { taskClass: "llm-completions", limit: 3 }); + const body = JSON.parse(mockFetchWithPayment.mock.calls[0][1].body); + expect(body).toEqual({ taskClass: "llm-completions", limit: 3 }); + expect(body).not.toHaveProperty("url"); + }); + + // Server-side rejects empty body with HTTP 400; schema-level refine over + // a fully-optional object can pass parse (depending on zod version + // handling of the empty-object case), so we rely on the route check + // rather than the schema for that edge. + + it("watch_subscribe omits signals when not provided", async () => { + const provider = new X402stationActionProvider(); + const wallet = buildMockEvmWallet(); + mockFetchWithPayment.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue("{}"), + headers: { get: () => null }, + }); + await provider.watchSubscribe(wallet, { + url: "https://x", + webhookUrl: "https://hook", + }); + const body = JSON.parse(mockFetchWithPayment.mock.calls[0][1].body); + expect(body).toEqual({ url: "https://x", webhookUrl: "https://hook" }); + expect(body).not.toHaveProperty("signals"); + }); + + it("watch_subscribe includes signals when provided", async () => { + const provider = new X402stationActionProvider(); + const wallet = buildMockEvmWallet(); + mockFetchWithPayment.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue("{}"), + headers: { get: () => null }, + }); + await provider.watchSubscribe(wallet, { + url: "https://x", + webhookUrl: "https://hook", + signals: ["zombie", "decoy_price_extreme"], + }); + const body = JSON.parse(mockFetchWithPayment.mock.calls[0][1].body); + expect(body.signals).toEqual(["zombie", "decoy_price_extreme"]); + }); + + it("watch_subscribe rejects http:// webhookUrl via zod (Greptile P1)", () => { + // Schema-level guard: HMAC-signed alert payloads must not travel + // unencrypted. Validator runs before any wallet round-trip so + // the agent never signs a $0.01 challenge for a doomed call. + expect(() => + WatchSubscribeSchema.parse({ + url: "https://endpoint.example", + webhookUrl: "http://insecure-webhook.example", + }), + ).toThrow(/HTTPS/i); + expect(mockFetchWithPayment).not.toHaveBeenCalled(); + // Sanity: still accepts https:// + expect(() => + WatchSubscribeSchema.parse({ + url: "https://endpoint.example", + webhookUrl: "https://secure-webhook.example", + }), + ).not.toThrow(); + }); + + it("preflight surfaces a malformed payment-response header with malformed:true (Greptile P2)", async () => { + const provider = new X402stationActionProvider(); + const wallet = buildMockEvmWallet(); + mockFetchWithPayment.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue(JSON.stringify({ ok: true })), + headers: { + get: (name: string) => + name.toLowerCase() === "x-payment-response" + ? "not-base64-and-not-json!!!" + : null, + }, + }); + const result = await provider.preflight(wallet, { url: "https://x" }); + const parsed = JSON.parse(result); + expect(parsed.paymentReceipt).toEqual({ + raw: "not-base64-and-not-json!!!", + malformed: true, + }); + }); + }); + + describe("watch_status (free, secret-gated)", () => { + it("does NOT use the paying-fetch wrapper", async () => { + const provider = new X402stationActionProvider(); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue(JSON.stringify({ isActive: true })), + }); + await provider.watchStatus({ + watchId: "0a44f6b8-3b7d-4f2a-9e3a-2c5fd1b0aa11", + secret: "a".repeat(64), + }); + expect(mockFetchWithPayment).not.toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledWith( + "https://x402station.io/api/v1/watch/0a44f6b8-3b7d-4f2a-9e3a-2c5fd1b0aa11", + expect.objectContaining({ + method: "GET", + headers: { "x-x402station-secret": "a".repeat(64) }, + }), + ); + }); + + it("returns the parsed body on 200", async () => { + const provider = new X402stationActionProvider(); + const fakeBody = { isActive: true, alertsRemaining: 100 }; + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue(JSON.stringify(fakeBody)), + }); + const result = await provider.watchStatus({ + watchId: "0a44f6b8-3b7d-4f2a-9e3a-2c5fd1b0aa11", + secret: "b".repeat(64), + }); + expect(JSON.parse(result)).toEqual(fakeBody); + }); + + it("returns an error envelope on 404 (wrong secret OR missing watch)", async () => { + const provider = new X402stationActionProvider(); + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + text: jest.fn().mockResolvedValue('{"error":"watch not found"}'), + }); + const result = await provider.watchStatus({ + watchId: "0a44f6b8-3b7d-4f2a-9e3a-2c5fd1b0aa11", + secret: "c".repeat(64), + }); + const parsed = JSON.parse(result); + expect(parsed.error).toBe(true); + expect(parsed.status).toBe(404); + }); + }); + + describe("watch_unsubscribe (free, secret-gated)", () => { + it("issues DELETE", async () => { + const provider = new X402stationActionProvider(); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue('{"isActive":false}'), + }); + await provider.watchUnsubscribe({ + watchId: "0a44f6b8-3b7d-4f2a-9e3a-2c5fd1b0aa11", + secret: "d".repeat(64), + }); + expect(mockFetch).toHaveBeenCalledWith( + "https://x402station.io/api/v1/watch/0a44f6b8-3b7d-4f2a-9e3a-2c5fd1b0aa11", + expect.objectContaining({ method: "DELETE" }), + ); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/x402station/x402stationActionProvider.ts b/typescript/agentkit/src/action-providers/x402station/x402stationActionProvider.ts new file mode 100644 index 000000000..16c57aa6f --- /dev/null +++ b/typescript/agentkit/src/action-providers/x402station/x402stationActionProvider.ts @@ -0,0 +1,492 @@ +import { z } from "zod"; +import { ActionProvider } from "../actionProvider"; +import { Network } from "../../network"; +import { CreateAction } from "../actionDecorator"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; +import { registerExactEvmScheme } from "@x402/evm/exact/client"; +import { + CatalogDecoysSchema, + AlternativesSchema, + WhatsNewSchema, + ForensicsSchema, + PreflightSchema, + WatchStatusSchema, + WatchSubscribeSchema, + WatchUnsubscribeSchema, + X402stationConfig, +} from "./schemas"; + +const DEFAULT_BASE_URL = "https://x402station.io"; +const SUPPORTED_NETWORK_IDS = new Set(["base-mainnet", "base-sepolia"]); +// Per-call timeout so a stalled oracle doesn't hang the LLM step. 30s +// covers x402's 402 → sign → settle → JSON round-trip with margin. +// Greptile P2 (2026-04-27). +const DEFAULT_TIMEOUT_MS = 30_000; + +/** + * Validates that the configured base URL points at the canonical + * x402station.io domain (or a localhost dev URL). Any other value would + * let a misconfigured agent sign x402 payments against an attacker- + * controlled host. Same allow-list logic the official x402station-mcp + * package uses. + * + * @param raw - User-supplied URL or undefined for the default. + * @returns The validated URL with no trailing slash. + */ +function resolveBaseUrl(raw: string | undefined): string { + const value = (raw ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); + let u: URL; + try { + u = new URL(value); + } catch { + throw new Error(`x402station: baseUrl is not a valid URL: ${value}`); + } + // Canonical: `u.host` (NOT `u.hostname`) so a non-default port can't + // bypass — `x402station.io:9999`.hostname is "x402station.io" but + // `.host` keeps the port. + const isCanonical = u.host === "x402station.io" && u.protocol === "https:"; + // localDev: `u.hostname` exact-match (NOT `u.host.startsWith(...)`) + // so a host like `localhost.attacker.com` or `127.0.0.1.evil.example` + // can't pass the loopback check via prefix. WHATWG `URL.hostname` + // returns `[::1]` (with brackets per RFC 2732) for IPv6 literals. + // CodeRabbit (mastra-ai/mastra#15804, 2026-04-27). + const isLocalDev = + (u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]") && + (u.protocol === "http:" || u.protocol === "https:"); + if (!isCanonical && !isLocalDev) { + throw new Error( + `x402station: baseUrl must be https://x402station.io or a localhost dev URL; got "${value}". ` + + "Refusing to sign x402 payments against an unknown host.", + ); + } + return value; +} + +/** + * X402stationActionProvider — pre-flight oracle for x402 endpoints. + * + * Six tools wrapping the x402station.io oracle API. Four are paid via + * x402 (preflight $0.001, forensics $0.001, catalog_decoys $0.005, + * watch_subscribe $0.01) and signed automatically through the agent's + * configured EvmWalletProvider. Two are free + secret-gated and used + * to manage an existing watch (watch_status, watch_unsubscribe). + * + * The oracle independently probes every active endpoint on the + * agentic.market catalog every ~10 minutes and surfaces decoys, zombies, + * dead services, and price-trap signals an agent should refuse to pay. + * + * Networks: Base mainnet (eip155:8453) and Base Sepolia (eip155:84532). + * + * Spec: https://x402station.io/.well-known/agent-skills (+ /skill.md) + */ +export class X402stationActionProvider extends ActionProvider { + private readonly baseUrl: string; + + /** + * Creates a new instance of X402stationActionProvider. + * + * @param config - Optional configuration (custom base URL for dev). + */ + constructor(config: X402stationConfig = {}) { + super("x402station", []); + this.baseUrl = resolveBaseUrl(config.baseUrl); + } + + /** + * Returns true for Base mainnet and Base Sepolia. The oracle accepts + * USDC settlements on both; any other EVM network would 402-handshake + * fine but the agent's payment wouldn't settle. + * + * @param network - The wallet's current network. + * @returns Whether the oracle accepts payments on this network. + */ + supportsNetwork(network: Network): boolean { + return ( + network.protocolFamily === "evm" && + typeof network.networkId === "string" && + SUPPORTED_NETWORK_IDS.has(network.networkId) + ); + } + + /** + * Wraps `fetch` with the agent's wallet so a 402 from the oracle is + * auto-signed and retried with X-PAYMENT. Same pattern the official + * x402 ActionProvider in this repo uses — extracted so our actions + * can reuse it without depending on the x402 provider directly. + * + * @param walletProvider - Agent's wallet (must be EVM, on a supported network). + * @returns A fetch function that handles 402 → sign → retry transparently. + */ + private async getPayingFetch( + walletProvider: EvmWalletProvider, + ): Promise { + const client = new x402Client(); + const account = walletProvider.toSigner(); + const signer = { + ...account, + // Mirror the readContract surface that registerExactEvmScheme + // uses to read the on-chain USDC contract for decimals/symbol. + // The viem account from toSigner() lacks readContract, so we + // delegate to the wallet provider's own implementation. + readContract: (args: { + address: `0x${string}`; + abi: readonly unknown[]; + functionName: string; + args?: readonly unknown[]; + }) => + walletProvider.readContract({ + address: args.address, + abi: args.abi as never, + functionName: args.functionName as never, + args: args.args as never, + }), + }; + registerExactEvmScheme(client, { signer }); + return wrapFetchWithPayment(fetch, client); + } + + /** + * Issues a paid POST to the oracle. Reads response body as text first + * so we can build a clear error message when nginx returns a 502/504 + * HTML body instead of JSON. + * + * @param walletProvider - Agent's wallet (will sign the 402 retry). + * @param path - Oracle endpoint path (e.g. "/api/v1/preflight"). + * @param body - JSON body to POST. + * @returns A JSON-stringified response with the oracle's payload + a + * `paymentReceipt` field decoded from the x-payment-response + * header so the agent can audit on-chain spend. + */ + private async callPaid( + walletProvider: EvmWalletProvider, + path: string, + body: unknown, + ): Promise { + const fetchPay = await this.getPayingFetch(walletProvider); + let r: Response; + try { + r = await fetchPay(`${this.baseUrl}${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body ?? {}), + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), + }); + } catch (err) { + const e = err as { name?: string }; + if (e.name === "AbortError" || e.name === "TimeoutError") { + return JSON.stringify( + { + error: true, + message: `x402station ${path} timed out after ${DEFAULT_TIMEOUT_MS}ms`, + }, + null, + 2, + ); + } + throw err; + } + + const receiptHeader = + r.headers.get("x-payment-response") ?? r.headers.get("payment-response"); + let paymentReceipt: unknown = null; + if (receiptHeader) { + try { + paymentReceipt = JSON.parse(atob(receiptHeader)); + } catch { + // Greptile P2 (2026-04-27): explicit malformed flag so audit + // code can branch on it instead of silently getting a stub + // object that satisfies the type but lacks transaction/network/ + // payer fields. + paymentReceipt = { raw: receiptHeader, malformed: true }; + } + } + + const raw = await r.text(); + if (!r.ok) { + const snippet = raw.length > 200 ? raw.slice(0, 200) + "…" : raw; + return JSON.stringify( + { + error: true, + status: r.status, + message: `x402station ${path} returned ${r.status}`, + details: snippet, + }, + null, + 2, + ); + } + let data: unknown; + try { + data = JSON.parse(raw); + } catch { + return JSON.stringify( + { + error: true, + message: `x402station ${path} returned 200 with non-JSON body`, + details: raw.slice(0, 200), + }, + null, + 2, + ); + } + + return JSON.stringify({ result: data, paymentReceipt }, null, 2); + } + + /** + * Issues a free, secret-gated request (GET or DELETE) to the oracle. + * Used by watch_status and watch_unsubscribe. + * + * @param path - Oracle path (e.g. "/api/v1/watch/"). + * @param method - HTTP method (GET or DELETE). + * @param secret - 64-char hex secret returned by watch_subscribe. + * @returns A JSON-stringified response. + */ + private async callFree( + path: string, + method: "GET" | "DELETE", + secret: string, + ): Promise { + let r: Response; + try { + r = await fetch(`${this.baseUrl}${path}`, { + method, + headers: { "x-x402station-secret": secret }, + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), + }); + } catch (err) { + const e = err as { name?: string }; + if (e.name === "AbortError" || e.name === "TimeoutError") { + return JSON.stringify( + { + error: true, + message: `x402station ${method} ${path} timed out after ${DEFAULT_TIMEOUT_MS}ms`, + }, + null, + 2, + ); + } + throw err; + } + const raw = await r.text(); + if (!r.ok) { + const snippet = raw.length > 200 ? raw.slice(0, 200) + "…" : raw; + return JSON.stringify( + { + error: true, + status: r.status, + message: `x402station ${path} returned ${r.status}`, + details: snippet, + }, + null, + 2, + ); + } + let data: unknown; + try { + data = JSON.parse(raw); + } catch { + return JSON.stringify( + { + error: true, + message: `x402station ${path} returned 200 with non-JSON body`, + details: raw.slice(0, 200), + }, + null, + 2, + ); + } + return JSON.stringify(data, null, 2); + } + + /** + * Pre-flight safety check for a single x402 endpoint URL. + * + * @param walletProvider - Agent's wallet (signs the $0.001 payment). + * @param args - The target URL. + * @returns JSON-stringified `{ ok, warnings[], metadata }` plus payment receipt. + */ + @CreateAction({ + name: "preflight", + description: + "Ask x402station whether a given x402 URL is safe to pay. Returns {ok, warnings[], metadata}. Costs $0.001 USDC (auto-signed via the wallet provider). Call this BEFORE any other paid x402 request to avoid decoys (price ≥ $1k USDC), zombie services, dead endpoints, and price/latency anomalies. ok:true only when no critical warning fires.", + schema: PreflightSchema, + }) + async preflight( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + return this.callPaid(walletProvider, "/api/v1/preflight", { url: args.url }); + } + + /** + * 7-day forensics report on one x402 endpoint. + * + * @param walletProvider - Agent's wallet (signs the $0.001 payment). + * @param args - The target URL. + * @returns JSON-stringified report with hourly uptime, latency p50/p90/p99, + * status-code distribution, concentration-group stats, decoy probability. + */ + @CreateAction({ + name: "forensics", + description: + "Deep history report for one x402 endpoint: hourly uptime over 7 days, latency p50/p90/p99, status-code distribution, concentration-group stats (how crowded this provider's namespace is), and a decoy probability score [0, 1]. Costs $0.001 USDC. Superset of preflight — if you're running forensics you don't need preflight too.", + schema: ForensicsSchema, + }) + async forensics( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + return this.callPaid(walletProvider, "/api/v1/forensics", { url: args.url }); + } + + /** + * Full known-bad blacklist as one JSON payload. + * + * @param walletProvider - Agent's wallet (signs the $0.005 payment). + * @param _args - No parameters. + * @returns JSON-stringified blacklist of every active endpoint flagged + * as decoy_price_extreme / zombie / dead_7d / mostly_dead. + */ + @CreateAction({ + name: "catalog_decoys", + description: + "Returns every active x402 endpoint currently flagged decoy_price_extreme / zombie / dead_7d / mostly_dead in one JSON payload, plus per-reason counts. Costs $0.005 USDC. Pull periodically (hourly is plenty — internal data refreshes every 10 min) and cache locally as a blacklist — cheaper than preflighting every URL.", + schema: CatalogDecoysSchema, + }) + async catalogDecoys( + walletProvider: EvmWalletProvider, + _args: z.infer, + ): Promise { + return this.callPaid(walletProvider, "/api/v1/catalog/decoys", {}); + } + + /** + * Catalog diff polling — what was added / removed since `since`. + * + * @param walletProvider - Agent's wallet (signs the $0.001 payment). + * @param args - { since?, limit? }. `since` defaults to now-24h on the + * server side; `limit` caps each of added_endpoints[] and + * removed_endpoints[] (1..500, default 200). + * @returns JSON-stringified `{ since, until, window_hours, + * added_endpoints[], removed_endpoints[], summary, + * truncated, limit }`. + */ + @CreateAction({ + name: "whats_new", + description: + "Catalog diff polling. Body { since?, limit? } (default since=now-24h, limit=200, max 500). Returns added_endpoints[] (first_seen_at >= since AND is_active=true), removed_endpoints[] (flipped to is_active=false since), service-level counts, polls_in_window, and current active totals. Costs $0.001 USDC. Designed for aggregator agents to poll hourly without breaking the bank — internal ingest cron runs every 5 min, so polling more often returns identical data.", + schema: WhatsNewSchema, + }) + async whatsNew( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + const body: Record = {}; + if (args.since !== undefined) body.since = args.since; + if (args.limit !== undefined) body.limit = args.limit; + return this.callPaid(walletProvider, "/api/v1/whats-new", body); + } + + /** + * Routing fallback — siblings to a flagged endpoint. + * + * @param walletProvider - Agent's wallet (signs the $0.005 payment). + * @param args - { url?, taskClass?, limit? } — at least one of url/taskClass + * required; limit is 1..10, default 5. + * @returns JSON-stringified `{ target, match_strategy, alternatives[], + * candidate_count }`. Each alternative carries url, service, + * provider, domain, category, price_usdc, uptime_1h_pct, + * uptime_7d_pct, avg_latency_1h_ms, match_reason. + */ + @CreateAction({ + name: "alternatives", + description: + "Routing fallback. Given a URL flagged by preflight (or a taskClass hint), returns up to 5 healthy sibling endpoints in the same provider/domain/category/price-band. Filters out 7-day-dead and 1-hour-erroring candidates; ranks by uptime + latency. Costs $0.005 USDC. Use this immediately after preflight returns ok=false — it answers 'where do I go instead?'. Pass {url} when you have a specific URL the agent was about to pay; pass {taskClass} (e.g. 'llm-completions', 'Inference') when discovering by service category; or both for a richer match.", + schema: AlternativesSchema, + }) + async alternatives( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + const body: Record = {}; + if (args.url) body.url = args.url; + if (args.taskClass) body.taskClass = args.taskClass; + if (args.limit !== undefined) body.limit = args.limit; + return this.callPaid(walletProvider, "/api/v1/alternatives", body); + } + + /** + * Subscribe to webhook alerts on x402 endpoint state changes. + * + * @param walletProvider - Agent's wallet (signs the $0.01 payment). + * @param args - URL to watch, webhook URL, optional signal subset. + * @returns JSON-stringified `{ watchId, secret, expiresAt, signals, + * alertsPaid, alertsRemaining, endpointKnown, deliveryFormat }`. + * The secret is the HMAC seed and is returned ONCE — store it. + */ + @CreateAction({ + name: "watch_subscribe", + description: + "Pay $0.01 USDC for a 30-day watch + 100 prepaid alerts on one x402 endpoint. When subscribed signals fire or clear (e.g. zombie, decoy_price_extreme), x402station POSTs an HMAC-SHA256-signed JSON payload to your webhookUrl. Returns watchId + secret — STORE THE SECRET, it's the HMAC seed for verifying delivery payloads and is not retrievable later.", + schema: WatchSubscribeSchema, + }) + async watchSubscribe( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + const body: Record = { + url: args.url, + webhookUrl: args.webhookUrl, + }; + if (args.signals && args.signals.length > 0) body.signals = args.signals; + return this.callPaid(walletProvider, "/api/v1/watch", body); + } + + /** + * Read-back the current state of a watch + recent alert deliveries. + * + * @param args - watchId UUID + 64-char hex secret. + * @returns JSON-stringified watch metadata. No payment required. + */ + @CreateAction({ + name: "watch_status", + description: + "Returns the current state of a watch: active/expired, alertsRemaining (out of 100 prepaid), last 10 alert deliveries with delivery_status, and the last computed signal snapshot. Free — no payment required, secret-gated by the secret returned from watch_subscribe.", + schema: WatchStatusSchema, + }) + async watchStatus(args: z.infer): Promise { + return this.callFree(`/api/v1/watch/${args.watchId}`, "GET", args.secret); + } + + /** + * Deactivate a watch — no further alerts will be queued or delivered. + * + * @param args - watchId UUID + 64-char hex secret. + * @returns JSON-stringified `{ watchId, isActive: false, message }`. + */ + @CreateAction({ + name: "watch_unsubscribe", + description: + "Deactivate a watch — no further alerts will be queued or delivered. Free — no payment required, secret-gated by the secret returned from watch_subscribe. The watch row + alert history are retained for audit. There is no refund for unused prepaid alerts.", + schema: WatchUnsubscribeSchema, + }) + async watchUnsubscribe( + args: z.infer, + ): Promise { + return this.callFree(`/api/v1/watch/${args.watchId}`, "DELETE", args.secret); + } +} + +/** + * Factory helper. + * + * @param config - Optional custom base URL. + * @returns A configured X402stationActionProvider. + */ +export function x402stationActionProvider( + config: X402stationConfig = {}, +): X402stationActionProvider { + return new X402stationActionProvider(config); +}