From def5fbdff98133186be11fd8244ccee43a026410 Mon Sep 17 00:00:00 2001 From: SpendPreflight Date: Fri, 9 Oct 2026 00:19:23 -0700 Subject: [PATCH] Add read-only SpendPreflight screening plugin --- .../.changeset/spendpreflight-screening.md | 5 ++ .../packages/plugins/spendpreflight/README.md | 30 ++++++++ .../plugins/spendpreflight/package.json | 17 +++++ .../plugins/spendpreflight/src/api.ts | 61 +++++++++++++++++ .../plugins/spendpreflight/src/index.ts | 3 + .../plugins/spendpreflight/src/parameters.ts | 45 ++++++++++++ .../src/spendpreflight.plugin.ts | 38 +++++++++++ .../spendpreflight/src/spendpreflight.test.ts | 68 +++++++++++++++++++ .../plugins/spendpreflight/tsconfig.json | 6 ++ .../plugins/spendpreflight/tsup.config.ts | 3 + typescript/pnpm-lock.yaml | 9 +++ 11 files changed, 285 insertions(+) create mode 100644 typescript/.changeset/spendpreflight-screening.md create mode 100644 typescript/packages/plugins/spendpreflight/README.md create mode 100644 typescript/packages/plugins/spendpreflight/package.json create mode 100644 typescript/packages/plugins/spendpreflight/src/api.ts create mode 100644 typescript/packages/plugins/spendpreflight/src/index.ts create mode 100644 typescript/packages/plugins/spendpreflight/src/parameters.ts create mode 100644 typescript/packages/plugins/spendpreflight/src/spendpreflight.plugin.ts create mode 100644 typescript/packages/plugins/spendpreflight/src/spendpreflight.test.ts create mode 100644 typescript/packages/plugins/spendpreflight/tsconfig.json create mode 100644 typescript/packages/plugins/spendpreflight/tsup.config.ts diff --git a/typescript/.changeset/spendpreflight-screening.md b/typescript/.changeset/spendpreflight-screening.md new file mode 100644 index 000000000..ccc49b614 --- /dev/null +++ b/typescript/.changeset/spendpreflight-screening.md @@ -0,0 +1,5 @@ +--- +"@goat-sdk/plugin-spendpreflight": minor +--- + +Add an operator-maintained SpendPreflight plugin with payee screening and payment preflight tools, explicit screening fees, a free-trial mode and an injected x402-paying fetch. The plugin never uses wallet signing or pays the merchant under review. diff --git a/typescript/packages/plugins/spendpreflight/README.md b/typescript/packages/plugins/spendpreflight/README.md new file mode 100644 index 000000000..4358a4289 --- /dev/null +++ b/typescript/packages/plugins/spendpreflight/README.md @@ -0,0 +1,30 @@ +# SpendPreflight plugin for GOAT + +Maintained by the operator of SpendPreflight. This plugin exposes two read-only screening tools before an agent pays a merchant: + +| Tool | Screening fee | Result | +|---|---|---| +| `spendpreflight_check_payee` | $0.01 | OFAC SDN wallet/name screening and domain age/DNS signals | +| `spendpreflight_preflight_payment` | $0.02 | `allow`, `hold` or `block`, reasons and a receipt for an x402 challenge or cart | + +The API takes USDC on Base over x402. Callers need no account or API key. The shared free trial is three calls per IP per UTC day. After the quota, a 402 requires an explicitly supplied x402-paying fetch; the plugin never automatically switches from trial to payment. + +```ts +import { spendpreflight } from "@goat-sdk/plugin-spendpreflight"; + +// Add to your GOAT adapter's plugins array. Default mode uses the free trial. +const plugin = spendpreflight(); + +// For paid screening, inject the paying fetch you already configured with x402. +// const plugin = spendpreflight({ fetch: payingFetch, trial: false }); +``` + +With a GOAT wallet already configured, `plugin.getTools(wallet)` returns both tools. The wallet is unused: no key is read, no wallet method is called, and the merchant payment under review is never signed or settled. An injected paying fetch may settle the separate screening fee only to `https://api.spendpreflight.com`. + +Enforce the result in your actual payment path: pay only on `allow`; ask a human on `hold`; stop on `block` or a tool error. Adding tools to a model does not intercept the model's other wallet tools. HTTP errors, exhausted trial, malformed decisions and timeouts throw; no error becomes `allow`. + +Inputs and raw responses are not logged by this plugin. See [privacy](https://api.spendpreflight.com/privacy), [terms](https://api.spendpreflight.com/terms), [status](https://api.spendpreflight.com/status) and [OpenAPI](https://api.spendpreflight.com/openapi.json). Screening is informational, not legal advice or a compliance certification. + +Tests execute the real GOAT tool interface against mocked screening responses, verify input/response handling and prove wallet signing is unused. They make no real screening payments, merchant calls or LLM calls. Run from `typescript/`: `pnpm --filter @goat-sdk/plugin-spendpreflight test`, `typecheck`, and `build`. + +This contribution is prepared for review; the package has not been published by GOAT. diff --git a/typescript/packages/plugins/spendpreflight/package.json b/typescript/packages/plugins/spendpreflight/package.json new file mode 100644 index 000000000..747ae350a --- /dev/null +++ b/typescript/packages/plugins/spendpreflight/package.json @@ -0,0 +1,17 @@ +{ + "name": "@goat-sdk/plugin-spendpreflight", + "version": "0.1.0", + "files": ["dist/**/*", "README.md", "package.json"], + "scripts": { "build": "tsup", "test": "vitest run", "typecheck": "tsc --noEmit" }, + "sideEffects": false, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "dependencies": { "@goat-sdk/core": "workspace:*", "zod": "catalog:" }, + "peerDependencies": { "@goat-sdk/core": "workspace:*" }, + "homepage": "https://spendpreflight.com", + "repository": { "type": "git", "url": "git+https://github.com/goat-sdk/goat.git" }, + "license": "MIT", + "bugs": { "url": "https://github.com/goat-sdk/goat/issues" }, + "keywords": ["ai", "agents", "x402", "sanctions", "spend-control"] +} diff --git a/typescript/packages/plugins/spendpreflight/src/api.ts b/typescript/packages/plugins/spendpreflight/src/api.ts new file mode 100644 index 000000000..102b0a4a5 --- /dev/null +++ b/typescript/packages/plugins/spendpreflight/src/api.ts @@ -0,0 +1,61 @@ +import { type z } from "zod"; +import { checkParameters, checkResult, preflightParameters, preflightResult } from "./parameters"; + +export interface SpendPreflightOptions { + /** Inject an existing x402-paying fetch. This plugin never accesses wallet keys. */ + fetch?: typeof fetch; + /** Use the shared three-per-IP daily free trial. Defaults to true without an injected fetch. */ + trial?: boolean; + timeoutMs?: number; +} + +export class SpendPreflightApi { + private readonly request: typeof fetch; + private readonly trial: boolean; + private readonly timeoutMs: number; + constructor(options: SpendPreflightOptions = {}) { + this.trial = options.trial ?? !options.fetch; + if (!this.trial && !options.fetch) throw new Error("Paid screening requires an injected x402-paying fetch"); + this.request = options.fetch ?? fetch; + this.timeoutMs = options.timeoutMs ?? 8000; + if (!Number.isInteger(this.timeoutMs) || this.timeoutMs < 1 || this.timeoutMs > 60000) + throw new Error("timeoutMs must be between 1 and 60000"); + } + + private async call(url: URL, init: RequestInit = {}): Promise { + if (this.trial) url.searchParams.set("trial", "1"); + const response = await this.request(url.toString(), { + ...init, + redirect: "error", + signal: AbortSignal.timeout(this.timeoutMs), + }); + if (response.status === 402) + throw new Error( + "SpendPreflight requires payment: check $0.01 or preflight $0.02 in Base USDC. Inject your x402-paying fetch or wait for the daily trial reset. No merchant payment was attempted.", + ); + if (!response.ok) + throw new Error(`SpendPreflight unavailable (HTTP ${response.status}); do not proceed with payment`); + return response.json(); + } + + async checkPayee(input: z.infer) { + const parameters = checkParameters.parse(input); + const url = new URL("https://api.spendpreflight.com/v1/check"); + for (const [key, value] of Object.entries(parameters)) if (value) url.searchParams.set(key, value); + return checkResult.parse(await this.call(url)); + } + + async preflightPayment(input: z.infer) { + const { spent_today_usd, ...parameters } = preflightParameters.parse(input); + const body = JSON.stringify({ ...parameters, context: { spent_today_usd } }); + if (new TextEncoder().encode(body).byteLength > 65536) + throw new Error("Preflight input exceeds 64 KB; not sent"); + return preflightResult.parse( + await this.call(new URL("https://api.spendpreflight.com/v1/preflight"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }), + ); + } +} diff --git a/typescript/packages/plugins/spendpreflight/src/index.ts b/typescript/packages/plugins/spendpreflight/src/index.ts new file mode 100644 index 000000000..987edb292 --- /dev/null +++ b/typescript/packages/plugins/spendpreflight/src/index.ts @@ -0,0 +1,3 @@ +export * from "./spendpreflight.plugin"; +export * from "./api"; +export * from "./parameters"; diff --git a/typescript/packages/plugins/spendpreflight/src/parameters.ts b/typescript/packages/plugins/spendpreflight/src/parameters.ts new file mode 100644 index 000000000..9b471107b --- /dev/null +++ b/typescript/packages/plugins/spendpreflight/src/parameters.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; + +export const checkParameters = z + .object({ + domain: z.string().max(2048).optional().describe("Merchant domain or URL"), + address: z.string().max(128).optional().describe("Payee wallet address"), + name: z.string().max(256).optional().describe("Merchant legal name"), + }) + .refine((p) => Boolean(p.domain || p.address || p.name), "Provide domain, address or name"); + +export const preflightParameters = z + .object({ + challenge: z + .record(z.unknown()) + .optional() + .describe("The x402 PaymentRequired body received from the merchant"), + cart: z + .object({ + merchant_domain: z.string().max(2048).optional(), + total_usd: z.number().nonnegative().optional(), + pay_to: z.string().max(128).optional(), + merchant_name: z.string().max(256).optional(), + }) + .optional(), + resource_url: z.string().url().max(2048).optional(), + rules: z.record(z.unknown()).optional().describe("Optional SpendPreflight spending rules"), + spent_today_usd: z.number().nonnegative().optional(), + }) + .refine((p) => Boolean(p.challenge || p.cart), "Provide an x402 challenge or a cart"); + +export const checkResult = z + .object({ + risk: z.enum(["low", "medium", "high", "unknown"]), + flags: z.array(z.string()), + data_as_of: z.string(), + disclaimer: z.string(), + }) + .passthrough(); +export const preflightResult = z + .object({ + decision: z.enum(["allow", "hold", "block"]), + reasons: z.array(z.string()), + receipt: z.object({ id: z.string().min(1) }).passthrough(), + }) + .passthrough(); diff --git a/typescript/packages/plugins/spendpreflight/src/spendpreflight.plugin.ts b/typescript/packages/plugins/spendpreflight/src/spendpreflight.plugin.ts new file mode 100644 index 000000000..411cb641e --- /dev/null +++ b/typescript/packages/plugins/spendpreflight/src/spendpreflight.plugin.ts @@ -0,0 +1,38 @@ +import { type Chain, PluginBase, type WalletClientBase, createTool } from "@goat-sdk/core"; +import { SpendPreflightApi, type SpendPreflightOptions } from "./api"; +import { checkParameters, preflightParameters } from "./parameters"; + +export class SpendPreflightPlugin extends PluginBase { + private readonly api: SpendPreflightApi; + constructor(options: SpendPreflightOptions = {}) { + super("spendpreflight", []); + this.api = new SpendPreflightApi(options); + } + supportsChain(_chain: Chain): boolean { + return true; + } + getTools(_walletClient: WalletClientBase) { + return [ + createTool( + { + name: "spendpreflight_check_payee", + description: + "Before paying an unfamiliar payee, screen their wallet/name against OFAC SDN and inspect merchant domain age/DNS. Operated by SpendPreflight. $0.01 per screening in Base USDC, shared free trial available. Read-only; never pays the merchant. Informational, not a compliance certification. Use preflight_payment for an allow/hold/block spending decision.", + parameters: checkParameters, + }, + (parameters) => this.api.checkPayee(parameters), + ), + createTool( + { + name: "spendpreflight_preflight_payment", + description: + "Before paying an x402 challenge or cart, obtain allow/hold/block with reasons and a receipt. Operated by SpendPreflight. $0.02 per screening in Base USDC, shared free trial available. This tool never pays the merchant or enforces your wallet: pay only on allow; hold asks a human; block or tool failure means do not pay.", + parameters: preflightParameters, + }, + (parameters) => this.api.preflightPayment(parameters), + ), + ]; + } +} + +export const spendpreflight = (options: SpendPreflightOptions = {}) => new SpendPreflightPlugin(options); diff --git a/typescript/packages/plugins/spendpreflight/src/spendpreflight.test.ts b/typescript/packages/plugins/spendpreflight/src/spendpreflight.test.ts new file mode 100644 index 000000000..fb3b395e8 --- /dev/null +++ b/typescript/packages/plugins/spendpreflight/src/spendpreflight.test.ts @@ -0,0 +1,68 @@ +import { type WalletClientBase } from "@goat-sdk/core"; +import { describe, expect, it, vi } from "vitest"; +import { SpendPreflightApi } from "./api"; +import { spendpreflight } from "./spendpreflight.plugin"; + +const wallet = { + signMessage: vi.fn(() => { + throw new Error("Wallet signing must not be used"); + }), +} as unknown as WalletClientBase; +const check = { risk: "low", flags: [], data_as_of: "2026-10-09T00:00:00Z", disclaimer: "Informational" }; +describe("SpendPreflight GOAT plugin", () => { + it("registers two real GOAT tools without wallet access", () => { + const plugin = spendpreflight(); + const tools = plugin.getTools(wallet); + expect(tools.map((t) => t.name)).toEqual(["spendpreflight_check_payee", "spendpreflight_preflight_payment"]); + expect(tools.every((t) => t.description.includes("Operated by SpendPreflight"))).toBe(true); + expect(wallet.signMessage).not.toHaveBeenCalled(); + }); + it("encodes check inputs and validates the result through the GOAT execution interface", async () => { + const request = vi.fn(async () => Response.json(check)); + const tools = spendpreflight({ fetch: request, trial: true }).getTools(wallet); + expect(await tools[0].execute({ name: "Acme & Sons" })).toMatchObject({ risk: "low" }); + const url = new URL(String(request.mock.calls[0][0])); + expect(url.hostname).toBe("api.spendpreflight.com"); + expect(url.searchParams.get("name")).toBe("Acme & Sons"); + expect(url.searchParams.get("trial")).toBe("1"); + }); + it("returns hold/block unchanged and sends spending rules/context only to the screening API", async () => { + const request = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)); + expect(body.context.spent_today_usd).toBe(0.1); + expect(body.rules.max_per_payment_usd).toBe(0.5); + return Response.json({ decision: "hold", reasons: ["hold: test policy"], receipt: { id: "fixture" } }); + }); + const tools = spendpreflight({ fetch: request }).getTools(wallet); + const result = await tools[1].execute({ + challenge: { x402Version: 2, accepts: [] }, + rules: { max_per_payment_usd: 0.5 }, + spent_today_usd: 0.1, + }); + expect(result).toMatchObject({ decision: "hold" }); + expect(String(request.mock.calls[0][0])).toBe("https://api.spendpreflight.com/v1/preflight"); + expect(wallet.signMessage).not.toHaveBeenCalled(); + }); + it("rejects bad input before network I/O", async () => { + const request = vi.fn(async () => Response.json(check)); + const api = new SpendPreflightApi({ fetch: request }); + await expect(api.checkPayee({})).rejects.toThrow(); + await expect(api.preflightPayment({})).rejects.toThrow(); + await expect(api.preflightPayment({ challenge: { large: "x".repeat(65536) } })).rejects.toThrow(/64 KB/); + expect(request).not.toHaveBeenCalled(); + }); + it("fails without retries on payment-required or unavailable responses", async () => { + for (const status of [402, 503]) { + const request = vi.fn(async () => new Response("", { status })); + await expect(new SpendPreflightApi({ fetch: request }).checkPayee({ name: "Acme" })).rejects.toThrow( + /SpendPreflight/, + ); + expect(request).toHaveBeenCalledTimes(1); + } + }); + it("rejects malformed decisions and refuses implicit paid mode", async () => { + const api = new SpendPreflightApi({ fetch: async () => Response.json({ decision: "sure" }) }); + await expect(api.preflightPayment({ cart: { total_usd: 0.01 } })).rejects.toThrow(); + expect(() => new SpendPreflightApi({ trial: false })).toThrow(/injected/); + }); +}); diff --git a/typescript/packages/plugins/spendpreflight/tsconfig.json b/typescript/packages/plugins/spendpreflight/tsconfig.json new file mode 100644 index 000000000..8abfc49b5 --- /dev/null +++ b/typescript/packages/plugins/spendpreflight/tsconfig.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../../tsconfig.base.json", + "include": ["src"], + "compilerOptions": { "outDir": "dist", "rootDir": "src", "module": "ESNext", "moduleResolution": "Bundler" } +} diff --git a/typescript/packages/plugins/spendpreflight/tsup.config.ts b/typescript/packages/plugins/spendpreflight/tsup.config.ts new file mode 100644 index 000000000..d10fc5cc7 --- /dev/null +++ b/typescript/packages/plugins/spendpreflight/tsup.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from "tsup"; +import { treeShakableConfig } from "../../../tsup.config.base"; +export default defineConfig({ ...treeShakableConfig }); diff --git a/typescript/pnpm-lock.yaml b/typescript/pnpm-lock.yaml index 92c84d75a..058f7897e 100644 --- a/typescript/pnpm-lock.yaml +++ b/typescript/pnpm-lock.yaml @@ -2184,6 +2184,15 @@ importers: specifier: 'catalog:' version: 3.23.8 + packages/plugins/spendpreflight: + dependencies: + '@goat-sdk/core': + specifier: workspace:* + version: link:../../core + zod: + specifier: 'catalog:' + version: 3.23.8 + packages/plugins/sns: dependencies: '@bonfida/spl-name-service':