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
28 changes: 28 additions & 0 deletions packages/js-sdk/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -18,6 +19,9 @@ interface ServerOptions {
svm?: string;
evmNetwork?: string;
svmNetwork?: string;
maxPerHour?: string;
maxPerDay?: string;
maxTxPerHour?: string;
}

const program = new Command();
Expand All @@ -37,6 +41,9 @@ program
.option('--svm <secretKey>', 'SVM secret key (base58/hex) (env: SVM_SECRET_KEY)')
.option('--evm-network <network>', 'EVM network (base-sepolia, base, avalanche-fuji, avalanche, iotex, sei, sei-testnet). Default: base-sepolia (env: EVM_NETWORK)')
.option('--svm-network <network>', 'SVM network (solana-devnet, solana). Default: solana-devnet (env: SVM_NETWORK)')
.option('--max-per-hour <value>', 'Max spend per rolling hour in atomic units (env: X402_MAX_PER_HOUR)')
.option('--max-per-day <value>', 'Max spend per rolling 24h in atomic units (env: X402_MAX_PER_DAY)')
.option('--max-tx-per-hour <value>', '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;
Expand Down Expand Up @@ -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;
}
Expand Down
4 changes: 3 additions & 1 deletion packages/js-sdk/src/client.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export { withX402Client } from './client/with-x402-client';
export { withX402Client } from './client/with-x402-client';
export type { SpendingPolicy, SpendingRecord, PolicyViolation } from './client/spending-tracker';
export { SpendingTracker, formatViolation } from './client/spending-tracker';
205 changes: 205 additions & 0 deletions packages/js-sdk/src/client/spending-tracker.ts
Original file line number Diff line number Diff line change
@@ -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<string, bigint>;
}

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;
}
}
38 changes: 38 additions & 0 deletions packages/js-sdk/src/client/with-x402-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
};

/**
Expand All @@ -46,6 +49,10 @@ export function withX402Client<T extends MCPClient>(

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
Expand Down Expand Up @@ -194,6 +201,31 @@ export function withX402Client<T extends MCPClient>(
};
}

// 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,
Expand All @@ -213,6 +245,12 @@ export function withX402Client<T extends MCPClient>(
resultSchema,
options
);

// Record the spend on successful payment
if (!paidRes.isError && spendingTracker) {
spendingTracker.record(maxAmountRequired, recipient, toolName, network);
}

return paidRes;
}

Expand Down