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
6 changes: 6 additions & 0 deletions packages/x402-proxy/src/commands/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions packages/x402-proxy/src/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string>)(params);
},
Expand Down
8 changes: 7 additions & 1 deletion packages/x402-proxy/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 13 additions & 1 deletion packages/x402-proxy/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<MppProxyHandler> {
const account = privateKeyToAccount(opts.evmKey as `0x${string}`);
const maxDeposit = opts.maxDeposit ?? "1";
Expand All @@ -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;
},
Expand Down
53 changes: 53 additions & 0 deletions packages/x402-proxy/src/lib/spend-limit.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
31 changes: 31 additions & 0 deletions packages/x402-proxy/src/lib/spend-limit.ts
Original file line number Diff line number Diff line change
@@ -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`);
}
}
}