diff --git a/src/index.ts b/src/index.ts index c93a6813..b46706b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2448,15 +2448,23 @@ export { FileSpendControlStorage, InMemorySpendControlStorage, formatDuration, + abortIfSpendPolicyBlocks, + registerSpendPolicyHook, + normalizePayee, + CAIP2_BASE, + CAIP2_SOLANA_MAINNET, } from "./spend-control.js"; export type { SpendWindow, + PolicyList, SpendLimits, + CounterpartyInfo, SpendRecord, SpendingStatus, CheckResult, SpendControlStorage, SpendControlOptions, + SpendPolicyAbort, } from "./spend-control.js"; export { generateWalletMnemonic, diff --git a/src/proxy.ts b/src/proxy.ts index eda25998..7ed95f89 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -78,6 +78,7 @@ import type { SolanaBalanceMonitor } from "./solana-balance.js"; /** Union type for chain-agnostic balance monitoring */ type AnyBalanceMonitor = BalanceMonitor | SolanaBalanceMonitor; import { resolvePaymentChain } from "./auth.js"; +import { registerSpendPolicyHook, SpendControl } from "./spend-control.js"; import { compressContext, shouldCompress, type NormalizedMessage } from "./compression/index.js"; // Error classes available for programmatic use but not used in proxy // (universal free fallback means we don't throw balance errors anymore) @@ -1333,6 +1334,11 @@ export type ProxyOptions = { onLowBalance?: (info: LowBalanceInfo) => void; /** Called when balance is insufficient for a request (request fails) */ onInsufficientFunds?: (info: InsufficientFundsInfo) => void; + /** + * Spend / counterparty policy. Default: FileSpendControlStorage at + * ~/.openclaw/blockrun/spending.json. Inject in tests. + */ + spendControl?: SpendControl; /** * Upstream proxy URL for all outgoing requests. * Supports http://, https://, and socks5:// schemes. @@ -2145,6 +2151,8 @@ export async function startProxy(options: ProxyOptions): Promise { const evmPublicClient = createPublicClient({ chain: base, transport: http() }); const evmSigner = toClientEvmSigner(account, evmPublicClient); const x402 = new x402Client(); + const spendControl = options.spendControl ?? new SpendControl(); + registerSpendPolicyHook(x402, spendControl); registerExactEvmScheme(x402, { signer: evmSigner }); // Register Solana scheme if key is available diff --git a/src/spend-control.test.ts b/src/spend-control.test.ts index 30fb5b7f..6de4a77d 100644 --- a/src/spend-control.test.ts +++ b/src/spend-control.test.ts @@ -2,8 +2,19 @@ * SpendControl tests — limits, recording, window expiry, persistence. */ -import { describe, it, expect } from "vitest"; -import { SpendControl, InMemorySpendControlStorage, formatDuration } from "./spend-control.js"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { x402Client } from "@x402/fetch"; +import { + SpendControl, + InMemorySpendControlStorage, + formatDuration, + registerSpendPolicyHook, + CAIP2_BASE, + CAIP2_SOLANA_MAINNET, +} from "./spend-control.js"; function createControl(nowMs = Date.now()) { let clock = nowMs; @@ -236,6 +247,329 @@ describe("SpendControl", () => { }); }); +describe("counterparty policy", () => { + describe("payee allowlist/blocklist", () => { + it("has no effect when not configured", () => { + const { control } = createControl(); + expect(control.check(0.01, { payTo: "0xanything" }).allowed).toBe(true); + expect(control.check(0.01).allowed).toBe(true); + }); + + it("allows a payee in the allowlist", () => { + const { control } = createControl(); + control.setPolicy("allowedPayees", ["0xgood"]); + expect(control.check(0.01, { payTo: "0xgood" }).allowed).toBe(true); + }); + + it("blocks a payee not in the allowlist", () => { + const { control } = createControl(); + control.setPolicy("allowedPayees", ["0xgood"]); + const result = control.check(0.01, { payTo: "0xother" }); + expect(result.allowed).toBe(false); + expect(result.blockedByPolicy).toBe("allowedPayees"); + }); + + it("blocks a payee on the blocklist", () => { + const { control } = createControl(); + control.setPolicy("blockedPayees", ["0xbad"]); + const result = control.check(0.01, { payTo: "0xbad" }); + expect(result.allowed).toBe(false); + expect(result.blockedByPolicy).toBe("blockedPayees"); + }); + + it("passes a payee not on the blocklist", () => { + const { control } = createControl(); + control.setPolicy("blockedPayees", ["0xbad"]); + expect(control.check(0.01, { payTo: "0xfine" }).allowed).toBe(true); + }); + + it("blocklist wins when a payee is on both lists", () => { + const { control } = createControl(); + control.setPolicy("allowedPayees", ["0xboth"]); + control.setPolicy("blockedPayees", ["0xboth"]); + const result = control.check(0.01, { payTo: "0xboth" }); + expect(result.allowed).toBe(false); + expect(result.blockedByPolicy).toBe("blockedPayees"); + }); + + it("fails closed when policy is configured but no payTo is given", () => { + const { control } = createControl(); + control.setPolicy("allowedPayees", ["0xgood"]); + const result = control.check(0.01); + expect(result.allowed).toBe(false); + expect(result.blockedByPolicy).toBe("allowedPayees"); + }); + + it("matches checksummed EVM denylist entries case-insensitively", () => { + const { control } = createControl(); + const checksummed = "0xAbcDef0123456789AbcDef0123456789AbcDef01"; + control.setPolicy("blockedPayees", [checksummed]); + const result = control.check(0.01, { payTo: checksummed.toLowerCase() }); + expect(result.allowed).toBe(false); + expect(result.blockedByPolicy).toBe("blockedPayees"); + }); + + it("leaves Solana base58 payees case-sensitive", () => { + const { control } = createControl(); + control.setPolicy("blockedPayees", ["SoLanaPayee1111111111111111111111111111111"]); + expect( + control.check(0.01, { payTo: "SoLanaPayee1111111111111111111111111111111" }).allowed, + ).toBe(false); + expect( + control.check(0.01, { payTo: "solanapayee1111111111111111111111111111111" }).allowed, + ).toBe(true); + }); + + it("clearPolicy removes a configured list", () => { + const { control } = createControl(); + control.setPolicy("allowedPayees", ["0xgood"]); + control.clearPolicy("allowedPayees"); + expect(control.check(0.01, { payTo: "0xanything" }).allowed).toBe(true); + }); + + it("does not set blockedBy (SpendWindow) for a policy denial", () => { + const { control } = createControl(); + control.setLimit("perRequest", 1000); + control.setPolicy("blockedPayees", ["0xbad"]); + const result = control.check(0.01, { payTo: "0xbad" }); + expect(result.allowed).toBe(false); + expect(result.blockedByPolicy).toBe("blockedPayees"); + expect(result.blockedBy).toBeUndefined(); + }); + }); + + describe("network allowlist", () => { + it("has no effect when not configured", () => { + const { control } = createControl(); + expect(control.check(0.01, { network: "anything" }).allowed).toBe(true); + }); + + it("allows a network in the allowlist", () => { + const { control } = createControl(); + control.setPolicy("allowedNetworks", [CAIP2_BASE]); + expect(control.check(0.01, { network: CAIP2_BASE }).allowed).toBe(true); + }); + + it("blocks a network not in the allowlist", () => { + const { control } = createControl(); + control.setPolicy("allowedNetworks", [CAIP2_BASE]); + const result = control.check(0.01, { network: CAIP2_SOLANA_MAINNET }); + expect(result.allowed).toBe(false); + expect(result.blockedByPolicy).toBe("allowedNetworks"); + }); + + it("does not treat the nickname 'base' as eip155:8453", () => { + const { control } = createControl(); + control.setPolicy("allowedNetworks", [CAIP2_BASE]); + const result = control.check(0.01, { network: "base" }); + expect(result.allowed).toBe(false); + expect(result.blockedByPolicy).toBe("allowedNetworks"); + }); + + it("fails closed when configured but no network is given", () => { + const { control } = createControl(); + control.setPolicy("allowedNetworks", [CAIP2_BASE]); + const result = control.check(0.01); + expect(result.allowed).toBe(false); + expect(result.blockedByPolicy).toBe("allowedNetworks"); + }); + }); + + describe("asset allowlist", () => { + it("allows an asset in the allowlist", () => { + const { control } = createControl(); + control.setPolicy("allowedAssets", ["USDC"]); + expect(control.check(0.01, { asset: "USDC" }).allowed).toBe(true); + }); + + it("blocks an asset not in the allowlist", () => { + const { control } = createControl(); + control.setPolicy("allowedAssets", ["USDC"]); + const result = control.check(0.01, { asset: "SOL" }); + expect(result.allowed).toBe(false); + expect(result.blockedByPolicy).toBe("allowedAssets"); + }); + }); + + describe("setPolicy validation", () => { + it("rejects an empty list", () => { + const { control } = createControl(); + expect(() => control.setPolicy("allowedPayees", [])).toThrow(); + }); + + it("rejects non-string or empty-string entries", () => { + const { control } = createControl(); + // @ts-expect-error deliberately invalid entry type, for a runtime validation test + expect(() => control.setPolicy("allowedPayees", [123])).toThrow(); + expect(() => control.setPolicy("allowedPayees", [""])).toThrow(); + }); + + it("rejects a SpendWindow name passed as a policy list, and does not touch that limit", () => { + const { control } = createControl(); + control.setLimit("perRequest", 0.5); + // @ts-expect-error deliberately invalid list, for a runtime validation test + expect(() => control.setPolicy("perRequest", ["0xgood"])).toThrow(); + expect(control.getLimits().perRequest).toBe(0.5); + }); + + it("clearPolicy rejects a SpendWindow name and does not clear that limit", () => { + const { control } = createControl(); + control.setLimit("hourly", 1.0); + // @ts-expect-error deliberately invalid list, for a runtime validation test + expect(() => control.clearPolicy("hourly")).toThrow(); + expect(control.getLimits().hourly).toBe(1.0); + }); + }); + + describe("defensive copies", () => { + it("mutating the array returned by getLimits() does not affect live policy", () => { + const { control } = createControl(); + control.setPolicy("allowedPayees", ["0xgood"]); + const limits = control.getLimits(); + limits.allowedPayees?.push("0xsneaky"); + expect(control.check(0.01, { payTo: "0xsneaky" }).allowed).toBe(false); + expect(control.getLimits().allowedPayees).toEqual(["0xgood"]); + }); + + it("mutating the array returned by getStatus().limits does not affect live policy", () => { + const { control } = createControl(); + control.setPolicy("blockedPayees", ["0xbad"]); + const status = control.getStatus(); + status.limits.blockedPayees?.push("0xalsogood"); + expect(control.check(0.01, { payTo: "0xalsogood" }).allowed).toBe(true); + }); + }); + + describe("amount checks still run after policy passes", () => { + it("still enforces perRequest once payee policy passes", () => { + const { control } = createControl(); + control.setPolicy("allowedPayees", ["0xgood"]); + control.setLimit("perRequest", 0.1); + const result = control.check(0.5, { payTo: "0xgood" }); + expect(result.allowed).toBe(false); + expect(result.blockedBy).toBe("perRequest"); + }); + }); +}); + +describe("FileSpendControlStorage persistence", () => { + let tmpHome: string | undefined; + const originalHome = process.env.HOME; + + afterEach(() => { + if (tmpHome) fs.rmSync(tmpHome, { recursive: true, force: true }); + tmpHome = undefined; + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + }); + + it("round-trips policy lists, not just spend limits, across save/load", async () => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawrouter-spend-")); + process.env.HOME = tmpHome; + vi.resetModules(); + const mod = await import("./spend-control.js"); + const storage = new mod.FileSpendControlStorage(); + + storage.save({ + limits: { + perRequest: 0.5, + allowedPayees: ["0xgood"], + blockedPayees: ["0xbad"], + allowedNetworks: [CAIP2_BASE], + allowedAssets: ["USDC"], + }, + history: [], + }); + + const loaded = storage.load(); + expect(loaded?.limits.perRequest).toBe(0.5); + expect(loaded?.limits.allowedPayees).toEqual(["0xgood"]); + expect(loaded?.limits.blockedPayees).toEqual(["0xbad"]); + expect(loaded?.limits.allowedNetworks).toEqual([CAIP2_BASE]); + expect(loaded?.limits.allowedAssets).toEqual(["USDC"]); + }); + + it("refuses to load when a policy list has a malformed entry", async () => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawrouter-spend-")); + process.env.HOME = tmpHome; + vi.resetModules(); + const mod = await import("./spend-control.js"); + const storage = new mod.FileSpendControlStorage(); + const spendingFile = path.join(tmpHome, ".openclaw", "blockrun", "spending.json"); + fs.mkdirSync(path.dirname(spendingFile), { recursive: true }); + fs.writeFileSync( + spendingFile, + JSON.stringify({ limits: { allowedPayees: ["ok", 123, ""] }, history: [] }), + ); + + expect(() => storage.load()).toThrow(/refusing to load spending.json/); + }); +}); + +describe("x402 onBeforePaymentCreation spend policy", () => { + const blocked = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + function payment(client: x402Client, amount: string, payTo = blocked) { + return client.createPaymentPayload({ + x402Version: 2, + resource: { url: "https://example.invalid/pay" }, + accepts: [ + { + scheme: "exact", + network: CAIP2_BASE, + amount, + asset: "USDC", + payTo, + maxTimeoutSeconds: 60, + extra: {}, + }, + ], + }); + } + + it("aborts before the scheme signer is invoked", async () => { + let signerCalls = 0; + const storage = new InMemorySpendControlStorage(); + const control = new SpendControl({ storage }); + control.setPolicy("blockedPayees", [blocked]); + + const client = new x402Client(); + registerSpendPolicyHook(client, control); + client.register(CAIP2_BASE, { + scheme: "exact", + async createPaymentPayload() { + signerCalls += 1; + return { x402Version: 2, payload: {} }; + }, + }); + + await expect(payment(client, "1000")).rejects.toThrow(/Payment creation aborted/); + expect(signerCalls).toBe(0); + }); + + it("reserves aggregate budget before signing the next payment", async () => { + let signerCalls = 0; + const control = new SpendControl({ storage: new InMemorySpendControlStorage() }); + control.setLimit("hourly", 0.015); + const client = new x402Client(); + registerSpendPolicyHook(client, control); + client.register(CAIP2_BASE, { + scheme: "exact", + async createPaymentPayload() { + signerCalls += 1; + return { x402Version: 2, payload: {} }; + }, + }); + + await payment(client, "10000", "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + await expect( + payment(client, "10000", "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + ).rejects.toThrow(/Payment creation aborted/); + expect(signerCalls).toBe(1); + expect(control.getSpending("hourly")).toBe(0.01); + }); +}); + describe("formatDuration", () => { it("formats seconds", () => { expect(formatDuration(30)).toBe("30s"); diff --git a/src/spend-control.ts b/src/spend-control.ts index 6402f080..94703d36 100644 --- a/src/spend-control.ts +++ b/src/spend-control.ts @@ -24,11 +24,74 @@ const DAY_MS = 24 * HOUR_MS; export type SpendWindow = "perRequest" | "hourly" | "daily" | "session"; +/** + * Counterparty/network/asset allow-or-deny lists. Default-off: a list only + * takes effect once configured via setPolicy(). `allowedPayees`/`blockedPayees` + * are both supported (block always wins if both are set); network and asset + * are allowlist-only, matching what a caller can realistically enumerate. + */ +export type PolicyList = "allowedPayees" | "blockedPayees" | "allowedNetworks" | "allowedAssets"; + +/** Base mainnet, as carried on x402 `selectedRequirements.network`. */ +export const CAIP2_BASE = "eip155:8453"; +/** Solana mainnet genesis, as carried on x402 `selectedRequirements.network`. */ +export const CAIP2_SOLANA_MAINNET = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d"; + +const POLICY_LISTS: readonly PolicyList[] = [ + "allowedPayees", + "blockedPayees", + "allowedNetworks", + "allowedAssets", +]; +const PAYEE_LISTS = ["allowedPayees", "blockedPayees"] as const; +const EVM_ADDRESS = /^0x[0-9a-fA-F]{40}$/; + +/** Lowercase a 20-byte EVM address; leave Solana base58 and other strings alone. */ +export function normalizePayee(value: string): string { + return EVM_ADDRESS.test(value) ? value.toLowerCase() : value; +} + +function isPolicyList(value: string): value is PolicyList { + return (POLICY_LISTS as readonly string[]).includes(value); +} + export interface SpendLimits { perRequest?: number; hourly?: number; daily?: number; session?: number; + allowedPayees?: string[]; + blockedPayees?: string[]; + /** + * CAIP-2 identifiers matching x402 `selectedRequirements.network` + * (e.g. `eip155:8453`, `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d`). + * Nicknames such as `base` or `solana` do not match and fail closed. + */ + allowedNetworks?: string[]; + allowedAssets?: string[]; +} + +/** Defensive copy: the four policy fields are arrays, so a shallow `{...limits}` still shares them by reference. */ +function cloneLimits(limits: SpendLimits): SpendLimits { + const clone: SpendLimits = { ...limits }; + for (const key of POLICY_LISTS) { + const val = limits[key]; + if (val !== undefined) { + clone[key] = [...val]; + } + } + return clone; +} + +/** + * Counterparty details for a pending payment, passed to check() alongside + * the estimated cost. EVM `payTo` values matching `0x` + 40 hex are compared + * case-insensitively; anything else (including Solana base58) is exact-match. + */ +export interface CounterpartyInfo { + payTo?: string; + network?: string; + asset?: string; } export interface SpendRecord { @@ -56,6 +119,7 @@ export interface SpendingStatus { export interface CheckResult { allowed: boolean; blockedBy?: SpendWindow; + blockedByPolicy?: PolicyList; remaining?: number; reason?: string; resetIn?: number; @@ -87,6 +151,22 @@ export class FileSpendControlStorage implements SpendControlStorage { limits[key] = val; } } + for (const key of POLICY_LISTS) { + if (!Object.prototype.hasOwnProperty.call(rawLimits, key)) continue; + const val = rawLimits[key]; + if ( + !Array.isArray(val) || + val.length === 0 || + val.some((v) => typeof v !== "string" || v.length === 0) + ) { + throw new Error( + `[ClawRouter] refusing to load spending.json: ${key} is malformed; a corrupted policy file must not widen what the agent may pay`, + ); + } + limits[key] = PAYEE_LISTS.includes(key as (typeof PAYEE_LISTS)[number]) + ? val.map(normalizePayee) + : [...val]; + } const history: SpendRecord[] = []; if (Array.isArray(rawHistory)) { @@ -111,6 +191,9 @@ export class FileSpendControlStorage implements SpendControlStorage { return { limits, history }; } } catch (err) { + if (err instanceof Error && err.message.includes("refusing to load spending.json")) { + throw err; + } console.error(`[ClawRouter] Failed to load spending data, starting fresh: ${err}`); } return null; @@ -136,7 +219,7 @@ export class InMemorySpendControlStorage implements SpendControlStorage { load(): { limits: SpendLimits; history: SpendRecord[] } | null { return this.data ? { - limits: { ...this.data.limits }, + limits: cloneLimits(this.data.limits), history: this.data.history.map((r) => ({ ...r })), } : null; @@ -144,7 +227,7 @@ export class InMemorySpendControlStorage implements SpendControlStorage { save(data: { limits: SpendLimits; history: SpendRecord[] }): void { this.data = { - limits: { ...data.limits }, + limits: cloneLimits(data.limits), history: data.history.map((r) => ({ ...r })), }; } @@ -182,11 +265,102 @@ export class SpendControl { this.save(); } + setPolicy(list: PolicyList, values: string[]): void { + if (!isPolicyList(list)) { + throw new Error(`Unknown policy list: ${String(list)}`); + } + if ( + !Array.isArray(values) || + values.length === 0 || + values.some((v) => typeof v !== "string" || v.length === 0) + ) { + throw new Error("Policy list must be a non-empty array of non-empty strings"); + } + this.limits[list] = PAYEE_LISTS.includes(list as (typeof PAYEE_LISTS)[number]) + ? values.map(normalizePayee) + : [...values]; + this.save(); + } + + clearPolicy(list: PolicyList): void { + if (!isPolicyList(list)) { + throw new Error(`Unknown policy list: ${String(list)}`); + } + delete this.limits[list]; + this.save(); + } + getLimits(): SpendLimits { - return { ...this.limits }; + return cloneLimits(this.limits); } - check(estimatedCost: number): CheckResult { + check(estimatedCost: number, counterparty?: CounterpartyInfo): CheckResult { + const payeePolicySet = + (this.limits.blockedPayees && this.limits.blockedPayees.length > 0) || + (this.limits.allowedPayees && this.limits.allowedPayees.length > 0); + if (payeePolicySet) { + if (counterparty?.payTo === undefined) { + return { + allowed: false, + blockedByPolicy: this.limits.blockedPayees?.length ? "blockedPayees" : "allowedPayees", + reason: "Payee policy is configured but no payTo was provided to check()", + }; + } + const payTo = normalizePayee(counterparty.payTo); + if (this.limits.blockedPayees?.includes(payTo)) { + return { + allowed: false, + blockedByPolicy: "blockedPayees", + reason: `Payee is blocked by policy: ${counterparty.payTo}`, + }; + } + if ( + this.limits.allowedPayees && + this.limits.allowedPayees.length > 0 && + !this.limits.allowedPayees.includes(payTo) + ) { + return { + allowed: false, + blockedByPolicy: "allowedPayees", + reason: `Payee is not in the configured allowlist: ${counterparty.payTo}`, + }; + } + } + + if (this.limits.allowedNetworks && this.limits.allowedNetworks.length > 0) { + if (counterparty?.network === undefined) { + return { + allowed: false, + blockedByPolicy: "allowedNetworks", + reason: "Network policy is configured but no network was provided to check()", + }; + } + if (!this.limits.allowedNetworks.includes(counterparty.network)) { + return { + allowed: false, + blockedByPolicy: "allowedNetworks", + reason: `Network is not in the configured allowlist: ${counterparty.network}`, + }; + } + } + + if (this.limits.allowedAssets && this.limits.allowedAssets.length > 0) { + if (counterparty?.asset === undefined) { + return { + allowed: false, + blockedByPolicy: "allowedAssets", + reason: "Asset policy is configured but no asset was provided to check()", + }; + } + if (!this.limits.allowedAssets.includes(counterparty.asset)) { + return { + allowed: false, + blockedByPolicy: "allowedAssets", + reason: `Asset is not in the configured allowlist: ${counterparty.asset}`, + }; + } + } + const now = this.now(); if (this.limits.perRequest !== undefined) { @@ -300,7 +474,7 @@ export class SpendControl { const dailySpent = this.getSpendingInWindow(now - DAY_MS, now); return { - limits: { ...this.limits }, + limits: cloneLimits(this.limits), spending: { hourly: hourlySpent, daily: dailySpent, @@ -332,7 +506,7 @@ export class SpendControl { private save(): void { this.storage.save({ - limits: { ...this.limits }, + limits: cloneLimits(this.limits), history: [...this.history], }); } @@ -340,13 +514,64 @@ export class SpendControl { private load(): void { const data = this.storage.load(); if (data) { - this.limits = data.limits; + this.limits = cloneLimits(data.limits); this.history = data.history; this.cleanup(); } } } +export type SpendPolicyAbort = { abort: true; reason: string }; + +/** + * Return an x402 `onBeforePaymentCreation` abort when policy or amount + * windows refuse. Aggregate-window amounts are reserved synchronously before + * the scheme signer runs so concurrent requests cannot all pass against the + * same remaining budget. The conservative reservation remains if a later + * signer or transport step fails. + */ +export function abortIfSpendPolicyBlocks( + control: SpendControl, + selected: { payTo?: string; network?: string; asset?: string; amount?: string }, +): SpendPolicyAbort | undefined { + const micros = Number.parseInt(selected.amount ?? "0", 10); + const estimatedCost = Number.isFinite(micros) ? micros / 1_000_000 : 0; + const result = control.check(estimatedCost, { + payTo: selected.payTo, + network: selected.network, + asset: selected.asset, + }); + if (!result.allowed) { + return { abort: true, reason: result.reason ?? "blocked by spend policy" }; + } + const limits = control.getLimits(); + if (limits.hourly !== undefined || limits.daily !== undefined || limits.session !== undefined) { + control.record(estimatedCost, { action: "x402 pre-sign reservation" }); + } + return undefined; +} + +/** Register the fail-closed spend-policy hook on an x402 client. */ +export function registerSpendPolicyHook( + x402: { + onBeforePaymentCreation( + hook: (ctx: { + selectedRequirements: { + payTo?: string; + network?: string; + asset?: string; + amount?: string; + }; + }) => Promise, + ): unknown; + }, + control: SpendControl, +): void { + x402.onBeforePaymentCreation(async (ctx) => + abortIfSpendPolicyBlocks(control, ctx.selectedRequirements), + ); +} + export function formatDuration(seconds: number): string { if (seconds < 60) { return `${seconds}s`;