From fea6d63f001284c71740de624f718041a05bb8d5 Mon Sep 17 00:00:00 2001 From: twzrd-sol Date: Thu, 30 Jul 2026 02:01:03 +0000 Subject: [PATCH] feat(policy): counterparty allow/deny by payTo (refuse WHO, not just HOW MUCH) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spend limits answer "how much may I pay". There is currently no way to answer "whom may I pay at all" — so an operator who knows a specific payTo is unsafe has no way to express that, short of not using the proxy. This adds `allowPayTo` / `denyPayTo` in exactly the existing spend-limit shape: x402-proxy config set denyPayTo 34w53Ukhf...,0xabc... x402-proxy config set allowPayTo BJGdsDXJ... # empty string clears Implemented as a PaymentPolicy via the existing `client.registerPolicy`, so it is pre-sign like the spend-limit policies: when no acceptable option remains the policy throws, the client never builds a payment payload, and nothing is signed. Details worth reviewing: - `deny` wins over `allow` when an address is in both — a denial is the safer reading of contradictory config. - EVM `0x` addresses match case-insensitively (hex checksumming varies); Solana base58 matches case-sensitively, because lower-casing a base58 key yields a different key. Both are covered by tests. - An empty requirements list passes through rather than throwing, so this policy never converts "nothing offered" into "counterparty refused". - The throw names every refused address plus the `config set` command, so the failure is actionable rather than opaque. - No new dependencies. Zero behaviour change when neither list is configured. Wired through the three call sites that build a client from config (fetch, mcp, serve) so the setting applies wherever payments happen, not just one command. Tests: 10 new cases in src/lib/counterparty-policy.test.ts. Package suite goes 114 -> 124 passing (9 -> 10 files); `pnpm type-check` clean; `biome check` clean. --- packages/x402-proxy/src/commands/config.ts | 25 +++++++ packages/x402-proxy/src/commands/fetch.ts | 2 + packages/x402-proxy/src/commands/mcp.ts | 2 + packages/x402-proxy/src/commands/serve.ts | 2 + packages/x402-proxy/src/lib/config.ts | 4 ++ .../src/lib/counterparty-policy.test.ts | 71 +++++++++++++++++++ packages/x402-proxy/src/lib/resolve-wallet.ts | 63 ++++++++++++++++ 7 files changed, 169 insertions(+) create mode 100644 packages/x402-proxy/src/lib/counterparty-policy.test.ts diff --git a/packages/x402-proxy/src/commands/config.ts b/packages/x402-proxy/src/commands/config.ts index 2b98da7..fa7f087 100644 --- a/packages/x402-proxy/src/commands/config.ts +++ b/packages/x402-proxy/src/commands/config.ts @@ -41,6 +41,14 @@ const VALID_KEYS: Record< return n; }, }, + allowPayTo: { + description: "Comma-separated payTo addresses this wallet may pay (empty = any)", + parse: (v) => parseAddressList(v), + }, + denyPayTo: { + description: "Comma-separated payTo addresses this wallet must never pay", + parse: (v) => parseAddressList(v), + }, spendLimitPerTx: { description: "Per-transaction spending limit in USDC", parse: (v) => { @@ -51,6 +59,23 @@ const VALID_KEYS: Record< }, }; +/** + * Parse a comma-separated counterparty list. Empty string clears the list, + * which is how an operator removes a restriction without editing YAML. + */ +function parseAddressList(v: string): string[] { + const items = v + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + for (const a of items) { + if (!(a.startsWith("0x") ? /^0x[0-9a-fA-F]{40}$/ : /^[1-9A-HJ-NP-Za-km-z]{32,44}$/).test(a)) { + throw new Error(`Not a valid EVM or Solana address: ${a}`); + } + } + return items; +} + function isConfigKey(k: string): k is keyof ProxyConfig { return k in VALID_KEYS; } diff --git a/packages/x402-proxy/src/commands/fetch.ts b/packages/x402-proxy/src/commands/fetch.ts index 39a0026..f024452 100644 --- a/packages/x402-proxy/src/commands/fetch.ts +++ b/packages/x402-proxy/src/commands/fetch.ts @@ -373,6 +373,8 @@ Examples: preferredNetwork, network: flags.network, spendLimitDaily: config?.spendLimitDaily, + allowPayTo: config?.allowPayTo, + denyPayTo: config?.denyPayTo, spendLimitPerTx: config?.spendLimitPerTx, }); const handler = createX402ProxyHandler({ client }); diff --git a/packages/x402-proxy/src/commands/mcp.ts b/packages/x402-proxy/src/commands/mcp.ts index 7ba9c9c..3b2bb1c 100644 --- a/packages/x402-proxy/src/commands/mcp.ts +++ b/packages/x402-proxy/src/commands/mcp.ts @@ -302,6 +302,8 @@ Wallet is auto-generated on first run. No env vars needed.`, preferredNetwork, network: flags.network, spendLimitDaily: config?.spendLimitDaily, + allowPayTo: config?.allowPayTo, + denyPayTo: config?.denyPayTo, spendLimitPerTx: config?.spendLimitPerTx, }); diff --git a/packages/x402-proxy/src/commands/serve.ts b/packages/x402-proxy/src/commands/serve.ts index 455df39..9ab79ae 100644 --- a/packages/x402-proxy/src/commands/serve.ts +++ b/packages/x402-proxy/src/commands/serve.ts @@ -131,6 +131,8 @@ export async function startServeServer( preferredNetwork: preferredNetwork || undefined, network: options.network, spendLimitDaily: config?.spendLimitDaily, + allowPayTo: config?.allowPayTo, + denyPayTo: config?.denyPayTo, spendLimitPerTx: config?.spendLimitPerTx, }); const x402Proxy = createX402ProxyHandler({ client: x402Client }); diff --git a/packages/x402-proxy/src/lib/config.ts b/packages/x402-proxy/src/lib/config.ts index e6cf6c7..782ce50 100644 --- a/packages/x402-proxy/src/lib/config.ts +++ b/packages/x402-proxy/src/lib/config.ts @@ -10,6 +10,10 @@ export type ProxyConfig = { mppSessionBudget?: string; spendLimitDaily?: number; spendLimitPerTx?: number; + /** Only pay these payTo addresses (empty/absent = no allow-list). */ + allowPayTo?: string[]; + /** Never pay these payTo addresses. Wins over allowPayTo. */ + denyPayTo?: string[]; }; export type WalletFile = { diff --git a/packages/x402-proxy/src/lib/counterparty-policy.test.ts b/packages/x402-proxy/src/lib/counterparty-policy.test.ts new file mode 100644 index 0000000..99c1fd8 --- /dev/null +++ b/packages/x402-proxy/src/lib/counterparty-policy.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { createCounterpartyPolicy } from "./resolve-wallet.js"; + +const SOL = "BJGdsDXJFy63eCAnX3UmGfShp8BuqbtkTfcamyRGr7VQ"; +const SOL2 = "34w53UkhfBBmVLBcphD1M4dRhbBTQaLLbnRfyTeXzc5m"; +const EVM = "0x6B386D954052D4d5dCB7F066624DAF11d6Ed191a"; + +// Minimal shape the policy touches. The real type carries more fields. +function req(payTo: string, network = "solana:mainnet") { + return { payTo, network, amount: "50000", asset: "USDC" } as never; +} + +describe("createCounterpartyPolicy", () => { + it("is a no-op when no lists are configured", () => { + const policy = createCounterpartyPolicy({}); + const reqs = [req(SOL), req(SOL2)]; + expect(policy(1, reqs)).toHaveLength(2); + }); + + it("drops a denied counterparty but keeps the rest", () => { + const policy = createCounterpartyPolicy({ deny: [SOL2] }); + const kept = policy(1, [req(SOL), req(SOL2)]); + expect(kept).toHaveLength(1); + expect((kept[0] as { payTo: string }).payTo).toBe(SOL); + }); + + it("throws when every option is denied (nothing gets signed)", () => { + const policy = createCounterpartyPolicy({ deny: [SOL] }); + expect(() => policy(1, [req(SOL)])).toThrow(/No acceptable counterparty/); + }); + + it("names the refused address so the operator can act", () => { + const policy = createCounterpartyPolicy({ deny: [SOL] }); + expect(() => policy(1, [req(SOL)])).toThrow(new RegExp(SOL)); + }); + + it("allow list restricts to exactly its members", () => { + const policy = createCounterpartyPolicy({ allow: [SOL] }); + const kept = policy(1, [req(SOL), req(SOL2)]); + expect(kept).toHaveLength(1); + expect((kept[0] as { payTo: string }).payTo).toBe(SOL); + }); + + it("throws when nothing satisfies the allow list", () => { + const policy = createCounterpartyPolicy({ allow: [SOL] }); + expect(() => policy(1, [req(SOL2)])).toThrow(/not in allow list/); + }); + + it("deny wins over allow for the same address", () => { + // Contradictory config must resolve to the safer reading. + const policy = createCounterpartyPolicy({ allow: [SOL], deny: [SOL] }); + expect(() => policy(1, [req(SOL)])).toThrow(/denied/); + }); + + it("matches EVM addresses case-insensitively", () => { + const policy = createCounterpartyPolicy({ deny: [EVM.toLowerCase()] }); + expect(() => policy(1, [req(EVM, "eip155:8453")])).toThrow(/No acceptable counterparty/); + }); + + it("matches Solana addresses case-sensitively (base58 is case-significant)", () => { + // Lower-casing a base58 key yields a DIFFERENT key; it must not match. + const policy = createCounterpartyPolicy({ deny: [SOL.toLowerCase()] }); + expect(policy(1, [req(SOL)])).toHaveLength(1); + }); + + it("empty request list is passed through, not treated as a refusal", () => { + const policy = createCounterpartyPolicy({ deny: [SOL] }); + expect(policy(1, [])).toHaveLength(0); + }); +}); diff --git a/packages/x402-proxy/src/lib/resolve-wallet.ts b/packages/x402-proxy/src/lib/resolve-wallet.ts index 7a86af6..d47f8a0 100644 --- a/packages/x402-proxy/src/lib/resolve-wallet.ts +++ b/packages/x402-proxy/src/lib/resolve-wallet.ts @@ -78,8 +78,64 @@ export type BuildClientOptions = { network?: string; spendLimitDaily?: number; spendLimitPerTx?: number; + /** Counterparty allow/deny by payTo address (deny wins on conflict). */ + allowPayTo?: string[]; + denyPayTo?: string[]; }; +/** + * Refuse (or restrict) payment by COUNTERPARTY. + * + * Spend limits answer "how much may I pay"; this answers "whom may I pay at + * all". Both are pre-sign: a policy that throws stops the client before the + * payment payload is built, so nothing is signed and no funds move. + * + * `deny` wins over `allow` when an address appears in both, because a denial is + * the safer interpretation of contradictory config. + * + * Matching is exact and case-sensitive for Solana (base58 is case-significant) + * and case-insensitive for EVM `0x` addresses (hex checksumming varies). + */ +export function createCounterpartyPolicy(opts: { + allow?: string[]; + deny?: string[]; +}): PaymentPolicy { + const norm = (a: string) => (a.startsWith("0x") ? a.toLowerCase() : a); + const deny = new Set((opts.deny ?? []).map(norm)); + const allow = new Set((opts.allow ?? []).map(norm)); + + return (_version, reqs) => { + const before = reqs.length; + const refused: string[] = []; + + let kept = reqs.filter((r) => { + if (deny.has(norm(r.payTo))) { + refused.push(`${r.payTo} (denied)`); + return false; + } + return true; + }); + + if (allow.size > 0) { + kept = kept.filter((r) => { + if (!allow.has(norm(r.payTo))) { + refused.push(`${r.payTo} (not in allow list)`); + return false; + } + return true; + }); + } + + if (kept.length === 0 && before > 0) { + throw new Error( + `No acceptable counterparty. Refused:\n ${refused.join("\n ")}\n` + + `Update allowPayTo / denyPayTo with: x402-proxy config set`, + ); + } + return kept; + }; +} + /** * Build a configured x402Client from resolved wallet keys. */ @@ -113,6 +169,13 @@ export async function buildX402Client( client.registerPolicy(createNetworkFilter(opts.network)); } + // Counterparty policy: refuse WHO, independent of HOW MUCH. + if (opts?.allowPayTo?.length || opts?.denyPayTo?.length) { + client.registerPolicy( + createCounterpartyPolicy({ allow: opts.allowPayTo, deny: opts.denyPayTo }), + ); + } + // Spend limit policies const daily = opts?.spendLimitDaily; const perTx = opts?.spendLimitPerTx;