Skip to content
Merged
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
81 changes: 81 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 9 additions & 2 deletions src/payment-preauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<InstanceType<typeof x402Client>["createPaymentPayload"]>[0];

Expand Down Expand Up @@ -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.
}
}

Expand Down
116 changes: 116 additions & 0 deletions src/proxy.spend-policy.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<void>((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);
});
23 changes: 23 additions & 0 deletions src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -2185,6 +2191,8 @@ export async function startProxy(options: ProxyOptions): Promise<ProxyHandle> {
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
Expand Down Expand Up @@ -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,
Expand Down
Loading