From 079a3b8798c23c3534c2c6db37461e5ea83c44e4 Mon Sep 17 00:00:00 2001 From: Bortlesboat Date: Sat, 28 Mar 2026 15:51:49 -0400 Subject: [PATCH] feat(client): add spending policy enforcement for x402 payments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a SpendingTracker that evaluates configurable spending rules before signing any x402 payment. Supports rolling hourly/daily budgets, per-transaction and per-tool caps, rate limiting, and recipient allow/block lists. Fully opt-in — no policy configured means existing behavior is unchanged. Also exposes --max-per-hour, --max-per-day, --max-tx-per-hour CLI flags for the connect command. Closes #31 --- packages/js-sdk/src/cli/index.ts | 28 +++ packages/js-sdk/src/client.ts | 4 +- .../js-sdk/src/client/spending-tracker.ts | 205 ++++++++++++++++++ .../js-sdk/src/client/with-x402-client.ts | 38 ++++ 4 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 packages/js-sdk/src/client/spending-tracker.ts diff --git a/packages/js-sdk/src/cli/index.ts b/packages/js-sdk/src/cli/index.ts index bc0355f9..882277f9 100644 --- a/packages/js-sdk/src/cli/index.ts +++ b/packages/js-sdk/src/cli/index.ts @@ -5,6 +5,7 @@ import { config } from "dotenv"; import { createSigner } from "x402/types"; import packageJson from '../../package.json' with { type: 'json' }; import type { X402ClientConfig } from "../client/with-x402-client.js"; +import type { SpendingPolicy } from "../client/spending-tracker.js"; import { ServerType, startStdioServer } from '../server/stdio/start-stdio-server.js'; import { SupportedEVMNetworks, SupportedSVMNetworks } from "x402/types"; @@ -18,6 +19,9 @@ interface ServerOptions { svm?: string; evmNetwork?: string; svmNetwork?: string; + maxPerHour?: string; + maxPerDay?: string; + maxTxPerHour?: string; } const program = new Command(); @@ -37,6 +41,9 @@ program .option('--svm ', 'SVM secret key (base58/hex) (env: SVM_SECRET_KEY)') .option('--evm-network ', 'EVM network (base-sepolia, base, avalanche-fuji, avalanche, iotex, sei, sei-testnet). Default: base-sepolia (env: EVM_NETWORK)') .option('--svm-network ', 'SVM network (solana-devnet, solana). Default: solana-devnet (env: SVM_NETWORK)') + .option('--max-per-hour ', 'Max spend per rolling hour in atomic units (env: X402_MAX_PER_HOUR)') + .option('--max-per-day ', 'Max spend per rolling 24h in atomic units (env: X402_MAX_PER_DAY)') + .option('--max-tx-per-hour ', 'Max number of payments per rolling hour (env: X402_MAX_TX_PER_HOUR)') .action(async (options: ServerOptions) => { try { const apiKey = options.apiKey || process.env.API_KEY; @@ -148,9 +155,30 @@ program const maybeMax = maxAtomicArg ? (() => { try { return BigInt(maxAtomicArg); } catch { return undefined; } })() : undefined; + // Build spending policy from CLI flags / env vars + const maxPerHourArg = options.maxPerHour || process.env.X402_MAX_PER_HOUR; + const maxPerDayArg = options.maxPerDay || process.env.X402_MAX_PER_DAY; + const maxTxPerHourArg = options.maxTxPerHour || process.env.X402_MAX_TX_PER_HOUR; + + let policy: SpendingPolicy | undefined; + if (maxPerHourArg || maxPerDayArg || maxTxPerHourArg) { + policy = {}; + if (maxPerHourArg) { + try { policy.maxPerHour = BigInt(maxPerHourArg); } catch { /* ignore invalid */ } + } + if (maxPerDayArg) { + try { policy.maxPerDay = BigInt(maxPerDayArg); } catch { /* ignore invalid */ } + } + if (maxTxPerHourArg) { + const n = parseInt(maxTxPerHourArg, 10); + if (!isNaN(n) && n > 0) policy.maxTransactionsPerHour = n; + } + } + x402ClientConfig = { wallet: walletObj as X402ClientConfig['wallet'], ...(maybeMax !== undefined ? { maxPaymentValue: maybeMax } : {}), + ...(policy ? { policy } : {}), confirmationCallback: async (payment) => { return true; } diff --git a/packages/js-sdk/src/client.ts b/packages/js-sdk/src/client.ts index ab543e79..449d8376 100644 --- a/packages/js-sdk/src/client.ts +++ b/packages/js-sdk/src/client.ts @@ -1 +1,3 @@ -export { withX402Client } from './client/with-x402-client'; \ No newline at end of file +export { withX402Client } from './client/with-x402-client'; +export type { SpendingPolicy, SpendingRecord, PolicyViolation } from './client/spending-tracker'; +export { SpendingTracker, formatViolation } from './client/spending-tracker'; \ No newline at end of file diff --git a/packages/js-sdk/src/client/spending-tracker.ts b/packages/js-sdk/src/client/spending-tracker.ts new file mode 100644 index 00000000..8358fb9e --- /dev/null +++ b/packages/js-sdk/src/client/spending-tracker.ts @@ -0,0 +1,205 @@ +/** + * In-memory spending tracker for x402 payment policy enforcement. + * + * Tracks payment amounts over rolling time windows so the client can + * enforce cumulative budgets (per-hour, per-day) and rate limits + * without any external state. + */ + +export interface SpendingRecord { + amount: bigint; + recipient: string; + toolName: string; + network: string; + timestamp: number; +} + +export interface SpendingPolicy { + /** Max atomic units per single transaction (overrides maxPaymentValue if set) */ + maxPerTransaction?: bigint; + /** Max atomic units across all transactions in a rolling 1-hour window */ + maxPerHour?: bigint; + /** Max atomic units across all transactions in a rolling 24-hour window */ + maxPerDay?: bigint; + /** Max number of payments allowed in a rolling 1-hour window */ + maxTransactionsPerHour?: number; + /** Addresses the client is allowed to pay. If set, all others are rejected. */ + allowedRecipients?: string[]; + /** Addresses the client must never pay. Checked before allowedRecipients. */ + blockedRecipients?: string[]; + /** Per-tool transaction caps (atomic units). Tool names not listed fall through to global limits. */ + toolLimits?: Record; +} + +export type PolicyViolation = + | { type: "per_transaction"; limit: bigint; requested: bigint } + | { type: "hourly_budget"; limit: bigint; spent: bigint; requested: bigint } + | { type: "daily_budget"; limit: bigint; spent: bigint; requested: bigint } + | { type: "rate_limit"; limit: number; count: number; windowMs: number } + | { type: "blocked_recipient"; recipient: string } + | { type: "recipient_not_allowed"; recipient: string } + | { type: "tool_limit"; tool: string; limit: bigint; requested: bigint }; + +export function formatViolation(v: PolicyViolation): string { + switch (v.type) { + case "per_transaction": + return `Transaction amount ${v.requested} exceeds per-transaction limit ${v.limit}`; + case "hourly_budget": + return `Hourly budget exceeded: ${v.spent} already spent + ${v.requested} requested > ${v.limit} limit`; + case "daily_budget": + return `Daily budget exceeded: ${v.spent} already spent + ${v.requested} requested > ${v.limit} limit`; + case "rate_limit": + return `Rate limit exceeded: ${v.count} transactions in the last hour (limit: ${v.limit})`; + case "blocked_recipient": + return `Recipient ${v.recipient} is blocked`; + case "recipient_not_allowed": + return `Recipient ${v.recipient} is not in the allow list`; + case "tool_limit": + return `Tool "${v.tool}" amount ${v.requested} exceeds tool-specific limit ${v.limit}`; + } +} + +const ONE_HOUR_MS = 60 * 60 * 1000; +const ONE_DAY_MS = 24 * ONE_HOUR_MS; + +export class SpendingTracker { + private records: SpendingRecord[] = []; + private readonly policy: SpendingPolicy; + + constructor(policy: SpendingPolicy) { + this.policy = policy; + } + + /** + * Check whether a proposed payment violates the policy. + * Returns null if allowed, or a PolicyViolation describing what failed. + */ + evaluate( + amount: bigint, + recipient: string, + toolName: string, + network: string, + ): PolicyViolation | null { + const now = Date.now(); + const p = this.policy; + + // --- Recipient checks (cheapest, no iteration) --- + + if (p.blockedRecipients) { + const lower = recipient.toLowerCase(); + if (p.blockedRecipients.some((b) => b.toLowerCase() === lower)) { + return { type: "blocked_recipient", recipient }; + } + } + + if (p.allowedRecipients) { + const lower = recipient.toLowerCase(); + if (!p.allowedRecipients.some((a) => a.toLowerCase() === lower)) { + return { type: "recipient_not_allowed", recipient }; + } + } + + // --- Per-transaction cap --- + + const txLimit = p.maxPerTransaction; + if (txLimit !== undefined && amount > txLimit) { + return { type: "per_transaction", limit: txLimit, requested: amount }; + } + + // --- Tool-specific cap --- + + if (p.toolLimits && toolName in p.toolLimits) { + const toolCap = p.toolLimits[toolName]!; + if (amount > toolCap) { + return { type: "tool_limit", tool: toolName, limit: toolCap, requested: amount }; + } + } + + // --- Rolling window checks (prune stale records first) --- + + this.prune(now); + + if (p.maxPerHour !== undefined) { + const hourAgo = now - ONE_HOUR_MS; + const hourlySpend = this.sumSince(hourAgo); + if (hourlySpend + amount > p.maxPerHour) { + return { + type: "hourly_budget", + limit: p.maxPerHour, + spent: hourlySpend, + requested: amount, + }; + } + } + + if (p.maxPerDay !== undefined) { + const dayAgo = now - ONE_DAY_MS; + const dailySpend = this.sumSince(dayAgo); + if (dailySpend + amount > p.maxPerDay) { + return { + type: "daily_budget", + limit: p.maxPerDay, + spent: dailySpend, + requested: amount, + }; + } + } + + if (p.maxTransactionsPerHour !== undefined) { + const hourAgo = now - ONE_HOUR_MS; + const count = this.countSince(hourAgo); + if (count >= p.maxTransactionsPerHour) { + return { + type: "rate_limit", + limit: p.maxTransactionsPerHour, + count, + windowMs: ONE_HOUR_MS, + }; + } + } + + return null; + } + + /** + * Record a payment that was actually executed (call after successful signing). + */ + record(amount: bigint, recipient: string, toolName: string, network: string): void { + this.records.push({ + amount, + recipient, + toolName, + network, + timestamp: Date.now(), + }); + } + + /** Visible for testing. */ + getRecords(): readonly SpendingRecord[] { + return this.records; + } + + // --- internals --- + + private prune(now: number): void { + // Drop anything older than 24 hours -- no policy looks further back + const cutoff = now - ONE_DAY_MS; + this.records = this.records.filter((r) => r.timestamp >= cutoff); + } + + private sumSince(since: number): bigint { + let total = BigInt(0); + for (const r of this.records) { + if (r.timestamp >= since) total += r.amount; + } + return total; + } + + private countSince(since: number): number { + let count = 0; + for (const r of this.records) { + if (r.timestamp >= since) count++; + } + return count; + } +} diff --git a/packages/js-sdk/src/client/with-x402-client.ts b/packages/js-sdk/src/client/with-x402-client.ts index 91b64f2e..8bb5449c 100644 --- a/packages/js-sdk/src/client/with-x402-client.ts +++ b/packages/js-sdk/src/client/with-x402-client.ts @@ -9,6 +9,7 @@ import type { import { createPaymentHeader } from 'x402/client'; import type { MultiNetworkSigner, PaymentRequirements, Network } from 'x402/types'; import { SupportedEVMNetworks, SupportedSVMNetworks } from 'x402/types'; +import { SpendingTracker, formatViolation, type SpendingPolicy } from './spending-tracker.js'; export interface X402AugmentedClient { @@ -32,6 +33,8 @@ export type X402ClientConfig = { | { network: Network } | { requirement: PaymentRequirements } >; // Allows declining (false), approving (true), or selecting which requirement + /** Optional spending policy for rolling budgets, rate limits, and recipient controls. */ + policy?: SpendingPolicy; }; /** @@ -46,6 +49,10 @@ export function withX402Client( const maxPaymentValue = x402Config.maxPaymentValue ?? BigInt(0.1 * 10 ** 6); // 0.10 USDC + const spendingTracker = x402Config.policy + ? new SpendingTracker(x402Config.policy) + : null; + const _listTools = client.listTools.bind(client); // Wrap the original method to include payment information in the description @@ -194,6 +201,31 @@ export function withX402Client( }; } + // Evaluate spending policy (if configured) before signing + const toolName = String(params.name ?? ""); + const recipient = String("payTo" in req ? req.payTo : ""); + const network = String(req.network ?? ""); + + if (spendingTracker) { + const violation = spendingTracker.evaluate( + maxAmountRequired, + recipient, + toolName, + network, + ); + if (violation) { + return { + isError: true, + content: [ + { + type: "text", + text: `Spending policy violation: ${formatViolation(violation)}` + } + ] + }; + } + } + // Use x402/client to get the X-PAYMENT token with MultiNetworkSigner const token = await createPaymentHeader( signer, @@ -213,6 +245,12 @@ export function withX402Client( resultSchema, options ); + + // Record the spend on successful payment + if (!paidRes.isError && spendingTracker) { + spendingTracker.record(maxAmountRequired, recipient, toolName, network); + } + return paidRes; }