diff --git a/docs/configuration.md b/docs/configuration.md index 7970b3c0..2320cd3b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -12,6 +12,7 @@ Complete reference for ClawRouter configuration options. - [Routing Configuration](#routing-configuration) - [Tier Overrides](#tier-overrides) - [Scoring Weights](#scoring-weights) +- [Spend Control & Counterparty Policy](#spend-control--counterparty-policy) - [Testing Configuration](#testing-configuration) --- @@ -540,6 +541,86 @@ routing: --- +## Spend Control & Counterparty Policy + +Amount caps bound **how much** the agent may pay. Counterparty policy bounds +**whom** it may pay, and on which network and asset. Both are evaluated before +the wallet signs anything: a refusal aborts the payment at the x402 pre-sign +hook, so no authorization is ever produced. + +Everything here is **off by default**. An unconfigured list is not consulted. + +State lives in `~/.openclaw/blockrun/spending.json` (mode 0600), read once at +proxy startup — **edit it, then restart the proxy** for changes to take effect. + +```json +{ + "limits": { + "perRequest": 0.05, + "hourly": 2.0, + "daily": 20.0, + "session": 5.0, + "allowedPayees": ["0x1111111111111111111111111111111111111111"], + "blockedPayees": ["0x2222222222222222222222222222222222222222"], + "allowedNetworks": ["eip155:8453"], + "allowedAssets": ["0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"] + }, + "history": [] +} +``` + +| Field | Meaning | +| --------------------------------------------- | ---------------------------------------------------------------------------------- | +| `perRequest` / `hourly` / `daily` / `session` | USD caps. Rolling 1h and 24h windows; session resets on restart. | +| `allowedPayees` | Only these `payTo` addresses may be paid. | +| `blockedPayees` | These may never be paid. **Wins over `allowedPayees`** when an address is on both. | +| `allowedNetworks` | CAIP-2 ids only. | +| `allowedAssets` | Token contract addresses of the asset being paid in. | + +**Networks are CAIP-2, not nicknames.** `base` does not match `eip155:8453` +and fails closed. Use `eip155:8453` for Base and +`solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d` for Solana mainnet; both +are exported as `CAIP2_BASE` and `CAIP2_SOLANA_MAINNET`. + +EVM addresses in `allowedPayees`, `blockedPayees` and `allowedAssets` are +compared case-insensitively, so checksummed and lowercase forms both match. +Solana base58 addresses are case-sensitive and compared exactly. + +### Fail-closed behavior + +- A configured list with **no matching value on the payment** refuses, rather + than skipping the check. +- A payment quote whose amount is not a canonical decimal integer refuses + whenever an amount cap is set. (`parseInt` and the signer's `BigInt` disagree + on `0x`-style values, so an unparseable quote is never treated as $0.) +- A **malformed** policy list in `spending.json` refuses every paid request + until the file is repaired. The proxy still starts and free models keep + working; the error names the offending field. An empty array (`[]`) is not + malformed — it means "not configured", and is how you clear a list by hand. + +### Programmatic use + +```ts +import { SpendControl, registerSpendPolicyHook, CAIP2_BASE } from "@blockrun/clawrouter"; + +const control = new SpendControl(); +control.setLimit("daily", 20); +control.setPolicy("allowedNetworks", [CAIP2_BASE]); + +// startProxy does this for you; only needed on your own x402 client. +registerSpendPolicyHook(x402, control); +``` + +A refusal reaches the caller as HTTP 403 with +`{"error": {"type": "spend_policy_denied", ...}}`. It is deliberately **not** +retried against other models: a policy denial is a decision, not an outage. + +**Scope:** this governs payments made by the proxy. Local tools that sign with +the same wallet outside the proxy's x402 client (Polymarket funding and order +placement, `clawrouter doctor`'s probe) are not covered. + +--- + ## Testing Configuration ### Dry Run (No Payments) diff --git a/src/index.ts b/src/index.ts index c93a6813..a919ffa0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2448,10 +2448,17 @@ export { FileSpendControlStorage, InMemorySpendControlStorage, formatDuration, + registerSpendPolicyHook, + SpendPolicyError, + MalformedSpendPolicyError, + CAIP2_BASE, + CAIP2_SOLANA_MAINNET, } from "./spend-control.js"; export type { SpendWindow, + PolicyList, SpendLimits, + CounterpartyInfo, SpendRecord, SpendingStatus, CheckResult, diff --git a/src/payment-preauth.ts b/src/payment-preauth.ts index 7599f143..f401f9fc 100644 --- a/src/payment-preauth.ts +++ b/src/payment-preauth.ts @@ -25,6 +25,7 @@ import type { x402Client } from "@x402/fetch"; import { x402HTTPClient } from "@x402/fetch"; import { resolveMaxTokens } from "./max-tokens.js"; +import { SpendPolicyError } from "./spend-control.js"; type PaymentRequired = Parameters["createPaymentPayload"]>[0]; @@ -127,9 +128,15 @@ export function createPayFetchWithPreAuth( // The rejection 402 is NOT a reusable challenge, so drop it and fall // through to a clean, un-paid request that yields a fresh challenge. cache.delete(cacheKey); - } catch { - // Pre-auth signing failed — invalidate and fall through. + } catch (err) { cache.delete(cacheKey); + // A spend-policy refusal is deterministic: falling through would sign + // the same blocked payment again on the fresh-challenge path, costing + // an extra unpaid upstream round trip to reach the identical denial. + if (err instanceof SpendPolicyError) { + throw err; + } + // Pre-auth signing failed — invalidate and fall through. } } diff --git a/src/proxy.spend-policy.test.ts b/src/proxy.spend-policy.test.ts new file mode 100644 index 00000000..225c8de5 --- /dev/null +++ b/src/proxy.spend-policy.test.ts @@ -0,0 +1,116 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { generatePrivateKey } from "viem/accounts"; + +import { startProxy, type ProxyHandle } from "./proxy.js"; +import { SpendControl, InMemorySpendControlStorage, CAIP2_BASE } from "./spend-control.js"; + +/** + * Pins the enforcement wiring itself, not just the helper. + * + * `registerSpendPolicyHook` had full unit coverage against a hand-built + * x402Client while nothing asserted that `startProxy` actually registers it — + * deleting that one line from proxy.ts left every test green and shipped a + * spend policy that governed nothing. + * + * Also pins the classification: a policy refusal must reach the caller as a + * refusal. Treated as a retryable provider error it walks the whole paid + * fallback chain and then answers 200 from a free model, so the caller never + * learns their own policy blocked the payment. + */ +describe("startProxy enforces spend policy on the live payment path", () => { + const blockedPayee = "0xAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAa"; + let upstream: Server; + let proxy: ProxyHandle; + let control: SpendControl; + let unpaidHits = 0; + let paidHits = 0; + + beforeAll(async () => { + upstream = createServer((req: IncomingMessage, res: ServerResponse) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => { + if (req.headers["x-payment"]) { + // The signer ran and a payment was attached — the policy failed to stop it. + paidHits++; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ choices: [{ message: { content: "paid" } }] })); + return; + } + unpaidHits++; + // x402 v2 carries the challenge in the PAYMENT-REQUIRED header + // (base64 JSON); only v1 puts it in the body. + const challenge = { + x402Version: 2, + resource: { url: "http://127.0.0.1/v1/chat/completions" }, + accepts: [ + { + scheme: "exact", + network: CAIP2_BASE, + amount: "10000", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + payTo: blockedPayee, + maxTimeoutSeconds: 60, + extra: {}, + }, + ], + }; + res.writeHead(402, { + "Content-Type": "application/json", + "PAYMENT-REQUIRED": Buffer.from(JSON.stringify(challenge)).toString("base64"), + }); + res.end(JSON.stringify({ error: "payment required" })); + }); + }); + + await new Promise((resolve) => upstream.listen(0, "127.0.0.1", resolve)); + const addr = upstream.address() as AddressInfo; + + control = new SpendControl({ storage: new InMemorySpendControlStorage() }); + control.setPolicy("blockedPayees", [blockedPayee]); + + proxy = await startProxy({ + wallet: generatePrivateKey(), + apiBase: `http://127.0.0.1:${addr.port}`, + port: 0, + skipBalanceCheck: true, + spendControl: control, + }); + }, 20_000); + + afterAll(async () => { + await proxy?.close(); + upstream.closeAllConnections?.(); + await new Promise((resolve) => upstream.close(() => resolve())); + }); + + beforeEach(() => { + unpaidHits = 0; + paidHits = 0; + }); + + it("refuses to sign for a blocked payee and never attaches a payment", async () => { + const res = await fetch(`${proxy.baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "anthropic/claude-sonnet-4.6", + messages: [{ role: "user", content: "hello" }], + stream: false, + }), + }); + + const text = await res.text(); + + // The wallet never signed: upstream saw the unpaid probe and no retry + // carrying an X-PAYMENT header. + expect(paidHits).toBe(0); + expect(unpaidHits).toBeGreaterThan(0); + + // And the caller is told, rather than being handed a quiet free-model 200. + expect(res.status).not.toBe(200); + expect(text).toMatch(/blocked by policy|spend_policy_denied/i); + }, 30_000); +}); diff --git a/src/proxy.ts b/src/proxy.ts index d1cc5a06..5e217e5a 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, SpendPolicyError } 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) @@ -1373,6 +1374,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. @@ -2185,6 +2191,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 @@ -3722,6 +3730,21 @@ async function tryModelRequest( return { success: true, response }; } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); + if (err instanceof SpendPolicyError) { + // A local refusal, not an upstream fault. Retrying it walks every paid + // model in the chain (a wasted 402 round trip each) and then lands on a + // free model, returning HTTP 200 — so the caller never learns their + // spend policy blocked the payment. Stop the chain here and say so. + return { + success: false, + errorBody: JSON.stringify({ + error: { message: errorMsg, type: "spend_policy_denied", status: 403 }, + }), + errorStatus: 403, + isProviderError: false, + errorCategory: "payment_error", + }; + } return { success: false, errorBody: errorMsg, diff --git a/src/spend-control.test.ts b/src/spend-control.test.ts index 30fb5b7f..cc6aee69 100644 --- a/src/spend-control.test.ts +++ b/src/spend-control.test.ts @@ -2,8 +2,21 @@ * 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, + assertSpendPolicyAllows, + SpendPolicyError, + CAIP2_BASE, + CAIP2_SOLANA_MAINNET, +} from "./spend-control.js"; function createControl(nowMs = Date.now()) { let clock = nowMs; @@ -236,6 +249,537 @@ 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/); + }); + + it("treats an empty policy array as cleared, not corrupted", async () => { + // Hand-editing a list to [] is how an operator clears it. Refusing to load + // would take the proxy down over a legal edit. + 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: { blockedPayees: [], hourly: 1 }, history: [] }), + ); + + const loaded = storage.load(); + expect(loaded?.limits.blockedPayees).toBeUndefined(); + expect(loaded?.limits.hourly).toBe(1); + }); + + it("normalizes persisted checksummed payees on 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(); + const spendingFile = path.join(tmpHome, ".openclaw", "blockrun", "spending.json"); + fs.mkdirSync(path.dirname(spendingFile), { recursive: true }); + fs.writeFileSync( + spendingFile, + JSON.stringify({ + limits: { blockedPayees: ["0xAbcDef0123456789AbcDef0123456789AbcDef01"] }, + history: [], + }), + ); + + expect(storage.load()?.limits.blockedPayees).toEqual([ + "0xabcdef0123456789abcdef0123456789abcdef01", + ]); + }); + + it("recording spend does not overwrite a policy edit made on disk", async () => { + // The proxy loads limits once at startup. Writing its in-memory copy back + // on every payment would erase an operator's hand-edit seconds later. + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawrouter-spend-")); + process.env.HOME = tmpHome; + vi.resetModules(); + const mod = await import("./spend-control.js"); + const spendingFile = path.join(tmpHome, ".openclaw", "blockrun", "spending.json"); + fs.mkdirSync(path.dirname(spendingFile), { recursive: true }); + fs.writeFileSync(spendingFile, JSON.stringify({ limits: {}, history: [] })); + + const control = new mod.SpendControl({ storage: new mod.FileSpendControlStorage() }); + + // Operator blocks a payee while the proxy is already running. + fs.writeFileSync( + spendingFile, + JSON.stringify({ limits: { blockedPayees: ["0xdead"] }, history: [] }), + ); + + control.record(0.01, { action: "x402 payment" }); + + const onDisk = JSON.parse(fs.readFileSync(spendingFile, "utf8")); + expect(onDisk.limits.blockedPayees).toEqual(["0xdead"]); + expect(onDisk.history).toHaveLength(1); + }); +}); + +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); + }); + + it("only one of two concurrent payments clears the same remaining budget", 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: {} }; + }, + }); + + const payee = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const results = await Promise.allSettled([ + payment(client, "10000", payee), + payment(client, "10000", payee), + ]); + + expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1); + expect(signerCalls).toBe(1); + }); + + describe("amount parsing is fail-closed", () => { + // parseInt(v, 10) stops at the first non-decimal character while the EVM + // scheme signs BigInt(v), which accepts radix prefixes: + // parseInt("0x1DCD6500", 10) === 0 BigInt("0x1DCD6500") === 500000000n + // A gateway quoting hex would otherwise read as $0 against every cap and + // still get a $500 authorization signed. + const nonCanonical = ["0x1DCD6500", "0X10", "0b1010", "0o17", "1e9", "abc", "-1000", ""]; + + for (const amount of nonCanonical) { + it(`refuses to sign a quote of ${JSON.stringify(amount)} when a limit is set`, async () => { + let signerCalls = 0; + const control = new SpendControl({ storage: new InMemorySpendControlStorage() }); + control.setLimit("perRequest", 0.01); + 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, amount, "0xcccccccccccccccccccccccccccccccccccccccc"), + ).rejects.toThrow(/no usable amount/); + expect(signerCalls).toBe(0); + }); + } + + it("still signs a canonical decimal quote", async () => { + let signerCalls = 0; + const control = new SpendControl({ storage: new InMemorySpendControlStorage() }); + control.setLimit("perRequest", 0.02); + 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", "0xcccccccccccccccccccccccccccccccccccccccc"); + expect(signerCalls).toBe(1); + }); + + it("reads the v1 maxAmountRequired field, which carries no `amount`", () => { + const control = new SpendControl({ storage: new InMemorySpendControlStorage() }); + control.setLimit("perRequest", 0.005); + + // v1 quote for $0.01 — over the $0.005 cap, so it must be refused on + // amount rather than sail through as an unparseable $0. + expect(() => + assertSpendPolicyAllows(control, { + payTo: "0xcccccccccccccccccccccccccccccccccccccccc", + network: CAIP2_BASE, + maxAmountRequired: "10000", + }), + ).toThrow(/Per-request limit exceeded/); + }); + + it("allows an unparseable amount through when no amount window is configured", () => { + // Policy-only setups never compare an amount, so a missing quote is not + // a reason to refuse — the payee allowlist still decides. + const control = new SpendControl({ storage: new InMemorySpendControlStorage() }); + control.setPolicy("allowedPayees", ["0xcccccccccccccccccccccccccccccccccccccccc"]); + + expect(() => + assertSpendPolicyAllows(control, { + payTo: "0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + network: CAIP2_BASE, + }), + ).not.toThrow(); + }); + }); + + it("releases the reservation when the signer fails, instead of draining the window", async () => { + 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() { + throw new Error("signer boom"); + }, + }); + + await expect( + payment(client, "10000", "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + ).rejects.toThrow(/signer boom/); + + // Nothing was signed, so nothing was spent — a failed payment must not + // consume budget, or a burst of failures locks the window with no money moved. + expect(control.getSpending("hourly")).toBe(0); + expect(control.getHistory()).toHaveLength(0); + }); + + it("throws a typed SpendPolicyError so callers can tell refusal from an upstream fault", async () => { + const control = new SpendControl({ storage: new InMemorySpendControlStorage() }); + control.setPolicy("blockedPayees", [blocked]); + const client = new x402Client(); + registerSpendPolicyHook(client, control); + client.register(CAIP2_BASE, { + scheme: "exact", + async createPaymentPayload() { + return { x402Version: 2, payload: {} }; + }, + }); + + const err = await payment(client, "1000").catch((e: unknown) => e); + expect(err).toBeInstanceOf(SpendPolicyError); + expect((err as SpendPolicyError).blockedByPolicy).toBe("blockedPayees"); + }); +}); + describe("formatDuration", () => { it("formats seconds", () => { expect(formatDuration(30)).toBe("30s"); diff --git a/src/spend-control.ts b/src/spend-control.ts index 6402f080..064736a8 100644 --- a/src/spend-control.ts +++ b/src/spend-control.ts @@ -1,5 +1,5 @@ /** - * Spend Control - Time-windowed spending limits + * Spend Control - Time-windowed spending limits and counterparty policy * * Absorbed from @blockrun/clawwallet. Chain-agnostic (works for both EVM and Solana). * @@ -9,12 +9,15 @@ * - Daily limits (e.g., max $20.00 per day) * - Session limits (e.g., max $5.00 per session) * - Rolling windows (last 1h, last 24h) + * - Counterparty policy: payee allow/deny, network and asset allowlists + * - Fail-closed enforcement before the signer, via the x402 pre-sign hook * - Persistent storage (~/.openclaw/blockrun/spending.json) */ import * as fs from "node:fs"; import * as path from "node:path"; import { homedir } from "node:os"; +import type { x402Client } from "@x402/fetch"; import { readTextFileSync } from "./fs-read.js"; const WALLET_DIR = path.join(homedir(), ".openclaw", "blockrun"); @@ -24,11 +27,111 @@ 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", +]; +/** + * Lists whose entries are addresses. EVM addresses are case-insensitive hex, + * so a checksummed entry must match a lowercase one and vice versa. `asset` is + * a token contract address and belongs here too: on Base, USDC is quoted as + * `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913`, and an operator who configures + * the lowercase form would otherwise have every legitimate payment refused. + * `allowedNetworks` is deliberately absent — CAIP-2 ids are case-sensitive. + */ +const ADDRESS_LISTS = ["allowedPayees", "blockedPayees", "allowedAssets"] 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 isAddressList(list: PolicyList): boolean { + return (ADDRESS_LISTS as readonly string[]).includes(list); +} + +/** Normalize a policy list's entries for storage and comparison. */ +function normalizePolicyValues(list: PolicyList, values: readonly string[]): string[] { + return isAddressList(list) ? values.map(normalizePayee) : [...values]; +} + +/** Policy entries must be a non-empty array of non-empty strings. */ +function isValidPolicyValues(values: unknown): values is string[] { + return Array.isArray(values) && values.every((v) => typeof v === "string" && v.length > 0); +} + +function isPolicyList(value: string): value is PolicyList { + return (POLICY_LISTS as readonly string[]).includes(value); +} + +/** + * A policy list on disk is present but unusable. Thrown rather than swallowed: + * silently dropping a corrupted allow/deny list would widen what the agent may + * pay, which is the one direction this file must never fail in. Callers + * classify on `instanceof`, not on the message text. + */ +export class MalformedSpendPolicyError extends Error { + constructor(key: string) { + super( + `[ClawRouter] refusing to load spending.json: ${key} is malformed; a corrupted policy file must not widen what the agent may pay`, + ); + this.name = "MalformedSpendPolicyError"; + } +} + 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 +159,7 @@ export interface SpendingStatus { export interface CheckResult { allowed: boolean; blockedBy?: SpendWindow; + blockedByPolicy?: PolicyList; remaining?: number; reason?: string; resetIn?: number; @@ -64,6 +168,12 @@ export interface CheckResult { export interface SpendControlStorage { load(): { limits: SpendLimits; history: SpendRecord[] } | null; save(data: { limits: SpendLimits; history: SpendRecord[] }): void; + /** + * Optional: persist history without touching stored limits. Implement it to + * keep recorded spend from overwriting an operator's policy edits. Falls + * back to save() when absent. + */ + saveHistory?(history: SpendRecord[]): void; } export class FileSpendControlStorage implements SpendControlStorage { @@ -87,6 +197,18 @@ 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 (!isValidPolicyValues(val)) { + throw new MalformedSpendPolicyError(key); + } + // An empty array is how an operator clears a list by hand. Treat it + // as "not configured" rather than corruption — refusing to start + // over an empty array would brick the proxy on a legal edit. + if (val.length === 0) continue; + limits[key] = normalizePolicyValues(key, val); + } const history: SpendRecord[] = []; if (Array.isArray(rawHistory)) { @@ -111,7 +233,15 @@ export class FileSpendControlStorage implements SpendControlStorage { return { limits, history }; } } catch (err) { - console.error(`[ClawRouter] Failed to load spending data, starting fresh: ${err}`); + if (err instanceof MalformedSpendPolicyError) { + throw err; + } + // A torn or unparseable file loses history, which is safe. It must not + // also silently drop configured policy lists — but at this point we + // cannot tell whether any were configured, so say so loudly. + console.error( + `[ClawRouter] Failed to load spending data, starting fresh (any configured spend policy is NOT in effect until this file is repaired): ${err}`, + ); } return null; } @@ -121,13 +251,35 @@ export class FileSpendControlStorage implements SpendControlStorage { if (!fs.existsSync(WALLET_DIR)) { fs.mkdirSync(WALLET_DIR, { recursive: true, mode: 0o700 }); } - fs.writeFileSync(this.spendingFile, JSON.stringify(data, null, 2), { - mode: 0o600, - }); + // Write-then-rename: a crash mid-write must not leave truncated JSON. + // Torn JSON parses as a failure, which drops configured policy lists on + // the next start — fail-open on exactly the file that must not do that. + const tmp = `${this.spendingFile}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 }); + fs.renameSync(tmp, this.spendingFile); } catch (err) { console.error(`[ClawRouter] Failed to save spending data: ${err}`); } } + + /** + * Persist history while leaving the stored limits exactly as they are on + * disk. Recording spend must not rewrite policy: the proxy reads limits once + * at startup, so writing its in-memory copy back on every payment would + * erase an operator's hand-edit to spending.json seconds after they made it. + */ + saveHistory(history: SpendRecord[]): void { + let storedLimits: SpendLimits = {}; + try { + const current = this.load(); + if (current) storedLimits = current.limits; + } catch { + // A malformed policy list on disk: leave the file alone rather than + // overwrite it with a version that drops what we could not parse. + return; + } + this.save({ limits: storedLimits, history }); + } } export class InMemorySpendControlStorage implements SpendControlStorage { @@ -136,7 +288,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 +296,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 })), }; } @@ -155,11 +307,24 @@ export interface SpendControlOptions { now?: () => number; } +/** + * How long an unsettled pre-sign reservation holds budget. Longer than any + * payment round trip, short enough that a process killed mid-payment does not + * leave the window shut for the rest of the hour. + */ +const RESERVATION_TTL_MS = 2 * 60 * 1000; + export class SpendControl { private limits: SpendLimits = {}; private history: SpendRecord[] = []; private sessionSpent: number = 0; private sessionCalls: number = 0; + private pending = new Map(); + private reservationSeq = 0; + /** Limits we loaded and have not changed; history-only saves must not clobber operator edits. */ + private limitsDirty = false; + /** Set when spending.json held an unusable policy list: refuse every payment. */ + private policyFileBroken?: string; private readonly storage: SpendControlStorage; private readonly now: () => number; @@ -174,19 +339,114 @@ export class SpendControl { throw new Error("Limit must be a finite positive number"); } this.limits[window] = amount; + this.limitsDirty = true; this.save(); } clearLimit(window: SpendWindow): void { delete this.limits[window]; + this.limitsDirty = true; + this.save(); + } + + setPolicy(list: PolicyList, values: string[]): void { + if (!isPolicyList(list)) { + throw new Error(`Unknown policy list: ${String(list)}`); + } + if (!isValidPolicyValues(values) || values.length === 0) { + throw new Error("Policy list must be a non-empty array of non-empty strings"); + } + this.limits[list] = normalizePolicyValues(list, values); + this.limitsDirty = true; + this.save(); + } + + clearPolicy(list: PolicyList): void { + if (!isPolicyList(list)) { + throw new Error(`Unknown policy list: ${String(list)}`); + } + delete this.limits[list]; + this.limitsDirty = true; this.save(); } getLimits(): SpendLimits { - return { ...this.limits }; + return cloneLimits(this.limits); } - check(estimatedCost: number): CheckResult { + check(estimatedCost: number, counterparty?: CounterpartyInfo): CheckResult { + if (this.policyFileBroken !== undefined) { + return { + allowed: false, + reason: `Spend policy is unreadable, refusing all payments: ${this.policyFileBroken}`, + }; + } + 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(normalizePayee(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) { @@ -237,13 +497,14 @@ export class SpendControl { } if (this.limits.session !== undefined) { - const remaining = this.limits.session - this.sessionSpent; + const sessionSpent = this.sessionSpent + this.pendingTotal(); + const remaining = this.limits.session - sessionSpent; if (estimatedCost > remaining) { return { allowed: false, blockedBy: "session", remaining, - reason: `Session limit exceeded: $${(this.sessionSpent + estimatedCost).toFixed(2)} > $${this.limits.session.toFixed(2)} max`, + reason: `Session limit exceeded: $${(sessionSpent + estimatedCost).toFixed(2)} > $${this.limits.session.toFixed(2)} max`, }; } } @@ -270,10 +531,83 @@ export class SpendControl { this.save(); } + /** True when any window that this module can compare an amount against is set. */ + hasAmountLimits(): boolean { + return ( + this.limits.perRequest !== undefined || + this.limits.hourly !== undefined || + this.limits.daily !== undefined || + this.limits.session !== undefined + ); + } + + /** True when a window spans more than one request, so reservations matter. */ + hasAggregateLimits(): boolean { + return ( + this.limits.hourly !== undefined || + this.limits.daily !== undefined || + this.limits.session !== undefined + ); + } + + /** + * Hold `amount` against the aggregate windows before a payment is signed. + * + * Reservations live in memory only and are never persisted: an unsettled + * reservation is not spend, and writing it to disk is what made a failed + * signer permanently consume budget. They expire on their own so a caller + * that never settles or releases (process killed mid-payment, a transport + * that hangs past the payment timeout) cannot wedge the window shut. + */ + reserve(amount: number): string { + if (!Number.isFinite(amount) || amount < 0) { + throw new Error("Reservation amount must be a non-negative finite number"); + } + const id = `${this.now()}-${(this.reservationSeq += 1)}`; + this.pending.set(id, { amount, expiresAt: this.now() + RESERVATION_TTL_MS }); + return id; + } + + /** Convert a reservation into recorded spend (the payment was signed). */ + settleReservation(id: string, metadata?: { model?: string; action?: string }): void { + const held = this.pending.get(id); + if (!held) return; // already released, settled, or expired + this.pending.delete(id); + this.record(held.amount, metadata); + } + + /** Drop a reservation without recording spend (the payment was never signed). */ + releaseReservation(id: string): void { + this.pending.delete(id); + } + + /** Total currently held but not yet settled. */ + private pendingTotal(): number { + this.expireReservations(); + let total = 0; + for (const held of this.pending.values()) { + total += held.amount; + } + return total; + } + + private expireReservations(): void { + const now = this.now(); + for (const [id, held] of this.pending) { + if (held.expiresAt <= now) { + this.pending.delete(id); + } + } + } + private getSpendingInWindow(from: number, to: number): number { - return this.history + const recorded = this.history .filter((r) => r.timestamp >= from && r.timestamp <= to) .reduce((sum, r) => sum + r.amount, 0); + // In-flight reservations count against every window they could land in. + // Both the hourly and daily windows end at `now`, so a live hold belongs + // to each of them. + return recorded + (to >= this.now() ? this.pendingTotal() : 0); } getSpending(window: "hourly" | "daily" | "session"): number { @@ -284,7 +618,7 @@ export class SpendControl { case "daily": return this.getSpendingInWindow(now - DAY_MS, now); case "session": - return this.sessionSpent; + return this.sessionSpent + this.pendingTotal(); } } @@ -300,7 +634,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, @@ -309,7 +643,10 @@ export class SpendControl { remaining: { hourly: this.limits.hourly !== undefined ? this.limits.hourly - hourlySpent : null, daily: this.limits.daily !== undefined ? this.limits.daily - dailySpent : null, - session: this.limits.session !== undefined ? this.limits.session - this.sessionSpent : null, + session: + this.limits.session !== undefined + ? this.limits.session - (this.sessionSpent + this.pendingTotal()) + : null, }, calls: this.sessionCalls, }; @@ -331,22 +668,195 @@ export class SpendControl { } private save(): void { + if (this.policyFileBroken !== undefined) { + return; // never rewrite a file we could not fully parse + } + if (!this.limitsDirty && this.storage.saveHistory) { + this.storage.saveHistory([...this.history]); + return; + } this.storage.save({ - limits: { ...this.limits }, + limits: cloneLimits(this.limits), history: [...this.history], }); } private load(): void { - const data = this.storage.load(); + let data: { limits: SpendLimits; history: SpendRecord[] } | null; + try { + data = this.storage.load(); + } catch (err) { + if (!(err instanceof MalformedSpendPolicyError)) throw err; + // Refuse every paid request rather than either (a) running with the + // policy silently dropped, or (b) throwing out of the constructor and + // taking the whole proxy down — which would kill free models too, for a + // file that only governs payments. + this.policyFileBroken = err.message; + console.error(`[ClawRouter] ${err.message}`); + console.error( + "[ClawRouter] All paid requests will be refused until spending.json is repaired. Free models are unaffected.", + ); + return; + } if (data) { - this.limits = data.limits; + this.limits = cloneLimits(data.limits); this.history = data.history; this.cleanup(); } } } +export type SpendPolicyAbort = { abort: true; reason: string }; + +/** + * Thrown from the pre-sign hook when policy or an amount window refuses. + * + * A deliberate refusal must never be mistaken for a transient upstream fault: + * the proxy's fallback loop retries provider errors across every paid model + * and then silently lands on a free one, which would hide the denial from the + * caller entirely. Callers classify on `instanceof` (see `proxy.ts`), so the + * message text is free to change. It keeps the `Payment creation aborted:` + * prefix that `@x402/core` uses for its own aborts so existing log greps and + * error matchers still see a familiar string. + */ +export class SpendPolicyError extends Error { + readonly blockedBy?: SpendWindow; + readonly blockedByPolicy?: PolicyList; + + constructor(reason: string, blocked?: { blockedBy?: SpendWindow; blockedByPolicy?: PolicyList }) { + super(`Payment creation aborted: ${reason}`); + this.name = "SpendPolicyError"; + this.blockedBy = blocked?.blockedBy; + this.blockedByPolicy = blocked?.blockedByPolicy; + } +} + +/** Server-quoted amounts are canonical decimal micro-USDC strings, nothing else. */ +const CANONICAL_AMOUNT = /^\d+$/; + +/** + * Read the payment amount the signer is about to authorize, in USD. + * + * `Number.parseInt(v, 10)` is NOT safe here. `@x402/core` validates `amount` + * as a non-empty string with no digit-format check, while the EVM exact scheme + * signs `BigInt(value)` off the same raw string — and the two disagree on every + * radix prefix `BigInt` accepts: + * + * parseInt("0x1DCD6500", 10) === 0 BigInt("0x1DCD6500") === 500000000n + * + * A gateway quoting hex therefore reads as $0.000000 against every cap while + * the wallet authorizes the full amount. Returns undefined for anything that + * is not a canonical decimal integer so the caller can fail closed. + * + * x402 v1 carries the cost in `maxAmountRequired`; v2 renamed it to `amount`. + */ +function parseQuotedAmountUsd(selected: { + amount?: string; + maxAmountRequired?: string; +}): number | undefined { + const raw = selected.amount ?? selected.maxAmountRequired; + if (typeof raw !== "string" || !CANONICAL_AMOUNT.test(raw)) { + return undefined; + } + const micros = Number(raw); + if (!Number.isSafeInteger(micros)) { + return undefined; + } + return micros / 1_000_000; +} + +/** Requirements as they reach the pre-sign hook (v2 `amount`, v1 `maxAmountRequired`). */ +export type QuotedRequirements = { + payTo?: string; + network?: string; + asset?: string; + amount?: string; + maxAmountRequired?: string; +}; + +/** + * Evaluate policy and amount windows for a pending payment. + * + * Returns a reservation id when the payment may proceed and an aggregate + * window is configured; the caller must settle or release it. Throws + * `SpendPolicyError` when the payment must not be signed. + */ +export function assertSpendPolicyAllows( + control: SpendControl, + selected: QuotedRequirements, +): string | undefined { + const quoted = parseQuotedAmountUsd(selected); + if (quoted === undefined && control.hasAmountLimits()) { + // Fail closed: we cannot compare an amount we could not parse against a + // cap the operator configured. + throw new SpendPolicyError( + `Payment quote carries no usable amount (${JSON.stringify( + selected.amount ?? selected.maxAmountRequired, + )}); refusing to sign against a configured spend limit`, + ); + } + const estimatedCost = quoted ?? 0; + const result = control.check(estimatedCost, { + payTo: selected.payTo, + network: selected.network, + asset: selected.asset, + }); + if (!result.allowed) { + throw new SpendPolicyError(result.reason ?? "blocked by spend policy", { + blockedBy: result.blockedBy, + blockedByPolicy: result.blockedByPolicy, + }); + } + if (!control.hasAggregateLimits()) { + return undefined; + } + // Reserve synchronously — no await between check() and reserve() — so two + // concurrent payments cannot both clear the same remaining budget. + return control.reserve(estimatedCost); +} + +/** + * Register the fail-closed spend-policy hook on an x402 client. + * + * Reservations are keyed on the `selectedRequirements` object, which + * `@x402/core` passes by reference to the before / after / failure hooks of + * the same `createPaymentPayload` call, so concurrent payments never settle + * each other's reservation. + */ +export function registerSpendPolicyHook(x402: x402Client, control: SpendControl): void { + const reservations = new WeakMap(); + + x402.onBeforePaymentCreation(async (ctx) => { + const selected = ctx.selectedRequirements as unknown as QuotedRequirements; + const reservationId = assertSpendPolicyAllows(control, selected); + if (reservationId !== undefined) { + reservations.set(ctx.selectedRequirements as unknown as object, reservationId); + } + }); + + // Signed: the wallet has authorized this payment, so the reservation becomes + // real spend. Conservative by design — a payment that is signed but never + // settles upstream still counts against the window. + x402.onAfterPaymentCreation(async (ctx) => { + const key = ctx.selectedRequirements as unknown as object; + const id = reservations.get(key); + if (id !== undefined) { + reservations.delete(key); + control.settleReservation(id, { action: "x402 payment" }); + } + }); + + // Never signed: release, or the window drains on failures that cost nothing. + x402.onPaymentCreationFailure(async (ctx) => { + const key = ctx.selectedRequirements as unknown as object; + const id = reservations.get(key); + if (id !== undefined) { + reservations.delete(key); + control.releaseReservation(id); + } + }); +} + export function formatDuration(seconds: number): string { if (seconds < 60) { return `${seconds}s`;