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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions packages/x402-proxy/src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/x402-proxy/src/commands/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
2 changes: 2 additions & 0 deletions packages/x402-proxy/src/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down
2 changes: 2 additions & 0 deletions packages/x402-proxy/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
4 changes: 4 additions & 0 deletions packages/x402-proxy/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
71 changes: 71 additions & 0 deletions packages/x402-proxy/src/lib/counterparty-policy.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
63 changes: 63 additions & 0 deletions packages/x402-proxy/src/lib/resolve-wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;
Expand Down