From 252c16b7b9e50aebcaffc6ebdb6b099278e3efb2 Mon Sep 17 00:00:00 2001 From: mercadosclaro-eng Date: Sat, 26 Sep 2026 15:29:47 +0200 Subject: [PATCH 1/3] fix(mpp): enforce configured spend limits Apply the owner's daily and per-transaction limits to explicit MPP and automatic MPP fallback paths. --- packages/x402-proxy/src/commands/fetch.ts | 6 ++++++ packages/x402-proxy/src/commands/mcp.ts | 6 ++++++ packages/x402-proxy/src/commands/serve.ts | 8 +++++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/x402-proxy/src/commands/fetch.ts b/packages/x402-proxy/src/commands/fetch.ts index 39a0026..635ba79 100644 --- a/packages/x402-proxy/src/commands/fetch.ts +++ b/packages/x402-proxy/src/commands/fetch.ts @@ -284,6 +284,9 @@ Examples: const mppHandler = await createMppProxyHandler({ evmKey: wallet.evmKey, maxDeposit, + historyPath: getHistoryPath(), + spendLimitDaily: config?.spendLimitDaily, + spendLimitPerTx: config?.spendLimitPerTx, }); // Detect SSE streaming requests - these need session.sse() for mid-stream voucher cycling @@ -401,6 +404,9 @@ Examples: const mppHandler = await createMppProxyHandler({ evmKey: wallet.evmKey, maxDeposit, + historyPath: getHistoryPath(), + spendLimitDaily: config?.spendLimitDaily, + spendLimitPerTx: config?.spendLimitPerTx, }); try { response = await mppHandler.fetch(parsedUrl.toString(), init); diff --git a/packages/x402-proxy/src/commands/mcp.ts b/packages/x402-proxy/src/commands/mcp.ts index 7ba9c9c..a6adc0e 100644 --- a/packages/x402-proxy/src/commands/mcp.ts +++ b/packages/x402-proxy/src/commands/mcp.ts @@ -7,6 +7,7 @@ import { appendHistory, displayNetwork, formatAmount, type TxRecord } from "../h import { getHistoryPath, loadConfig } from "../lib/config.js"; import { dim, error, warn } from "../lib/output.js"; import { buildX402Client, resolveWallet } from "../lib/resolve-wallet.js"; +import { assertSpendAllowed } from "../lib/spend-limit.js"; type McpFlags = { evmKey: string | undefined; @@ -467,6 +468,11 @@ Wallet is auto-generated on first run. No env vars needed.`, const store = challengeAmountStore.getStore(); if (req.amount && store) { store.amount = Number(req.amount) / 10 ** (req.decimals ?? 6); + assertSpendAllowed(store.amount, { + historyPath: getHistoryPath(), + spendLimitDaily: config?.spendLimitDaily, + spendLimitPerTx: config?.spendLimitPerTx, + }); } return (m.createCredential as (p: unknown) => Promise)(params); }, diff --git a/packages/x402-proxy/src/commands/serve.ts b/packages/x402-proxy/src/commands/serve.ts index 455df39..97211e0 100644 --- a/packages/x402-proxy/src/commands/serve.ts +++ b/packages/x402-proxy/src/commands/serve.ts @@ -135,7 +135,13 @@ export async function startServeServer( }); const x402Proxy = createX402ProxyHandler({ client: x402Client }); const mppHandler = wallet.evmKey - ? await createMppProxyHandler({ evmKey: wallet.evmKey, maxDeposit: configuredMppBudget }) + ? await createMppProxyHandler({ + evmKey: wallet.evmKey, + maxDeposit: configuredMppBudget, + historyPath: getHistoryPath(), + spendLimitDaily: config?.spendLimitDaily, + spendLimitPerTx: config?.spendLimitPerTx, + }) : null; const { providers, models } = resolveProviders({ protocol: resolvedProtocol, From b79ecc582aaf0f3fd9ed56b48149b9f77095557d Mon Sep 17 00:00:00 2001 From: mercadosclaro-eng Date: Sat, 26 Sep 2026 15:30:45 +0200 Subject: [PATCH 2/3] fix(mpp): guard payment credential creation Validate each MPP challenge against the configured owner limits before creating a credential. --- packages/x402-proxy/src/handler.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/x402-proxy/src/handler.ts b/packages/x402-proxy/src/handler.ts index 024cd0e..a163fad 100644 --- a/packages/x402-proxy/src/handler.ts +++ b/packages/x402-proxy/src/handler.ts @@ -6,6 +6,7 @@ import { parseUnits } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { clearSession, saveSession } from "./lib/config.js"; import { getMppVoucherHeadroomUsdc, isDebugEnabled } from "./lib/env.js"; +import { assertSpendAllowed } from "./lib/spend-limit.js"; export type PaymentInfo = { protocol: "x402"; @@ -181,6 +182,9 @@ export function splitSseFrames(buffer: string): { frames: string[]; rest: string export async function createMppProxyHandler(opts: { evmKey: string; maxDeposit?: string; + historyPath?: string; + spendLimitDaily?: number; + spendLimitPerTx?: number; }): Promise { const account = privateKeyToAccount(opts.evmKey as `0x${string}`); const maxDeposit = opts.maxDeposit ?? "1"; @@ -196,7 +200,15 @@ export async function createMppProxyHandler(opts: { onChallenge: async (challenge) => { const req = challenge.request as { amount?: string; decimals?: number }; if (req.amount) { - lastChallengeAmount = (Number(req.amount) / 10 ** (req.decimals ?? 6)).toString(); + const amount = Number(req.amount) / 10 ** (req.decimals ?? 6); + if (opts.historyPath) { + assertSpendAllowed(amount, { + historyPath: opts.historyPath, + spendLimitDaily: opts.spendLimitDaily, + spendLimitPerTx: opts.spendLimitPerTx, + }); + } + lastChallengeAmount = amount.toString(); } return undefined; }, From 849925a0b53dbb6804ef47365b41f0cac0619389 Mon Sep 17 00:00:00 2001 From: mercadosclaro-eng Date: Sat, 26 Sep 2026 15:31:42 +0200 Subject: [PATCH 3/3] test(mpp): cover owner spend limits Add a shared limit check with tests for per-transaction, daily remaining, and allowed payments. --- .../x402-proxy/src/lib/spend-limit.test.ts | 53 +++++++++++++++++++ packages/x402-proxy/src/lib/spend-limit.ts | 31 +++++++++++ 2 files changed, 84 insertions(+) create mode 100644 packages/x402-proxy/src/lib/spend-limit.test.ts create mode 100644 packages/x402-proxy/src/lib/spend-limit.ts diff --git a/packages/x402-proxy/src/lib/spend-limit.test.ts b/packages/x402-proxy/src/lib/spend-limit.test.ts new file mode 100644 index 0000000..b5472bc --- /dev/null +++ b/packages/x402-proxy/src/lib/spend-limit.test.ts @@ -0,0 +1,53 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { appendHistory } from "../history.js"; +import { assertSpendAllowed } from "./spend-limit.js"; + +const dirs: string[] = []; + +function historyPath(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "x402-proxy-limit-")); + dirs.push(dir); + return path.join(dir, "history.jsonl"); +} + +afterEach(() => { + for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe("assertSpendAllowed", () => { + it("blocks an MPP charge above the per-transaction owner limit", () => { + expect(() => + assertSpendAllowed(1.25, { historyPath: historyPath(), spendLimitPerTx: 1 }), + ).toThrow("exceeds per-transaction limit"); + }); + + it("blocks an MPP charge that would exceed the remaining daily owner limit", () => { + const file = historyPath(); + appendHistory(file, { + t: Date.now(), + ok: true, + kind: "mpp_payment", + net: "tempo", + from: "0xowner", + amount: 4.5, + token: "USDC", + }); + + expect(() => assertSpendAllowed(1, { historyPath: file, spendLimitDaily: 5 })).toThrow( + "exceeds remaining daily limit", + ); + }); + + it("allows an MPP charge within both owner limits", () => { + expect(() => + assertSpendAllowed(0.5, { + historyPath: historyPath(), + spendLimitDaily: 5, + spendLimitPerTx: 1, + }), + ).not.toThrow(); + }); +}); diff --git a/packages/x402-proxy/src/lib/spend-limit.ts b/packages/x402-proxy/src/lib/spend-limit.ts new file mode 100644 index 0000000..f158ed2 --- /dev/null +++ b/packages/x402-proxy/src/lib/spend-limit.ts @@ -0,0 +1,31 @@ +import { calcSpend, readHistory } from "../history.js"; + +export type SpendLimits = { + spendLimitDaily?: number; + spendLimitPerTx?: number; + historyPath: string; +}; + +/** Enforce owner-configured spending limits before creating a payment credential. */ +export function assertSpendAllowed(amount: number, limits: SpendLimits): void { + if (!Number.isFinite(amount) || amount < 0) { + throw new Error("Invalid payment amount"); + } + + if (limits.spendLimitPerTx !== undefined && amount > limits.spendLimitPerTx) { + throw new Error( + `Payment ${amount} USDC exceeds per-transaction limit of ${limits.spendLimitPerTx} USDC`, + ); + } + + if (limits.spendLimitDaily !== undefined) { + const spend = calcSpend(readHistory(limits.historyPath)); + if (spend.today >= limits.spendLimitDaily) { + throw new Error(`Daily spend limit of ${limits.spendLimitDaily} USDC reached`); + } + const remaining = limits.spendLimitDaily - spend.today; + if (amount > remaining) { + throw new Error(`Payment ${amount} USDC exceeds remaining daily limit of ${remaining} USDC`); + } + } +}