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
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2451,7 +2451,9 @@ export {
} from "./spend-control.js";
export type {
SpendWindow,
PolicyList,
SpendLimits,
CounterpartyInfo,
SpendRecord,
SpendingStatus,
CheckResult,
Expand Down
237 changes: 236 additions & 1 deletion src/spend-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
* SpendControl tests — limits, recording, window expiry, persistence.
*/

import { describe, it, expect } from "vitest";
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 { SpendControl, InMemorySpendControlStorage, formatDuration } from "./spend-control.js";

function createControl(nowMs = Date.now()) {
Expand Down Expand Up @@ -236,6 +239,238 @@ 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("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", ["base"]);
expect(control.check(0.01, { network: "base" }).allowed).toBe(true);
});

it("blocks a network not in the allowlist", () => {
const { control } = createControl();
control.setPolicy("allowedNetworks", ["base"]);
const result = control.check(0.01, { network: "solana" });
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", ["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: ["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(["base"]);
expect(loaded?.limits.allowedAssets).toEqual(["USDC"]);
});

it("drops malformed policy entries instead of throwing", 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: [] }),
);

const loaded = storage.load();
expect(loaded?.limits.allowedPayees).toBeUndefined();
});
});

describe("formatDuration", () => {
it("formats seconds", () => {
expect(formatDuration(30)).toBe("30s");
Expand Down
Loading