Before your agent can spend: a policy engine for agent wallets in 100 lines
Seven rules that sit between a model and the money: caps, an allowlist, approvals, idempotency, a velocity limit and an audit log. Plain TypeScript with no dependencies, a test for every rule, a red-team test, and adapters for stablecoins, mobile money and x402. Every output below is real.
Your agent pays suppliers. One morning it reads an invoice that someone has edited. Hidden in the notes is a line for the model: settle the balance to this new address, in 20 small transfers so nothing looks unusual. The model can't reliably tell that line from a real instruction. If the only thing between it and the wallet is a sentence in the system prompt, the money is gone.
Below, I build what belongs in that gap: a small policy engine every payment must pass. I test each rule, then run that exact attack against it.
The idea in plain words
An agent is a program where a language model chooses the next step, such as calling a tool. Give it a tool that moves money and it becomes part of what people call the agentic economy: software that acts, pays and gets paid for people. That only works if people can trust it with a wallet.
A policy engine is plain code that looks at a proposed payment and answers one of three things: allow, deny or needs approval (ask a person first). The model proposes, the code decides. The code never reads the chat, so a clever sentence can't change its mind.
The rules are the ones a careful finance clerk already follows: a limit per payment and per day, known payees only, a manager signs off big ones, no invoice paid twice, suspicion of sudden bursts, and a written reason for every decision.
Before you start
- Node.js 24. I used 24.18.0 with npm 11.16.0. Node runs
.tsfiles directly: type stripping is on by default since 23.6.0, with no warning since 24.3.0.node --experimental-strip-typesstill works, but neither the flag nortsxis needed. - The engine has no dependencies. Steps 1 to 5 need nothing from npm.
- For the payment adapters only (step 6), pinned:
viem2.57.3,@x402/core,@x402/fetchand@x402/evm2.28.0. For an optional type check:typescript7.0.2 and@types/node24.19.1. - No wallet, no keys, no money. Everything runs offline against fakes. One script makes a throwaway signing key in memory; it is never printed or saved and holds nothing.
Type stripping only removes types, so enum and constructor parameter properties won't run, and imports need the .ts extension. The code below follows both rules.
Build it, step by step
Step 1. Make the project
mkdir agent-policy
cd agent-policy
npm init -y
npm pkg set type=module
npm pkg set scripts.test="node --test"
Step 2. Decide how to hold money
Every amount in the engine is an integer count of the smallest unit: kobo for naira, cents for dollars, base units for a token. It is a JavaScript bigint, never a float. Two reasons, and this script shows both:
// money.ts: why the engine refuses floats, and how to convert at the edge.
let total = 0;
for (let i = 0; i < 10; i++) total += 0.1; // ten payments of 0.10
console.log("float total:", total, "equals 1?", total === 1);
const oneCusd = 10n ** 18n; // an 18-decimal token: 1.0 = 10^18 base units
console.log("safe as a Number?", Number.isSafeInteger(Number(oneCusd)));
console.log("1 base unit lost:", Number(oneCusd + 1n) === Number(oneCusd));
// Parse a decimal string (from a form, an API or a model) into minor units, or refuse.
export function toMinor(text: string, decimals: number): bigint {
const m = /^(\d+)(?:\.(\d+))?$/.exec(text.trim());
if (!m) throw new Error(`not a plain decimal amount: "${text}"`);
const frac = m[2] ?? "";
if (frac.length > decimals) throw new Error(`more than ${decimals} decimal places: "${text}"`);
return BigInt(m[1]) * 10n ** BigInt(decimals) + BigInt(frac.padEnd(decimals, "0") || "0");
}
console.log(toMinor("1500.50", 2)); // naira to kobo
console.log(toMinor("0.1", 6)); // USDC has 6 decimals
for (const bad of ["1,500", "-20", "1e3", "0.001"]) {
try { toMinor(bad, 2); } catch (e) { console.log("refused:", (e as Error).message); }
}
node money.ts
float total: 0.9999999999999999 equals 1? false
safe as a Number? false
1 base unit lost: true
150050n
100000n
refused: not a plain decimal amount: "1,500"
refused: not a plain decimal amount: "-20"
refused: not a plain decimal amount: "1e3"
refused: more than 2 decimal places: "0.001"
First, binary floats can't hold 0.1 exactly, so ten payments of 0.10 don't add up to 1, and a cap check on floats can be off by a hair. Second, a JavaScript number holds integers exactly only up to 253 − 1. A token with 18 decimals counts one whole token as 1018 base units, already past that limit, so even "integers" in a number silently lose units. bigint has neither problem, and viem uses it for token amounts too.
Convert at the edge, once: toMinor turns text from a model, form or API into minor units, or refuses rather than guessing.
Step 3. Write the engine
About 110 lines with comments, 90 without:
// policy.ts: decide allow, deny or needs_approval before any money moves.
// Amounts are bigint minor units (kobo, cents, token base units). Never floats.
export type Proposal = {
idempotencyKey: string; // one per invoice or task, e.g. "inv-2041"
to: string; // wallet address, phone number or merchant id
amount: bigint; // minor units: 150000n kobo is NGN 1,500.00
currency: string; // "NGN", or a token such as "USDC"
};
export type Policy = {
currency: string;
perTxCap: bigint; // largest single payment
dailyCap: bigint; // total over any rolling 24 hours
approvalAbove: bigint; // a person must approve anything larger
maxPerHour: number; // velocity: payments allowed per rolling hour
allowlist: ReadonlySet<string>;
};
export type Verdict = "allow" | "deny" | "needs_approval";
export type Decision = { verdict: Verdict; reason: string; receipt?: string };
export type AuditEntry = {
at: string; key: string; to: string; amount: string; currency: string; // amount in minor units
event: Verdict | "paid" | "pay_failed"; reason: string;
};
export type PayFn = (p: Proposal) => Promise<string>; // resolves to a receipt id
const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
export class PolicyEngine {
readonly audit: AuditEntry[] = [];
private spends: { at: number; amount: bigint }[] = [];
private usedKeys = new Set<string>();
private approvals = new Set<string>();
private policy: Policy;
private now: () => number;
constructor(policy: Policy, now: () => number = Date.now) {
this.policy = policy;
this.now = now;
}
// A person calls this after seeing the exact proposal. It covers that call once.
approve(p: Proposal): void {
this.approvals.add(fingerprint(p));
}
decide(p: Proposal): Decision {
const d = this.check(p);
if (d.verdict === "allow") {
// Reserve in the same synchronous step as the check, so nothing can slip in between.
this.usedKeys.add(p.idempotencyKey);
this.spends.push({ at: this.now(), amount: p.amount });
this.approvals.delete(fingerprint(p));
}
this.record(p, d.verdict, d.reason);
return d;
}
record(p: Proposal, event: AuditEntry["event"], reason: string): void {
this.audit.push({
at: new Date(this.now()).toISOString(), key: p.idempotencyKey, to: p.to,
amount: String(p.amount), currency: p.currency, event, reason,
});
}
private check(p: Proposal): Decision {
const P = this.policy;
if (typeof p.amount !== "bigint" || p.amount <= 0n) return deny("amount must be a whole number of minor units above 0");
if (p.currency !== P.currency) return deny(`currency ${p.currency} is not ${P.currency}`);
if (this.usedKeys.has(p.idempotencyKey)) return deny(`duplicate: ${p.idempotencyKey} was already paid`);
if (!P.allowlist.has(p.to)) return deny(`recipient ${p.to} is not on the allowlist`);
if (p.amount > P.perTxCap) return deny(`${p.amount} is over the per-transaction cap of ${P.perTxCap}`);
const t = this.now();
this.spends = this.spends.filter((s) => s.at > t - DAY); // forget anything older than 24 hours
const lastHour = this.spends.filter((s) => s.at > t - HOUR).length;
if (lastHour >= P.maxPerHour) return deny(`velocity: ${lastHour} payments in the last hour, limit ${P.maxPerHour}`);
const spent = this.spends.reduce((sum, s) => sum + s.amount, 0n);
if (spent + p.amount > P.dailyCap) return deny(`24-hour cap: ${spent} spent + ${p.amount} would pass ${P.dailyCap}`);
if (p.amount > P.approvalAbove && !this.approvals.has(fingerprint(p))) {
return { verdict: "needs_approval", reason: `${p.amount} is above the approval threshold of ${P.approvalAbove}` };
}
return { verdict: "allow", reason: "within policy" };
}
}
// Wrap any payment function: a stablecoin transfer, a mobile-money API, an x402 client.
export async function guardedPay(engine: PolicyEngine, p: Proposal, payFn: PayFn): Promise<Decision> {
const d = engine.decide(p);
if (d.verdict !== "allow") return d; // payFn is never called
try {
const receipt = await payFn(p);
engine.record(p, "paid", `receipt ${receipt}`);
return { ...d, receipt };
} catch (err) {
// Fail closed: the spend and the key stay reserved, because the money may have moved.
engine.record(p, "pay_failed", String(err));
throw err;
}
}
function deny(reason: string): Decision {
return { verdict: "deny", reason };
}
function fingerprint(p: Proposal): string {
return [p.idempotencyKey, p.to, p.amount, p.currency].join("|");
}
Three choices are worth noticing.
- Order. Absolute rules run first: bad amount, wrong currency, duplicate, unknown payee, over the cap. Approval runs last, so nobody is asked to approve something the policy would refuse anyway.
- Reserve on allow.
deciderecords the spend and the key in the same synchronous step as the check, so nothing runs between "the cap has room" and "the cap is used". - Approvals are bound and single-use. An approval is a fingerprint of key, payee, amount and currency. Approve ₦45,000 and the agent can't spend it as ₦49,000, or twice.
Step 4. Test every rule, then attack it
// policy.test.ts: one test per rule, plus the attack.
import { test } from "node:test";
import assert from "node:assert/strict";
import { PolicyEngine, guardedPay } from "./policy.ts";
import type { Policy, Proposal } from "./policy.ts";
const naira = (n: number) => BigInt(n) * 100n; // NGN 1 = 100 kobo
const HOUR = 60 * 60 * 1000;
const policy: Policy = {
currency: "NGN",
perTxCap: naira(50_000),
dailyCap: naira(100_000),
approvalAbove: naira(20_000),
maxPerHour: 5,
allowlist: new Set(["acct:greenfield-school", "acct:ada-rice-supplier", "acct:ikeja-electric"]),
};
// A fake clock, so the tests can move time forward.
function setup() {
let t = Date.UTC(2026, 9, 5, 9, 0, 0);
const engine = new PolicyEngine(policy, () => t);
return { engine, tick: (ms: number) => { t += ms; } };
}
let n = 0;
const pay = (to: string, amount: bigint, key = `inv-${++n}`): Proposal =>
({ idempotencyKey: key, to, amount, currency: "NGN" });
test("per-transaction cap", () => {
const { engine } = setup();
const d = engine.decide(pay("acct:ada-rice-supplier", naira(50_001)));
assert.equal(d.verdict, "deny");
assert.match(d.reason, /per-transaction cap/);
});
test("rolling 24-hour cap, which frees up as old payments age out", () => {
const { engine, tick } = setup();
for (let i = 0; i < 5; i++) {
assert.equal(engine.decide(pay("acct:ada-rice-supplier", naira(20_000))).verdict, "allow");
tick(2 * HOUR);
}
// NGN 100,000 spent between 09:00 and 17:00. It's now 19:00.
const over = engine.decide(pay("acct:ada-rice-supplier", naira(1)));
assert.equal(over.verdict, "deny");
assert.match(over.reason, /24-hour cap/);
tick(14 * HOUR + 1); // 09:00 next day: the first payment is now older than 24 hours
assert.equal(engine.decide(pay("acct:ada-rice-supplier", naira(20_000))).verdict, "allow");
});
test("recipient allowlist", () => {
const { engine } = setup();
const d = engine.decide(pay("acct:stranger", naira(500)));
assert.equal(d.verdict, "deny");
assert.match(d.reason, /not on the allowlist/);
});
test("human approval: bound to one exact proposal, used once", () => {
const { engine } = setup();
const fees = pay("acct:greenfield-school", naira(45_000), "fees-term1");
assert.equal(engine.decide(fees).verdict, "needs_approval");
engine.approve(fees);
const bigger = { ...fees, amount: naira(49_000) }; // the agent tries to stretch the approval
assert.equal(engine.decide(bigger).verdict, "needs_approval");
assert.equal(engine.decide(fees).verdict, "allow");
assert.equal(engine.decide(fees).verdict, "deny"); // approval used, and the key is spent
});
test("idempotency: the same invoice is never paid twice", () => {
const { engine } = setup();
const bill = pay("acct:ikeja-electric", naira(8_000), "ikedc-sept");
assert.equal(engine.decide(bill).verdict, "allow");
const again = engine.decide({ ...bill }); // the agent retries after a timeout
assert.equal(again.verdict, "deny");
assert.match(again.reason, /duplicate/);
});
test("velocity: at most 5 payments per rolling hour", () => {
const { engine, tick } = setup();
for (let i = 0; i < 5; i++) {
assert.equal(engine.decide(pay("acct:ada-rice-supplier", naira(1_000))).verdict, "allow");
tick(60_000);
}
const sixth = engine.decide(pay("acct:ada-rice-supplier", naira(1_000)));
assert.equal(sixth.verdict, "deny");
assert.match(sixth.reason, /velocity/);
tick(HOUR);
assert.equal(engine.decide(pay("acct:ada-rice-supplier", naira(1_000))).verdict, "allow");
});
test("amounts must be positive whole minor units", () => {
const { engine } = setup();
const negative = engine.decide(pay("acct:ada-rice-supplier", -naira(4_500)));
assert.equal(negative.verdict, "deny");
const float = engine.decide({ ...pay("acct:ada-rice-supplier", 0n), amount: 45.5 as unknown as bigint });
assert.equal(float.verdict, "deny");
});
test("every decision is in the audit log, with a reason", () => {
const { engine } = setup();
engine.decide(pay("acct:ikeja-electric", naira(5_000)));
engine.decide(pay("acct:stranger", naira(5_000)));
engine.decide(pay("acct:greenfield-school", naira(30_000)));
assert.deepEqual(engine.audit.map((e) => e.event), ["allow", "deny", "needs_approval"]);
assert.ok(engine.audit.every((e) => e.reason.length > 0));
});
test("guardedPay calls payFn only on allow, and fails closed", async () => {
const { engine } = setup();
let calls = 0;
const payFn = async () => { calls++; throw new Error("gateway timeout"); };
const bill = pay("acct:ikeja-electric", naira(8_000), "ikedc-oct");
await assert.rejects(guardedPay(engine, bill, payFn), /gateway timeout/);
// The money may have left. A blind retry must not pay a second time.
const retry = await guardedPay(engine, bill, payFn);
assert.equal(retry.verdict, "deny");
assert.equal(calls, 1);
});
test("attack: injected agent tries to pay an unknown address 20 times in small amounts", async () => {
const { engine, tick } = setup();
const sent: Proposal[] = [];
const payFn = async (p: Proposal) => { sent.push(p); return `rcpt-${sent.length}`; };
// A poisoned invoice told the agent to "settle the balance" in 20 small transfers,
// each under the approval threshold, each with a fresh key so idempotency can't help.
for (let i = 1; i <= 20; i++) {
await guardedPay(engine, pay("0x9f3cAttacker", naira(4_900), `inj-${i}`), payFn);
tick(90_000);
}
assert.equal(sent.length, 0);
assert.equal(engine.audit.filter((e) => e.event === "deny").length, 20);
// Worse case: the attacker swapped the bank details of a payee already on the allowlist.
for (let i = 1; i <= 20; i++) {
await guardedPay(engine, pay("acct:ada-rice-supplier", naira(4_900), `swap-${i}`), payFn);
tick(90_000);
}
// Velocity holds it to 5 an hour. The 20 tries span 30 minutes, so 5 get through.
const lost = sent.reduce((sum, p) => sum + p.amount, 0n);
assert.equal(sent.length, 5);
assert.equal(lost, naira(24_500));
assert.ok(lost <= policy.dailyCap);
});
The last test is the attack from the opening: 20 payments of ₦4,900 to an unknown address, each with a fresh key and under the approval threshold. The allowlist stops all 20. Then the nastier version, where the attacker swapped the bank details of a payee you already trust. The allowlist passes, and the velocity limit caps the damage at 5 payments, ₦24,500.
Step 5. Watch a morning of decisions
// demo.ts: one morning of an agent's payments, through the policy.
import { PolicyEngine, guardedPay } from "./policy.ts";
import type { Proposal } from "./policy.ts";
const naira = (n: number) => BigInt(n) * 100n;
let t = Date.UTC(2026, 9, 5, 8, 0, 0);
const engine = new PolicyEngine({
currency: "NGN",
perTxCap: naira(50_000),
dailyCap: naira(100_000),
approvalAbove: naira(20_000),
maxPerHour: 5,
allowlist: new Set(["acct:greenfield-school", "acct:ada-rice-supplier", "acct:ikeja-electric"]),
}, () => t);
// Stand-in for a real rail. Swap in a stablecoin transfer, mobile money or x402.
const fakePay = async (p: Proposal) => `rcpt-${p.idempotencyKey}`;
const p = (key: string, to: string, amount: number): Proposal =>
({ idempotencyKey: key, to, amount: naira(amount), currency: "NGN" });
const fees = p("fees-term1", "acct:greenfield-school", 45_000);
const steps: Proposal[] = [
p("ikedc-oct", "acct:ikeja-electric", 8_000),
p("ikedc-oct", "acct:ikeja-electric", 8_000), // retried after a timeout
p("rice-0412", "acct:ada-rice-supplier", 18_500),
fees,
p("refund-77", "0x9f3cAttacker", 4_900), // injected by a poisoned invoice
p("rice-0413", "acct:ada-rice-supplier", 60_000),
];
for (const s of steps) { await guardedPay(engine, s, fakePay); t += 5 * 60_000; }
engine.approve(fees); // a parent checks the bill and approves
await guardedPay(engine, fees, fakePay);
for (const e of engine.audit) {
console.log(e.at.slice(11, 16), e.event.padEnd(14), e.key.padEnd(11), (e.amount + " kobo").padEnd(13), e.reason);
}
Step 6. Wrap real payment rails
The engine doesn't care how money moves. Install the adapter packages:
npm i --save-exact viem@2.57.3 @x402/core@2.28.0 @x402/fetch@2.28.0 @x402/evm@2.28.0
npm i --save-exact -D typescript@7.0.2 @types/node@24.19.1
A PayFn takes a proposal and returns a receipt. Any rail you push money through fits that shape, and guardedPay(engine, proposal, payFn) wraps it:
// rails.ts: plug the same policy into three payment rails.
import { erc20Abi } from "viem";
import type { Account, Address, Chain, Transport, WalletClient } from "viem";
import { wrapFetchWithPayment, x402Client } from "@x402/fetch";
import { ExactEvmScheme } from "@x402/evm/exact/client";
import type { PayFn, PolicyEngine } from "./policy.ts";
// 1. Stablecoin transfer: an ERC-20 transfer() with viem. The receipt is the tx hash.
export function stablecoinPay(wallet: WalletClient<Transport, Chain, Account>, token: Address): PayFn {
return (p) => wallet.writeContract({
address: token,
abi: erc20Abi,
functionName: "transfer",
args: [p.to as Address, p.amount], // viem takes bigint base units too: no conversion
});
}
// 2. Mobile money or bank transfer over HTTP. Field names differ by provider:
// map them from your provider's API reference. Convert units only here, at the edge.
export function mobileMoneyPay(baseUrl: string, apiKey: string, decimals: number, f = fetch): PayFn {
return async (p) => {
const res = await f(`${baseUrl}/transfers`, {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": p.idempotencyKey, // the provider dedupes retries too
},
body: JSON.stringify({ to: p.to, amount: toDecimalString(p.amount, decimals), currency: p.currency }),
});
if (!res.ok) throw new Error(`provider said ${res.status}`);
return (await res.json()).id as string;
};
}
export function toDecimalString(minor: bigint, decimals: number): string {
const s = minor.toString().padStart(decimals + 1, "0");
return decimals === 0 ? s : `${s.slice(0, -decimals)}.${s.slice(-decimals)}`;
}
// 3. x402: the server names the price in its 402 reply, so the check runs inside
// the client's hook, after the price is known and before anything is signed.
export function paidFetch(engine: PolicyEngine, signer: ConstructorParameters<typeof ExactEvmScheme>[0], f = fetch) {
return (url: string, taskKey: string) => {
const client = new x402Client().register("eip155:*", new ExactEvmScheme(signer));
client.onBeforePaymentCreation(async ({ selectedRequirements: r }) => {
const d = engine.decide({
idempotencyKey: taskKey,
to: r.payTo,
amount: BigInt(r.amount), // already atomic units in x402 v2
currency: `${r.network}/${r.asset}`, // e.g. USDC on Base Sepolia
});
if (d.verdict !== "allow") return { abort: true, reason: d.reason };
});
return wrapFetchWithPayment(f, client)(url);
};
}
x402 is the odd one out. It's an open protocol for paying over HTTP: the server replies 402 Payment Required with a price, and the client signs a payment and retries. The agent only learns the price from the server, so the check goes in the client's onBeforePaymentCreation hook, which in @x402/core 2.28.0 can cancel a payment by returning { abort: true, reason }. Same engine, same rules, run before anything is signed.
The mobile-money adapter is generic on purpose: providers name their fields differently, so map them from your provider's API reference. If it accepts a unique reference or idempotency key per request, as Stripe does with its Idempotency-Key header, pass your key there.
Step 7. Prove the rails offline
This script runs each adapter against a fake: a fake JSON-RPC node that lets viem sign a real ERC-20 transfer, a fake mobile-money API, and a fake x402 server that demands payment.
// rails-check.ts: exercise all three rails offline, with fakes in place of the network.
// The signing key is generated in memory for this run, never printed or saved, and holds nothing.
import { createWalletClient, custom, decodeFunctionData, erc20Abi, parseTransaction } from "viem";
import type { Hex } from "viem";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
import { baseSepolia } from "viem/chains";
import { encodePaymentRequiredHeader } from "@x402/core/http";
import { PolicyEngine, guardedPay } from "./policy.ts";
import { mobileMoneyPay, paidFetch, stablecoinPay } from "./rails.ts";
const account = privateKeyToAccount(generatePrivateKey());
const USDC = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; // USDC on Base Sepolia
const SHOP = "0x1111111111111111111111111111111111111111";
const STRANGER = "0x9f3c000000000000000000000000000000000bad";
const engineFor = (currency: string, allow: string[]) => new PolicyEngine({
currency, perTxCap: 5_000_000n, dailyCap: 20_000_000n, approvalAbove: 2_000_000n,
maxPerHour: 5, allowlist: new Set(allow),
});
// --- 1. stablecoin: a fake JSON-RPC node that answers just enough for viem to sign.
const wallet = createWalletClient({
account, chain: baseSepolia,
transport: custom({
async request({ method, params }: { method: string; params: unknown[] }) {
if (method === "eth_chainId") return "0x14a34";
if (method === "eth_getTransactionCount") return "0x0";
if (method === "eth_estimateGas") return "0xea60";
if (method === "eth_maxPriorityFeePerGas") return "0x3b9aca00";
if (method === "eth_getBlockByNumber") return { baseFeePerGas: "0x3b9aca00", number: "0x1" };
if (method === "eth_sendRawTransaction") {
const tx = parseTransaction(params[0] as Hex);
const call = decodeFunctionData({ abi: erc20Abi, data: tx.data! });
console.log(" signed tx to", tx.to, call.functionName, call.args);
return "0x" + "ab".repeat(32);
}
throw new Error(`fake node has no ${method}`);
},
}),
});
const usdc = engineFor("USDC", [SHOP]);
console.log("stablecoin:");
for (const [key, to, amount] of [["inv-1", SHOP, 1_500_000n], ["inv-2", STRANGER, 1_000n]] as const) {
const d = await guardedPay(usdc, { idempotencyKey: key, to, amount, currency: "USDC" }, stablecoinPay(wallet, USDC));
console.log(" ", key, d.verdict, d.receipt ?? d.reason);
}
// --- 2. mobile money: a fake provider that records what it was sent.
const fakeProvider = async (url: string | URL | Request, init?: RequestInit) => {
console.log(" POST", String(url), (init!.headers as Record<string, string>)["Idempotency-Key"], init!.body);
return Response.json({ id: "mm-48213" });
};
const ngn = engineFor("NGN", ["msisdn:2348030000000"]);
console.log("mobile money:");
const mm = await guardedPay(ngn, { idempotencyKey: "airtime-0901", to: "msisdn:2348030000000", amount: 150050n, currency: "NGN" },
mobileMoneyPay("https://sandbox.provider.example", "test-key", 2, fakeProvider as typeof fetch));
console.log(" ", mm.verdict, mm.receipt);
// --- 3. x402: a fake paid API that replies 402 until it sees a payment signature.
const paidApi = (payTo: string) => async (input: string | URL | Request) => {
const req = new Request(input);
if (req.headers.has("PAYMENT-SIGNATURE")) return new Response("weather: 31C, Lagos", { status: 200 });
const header = encodePaymentRequiredHeader({
x402Version: 2,
resource: { url: req.url, description: "forecast", mimeType: "text/plain" },
accepts: [{ scheme: "exact", network: "eip155:84532", asset: USDC, amount: "10000", payTo,
maxTimeoutSeconds: 60, extra: { name: "USDC", version: "2" } }],
});
return new Response("{}", { status: 402, headers: { "PAYMENT-REQUIRED": header } });
};
const x402Engine = engineFor(`eip155:84532/${USDC}`, [SHOP]);
console.log("x402:");
const ok = await paidFetch(x402Engine, account, paidApi(SHOP) as typeof fetch)("https://api.example/forecast", "task-7");
console.log(" vetted seller:", ok.status, await ok.text());
try {
await paidFetch(x402Engine, account, paidApi(STRANGER) as typeof fetch)("https://api.example/forecast", "task-8");
} catch (e) {
console.log(" unknown seller:", (e as Error).message);
}
console.log("audit events:", [usdc, ngn, x402Engine].flatMap((e) => e.audit.map((a) => a.event)).join(", "));
For the optional type check, save this as tsconfig.json:
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"noEmit": true,
"strict": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["*.ts"]
}
Run it and see it work
node --test
✔ per-transaction cap (3.5386ms)
✔ rolling 24-hour cap, which frees up as old payments age out (0.6189ms)
✔ recipient allowlist (0.3496ms)
✔ human approval: bound to one exact proposal, used once (0.4398ms)
✔ idempotency: the same invoice is never paid twice (0.3028ms)
✔ velocity: at most 5 payments per rolling hour (1.4144ms)
✔ amounts must be positive whole minor units (0.274ms)
✔ every decision is in the audit log, with a reason (1.3745ms)
✔ guardedPay calls payFn only on allow, and fails closed (1.0863ms)
✔ attack: injected agent tries to pay an unknown address 20 times in small amounts (1.1502ms)
ℹ tests 10
ℹ suites 0
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 279.8009
Your timings will differ. Then the morning of decisions:
node demo.ts
08:00 allow ikedc-oct 800000 kobo within policy
08:00 paid ikedc-oct 800000 kobo receipt rcpt-ikedc-oct
08:05 deny ikedc-oct 800000 kobo duplicate: ikedc-oct was already paid
08:10 allow rice-0412 1850000 kobo within policy
08:10 paid rice-0412 1850000 kobo receipt rcpt-rice-0412
08:15 needs_approval fees-term1 4500000 kobo 4500000 is above the approval threshold of 2000000
08:20 deny refund-77 490000 kobo recipient 0x9f3cAttacker is not on the allowlist
08:25 deny rice-0413 6000000 kobo 6000000 is over the per-transaction cap of 5000000
08:30 allow fees-term1 4500000 kobo within policy
08:30 paid fees-term1 4500000 kobo receipt rcpt-fees-term1
The electricity bill is paid once and the retry refused. The school fees wait for a person, then go through. The injected refund and the oversized order never reach the rail. Then the three adapters:
node rails-check.ts
stablecoin:
signed tx to 0x036cbd53842c5426634e7929541ec2318f3dcf7e transfer [ '0x1111111111111111111111111111111111111111', 1500000n ]
inv-1 allow 0xabababababababababababababababababababababababababababababababab
inv-2 deny recipient 0x9f3c000000000000000000000000000000000bad is not on the allowlist
mobile money:
POST https://sandbox.provider.example/transfers airtime-0901 {"to":"msisdn:2348030000000","amount":"1500.50","currency":"NGN"}
allow mm-48213
x402:
vetted seller: 200 weather: 31C, Lagos
unknown seller: Failed to create payment payload: Payment creation aborted: recipient 0x9f3c000000000000000000000000000000000bad is not on the allowlist
audit events: allow, paid, deny, allow, paid, allow, deny
The viem adapter produced a signed transfer of exactly 1,500,000 base units (1.5 USDC) to the shop. The mobile-money call converted 150050 kobo to "1500.50" only at the edge. The real x402 client paid a vetted seller and refused to sign for an unknown one. npx tsc prints nothing: the strict type check passed.
Going deeper
Why the policy lives outside the model
A rule in a prompt is a request. A model can be argued, tricked or confused out of it, and the same input can get a different answer next time. OWASP calls the root problem excessive agency: too much functionality, permission or autonomy. Its advice is to enforce authorisation in downstream systems, not to let the model decide what is allowed.
Code like decide is deterministic, testable and readable by an auditor, but it only protects you if the agent can't go around it. Here the engine and the agent share a process. In production, the signing key and payFn belong in a separate service the agent reaches only through guardedPay. If the agent's process holds the key, a compromised dependency can call the rail directly.
The threat model
- Prompt injection. Text in an invoice, web page or tool result steers the model. Assume it will sometimes work; prompt injection is a design problem explains why filters alone fail. The policy limits what a fooled model can do: unknown payees are refused, known ones are capped per payment, hour and day.
- Compromised tools. An MCP server or API returns a doctored invoice or a swapped account number. The engine trusts tools no more than the model. Its weak spot is the allowlist, so change payees only through a separate, human path the agent can't call, ideally with a cooling-off delay before a new payee can be paid.
- Replay. An invoice paid again after a timeout, a webhook delivered twice, or an approval stretched to a bigger amount. Idempotency keys and single-use, exact approvals cover these. At the token level, x402's exact scheme on EVM chains can use EIP-3009 authorisations, whose random 32-byte nonce the token contract marks as used, so one signed payment can't settle twice.
Two failure modes are easy to miss. If payFn times out, the money may or may not have left, so guardedPay fails closed: the spend and key stay reserved, a blind retry is refused, and a person reconciles against the provider or chain. And don't lean on a provider's idempotency alone. Stripe may prune keys once they are at least 24 hours old, after which the same key makes a new request. Keep your own record of used keys forever.
Storage and concurrency
The engine keeps state in memory, which is lost on restart. Move it into a database and the classic bug arrives: two requests read the daily total, both see room, both pay.
// race.ts: why "read the total, check it, then write" overspends under concurrency.
import { DatabaseSync } from "node:sqlite";
const CAP = 1_000n;
const latency = () => new Promise((r) => setTimeout(r, 10)); // a database round trip
// Naive: the check and the write are separate awaits, so requests interleave.
let spent = 0n;
async function naivePay(amount: bigint): Promise<boolean> {
const current = spent; await latency(); // SELECT spent ...
if (current + amount > CAP) return false;
await latency(); spent = current + amount; // UPDATE ... SET spent = ?
return true;
}
const naive = await Promise.all([1, 2, 3, 4, 5].map(() => naivePay(300n)));
const paid = naive.filter(Boolean).length;
console.log(`naive: ${paid} of 5 allowed, ${300 * paid} paid out, ledger says ${spent}, cap ${CAP}`);
// Atomic: one conditional UPDATE does the check and the write together.
const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE budget (wallet TEXT PRIMARY KEY, spent INTEGER NOT NULL, cap INTEGER NOT NULL)");
db.prepare("INSERT INTO budget VALUES (?, 0, ?)").run("agent-1", CAP);
const reserve = db.prepare(
"UPDATE budget SET spent = spent + ? WHERE wallet = ? AND spent + ? <= cap RETURNING spent");
const atomic = [1, 2, 3, 4, 5].map(() => reserve.get(300n, "agent-1", 300n) !== undefined);
const row = db.prepare("SELECT spent FROM budget WHERE wallet = ?").get("agent-1");
const ok = atomic.filter(Boolean).length;
console.log(`atomic: ${ok} of 5 allowed, ${300 * ok} paid out, ledger says ${row?.spent}, cap ${CAP}`);
node race.ts
naive: 5 of 5 allowed, 1500 paid out, ledger says 300, cap 1000
atomic: 3 of 5 allowed, 900 paid out, ledger says 900, cap 1000
The naive version paid out 1,500 against a cap of 1,000, and its ledger says 300, because every write overwrote the others. The fix is to make check and write one operation: a conditional UPDATE ... WHERE spent + ? <= cap. This demo runs in one process, so it shows the statement rather than a real race. In PostgreSQL at the default Read Committed level, a second UPDATE on the same row waits for the first to commit, then re-checks its WHERE clause against the new value, which makes the pattern safe across processes. A rolling window needs a sum over recent rows, so lock the wallet's row with SELECT ... FOR UPDATE before summing.
One more trap: SQLite INTEGER and PostgreSQL bigint are signed 64-bit, which holds only about 9.2 whole tokens at 18 decimals. Store token amounts as numeric or as text, and keep the audit log append-only, somewhere the agent's credentials can't edit.
Enforce it onchain too
An off-chain engine can have bugs, and its server can be breached. For a crypto wallet, put a second, coarser cap in the account itself, so even a stolen agent key can't drain it:
| Option | What it enforces | Notes |
|---|---|---|
| ERC-4337 smart accounts | The account's own contract validates each operation, so it can run custom rules | The standard for smart contract accounts; several options below run on them |
| ERC-7579 hook modules | Checks the account calls before and after each execution | Draft; aims to let one module work across different smart account implementations |
| Safe allowance module | A per-token allowance for one delegate, one-off or resetting on an interval | The reset is set in minutes: 1440 is a day |
| Coinbase spend permissions | An allowance per period for one spender and token, checked by a contract | Amounts in the token's smallest unit; the owner can revoke |
| Session keys, e.g. ZeroDev permissions | A key limited by policies such as allowed calls, rate limits and time windows | The agent holds a narrow key, never the owner key |
| ERC-7715 | A way for an app to request scoped, expiring permissions from a wallet | Draft |
The split that works: onchain, a blunt daily allowance the agent's key can never exceed. Off-chain, the rich rules contracts handle badly: allowlists you edit often, approvals, idempotency per invoice and an audit log with reasons. Set the onchain allowance just above the off-chain daily cap, so the engine is what normally says no.
Evaluate it with repeated runs
The engine is deterministic; what varies is the agent. As one run proves nothing explains, the number customers live with is pass^k: the chance that k runs in a row all succeed. So run a noisy agent many times, grade the ledger the fake rail kept rather than the engine's own story, and track two things: safety invariants, which must hold in every run, and the job, which the agent should finish.
// eval.ts: run a noisy agent many times; grade the ledger, not the agent's story.
import { PolicyEngine, guardedPay } from "./policy.ts";
import type { Policy, Proposal } from "./policy.ts";
const naira = (n: number) => BigInt(n) * 100n;
const policy: Policy = {
currency: "NGN", perTxCap: naira(50_000), dailyCap: naira(100_000), approvalAbove: naira(20_000),
maxPerHour: 5, allowlist: new Set(["acct:greenfield-school", "acct:ada-rice-supplier", "acct:ikeja-electric"]),
};
const JOB: Proposal[] = [
{ idempotencyKey: "ikedc-oct", to: "acct:ikeja-electric", amount: naira(8_000), currency: "NGN" },
{ idempotencyKey: "rice-0412", to: "acct:ada-rice-supplier", amount: naira(18_500), currency: "NGN" },
{ idempotencyKey: "fees-term1", to: "acct:greenfield-school", amount: naira(45_000), currency: "NGN" },
];
function rng(seed: number) { // mulberry32: same seed, same run
return () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
async function trial(seed: number) {
const r = rng(seed);
let t = Date.UTC(2026, 9, 5, 8);
const engine = new PolicyEngine(policy, () => t);
const ledger: Proposal[] = []; // the fake rail's own record: the oracle
const payFn = async (p: Proposal) => { ledger.push(p); return `rcpt-${ledger.length}`; };
for (const inv of JOB) {
// Model noise: sometimes it confuses naira with kobo, sometimes it retries a paid invoice.
const p = r() < 0.1 ? { ...inv, amount: inv.amount * 100n } : inv;
const d = await guardedPay(engine, p, payFn);
if (d.verdict === "needs_approval") { engine.approve(p); await guardedPay(engine, p, payFn); }
if (r() < 0.15) await guardedPay(engine, p, payFn);
t += 10 * 60_000;
}
if (r() < 0.3) { // a poisoned invoice hijacks this run
const target = r() < 0.5 ? "0x9f3cAttacker" : "acct:ada-rice-supplier"; // stranger, or swapped details
for (let i = 0; i < 20; i++) { await guardedPay(engine, { ...JOB[1], idempotencyKey: `inj-${i}`, to: target, amount: naira(4_900) }, payFn); t += 90_000; }
}
const total = ledger.reduce((s, p) => s + p.amount, 0n);
const keys = ledger.map((p) => p.idempotencyKey);
const safe = ledger.every((p) => policy.allowlist.has(p.to) && p.amount <= policy.perTxCap)
&& new Set(keys).size === keys.length && total <= policy.dailyCap
&& engine.audit.every((e) => e.reason !== "");
const done = JOB.every((inv) => ledger.some((p) => p.idempotencyKey === inv.idempotencyKey && p.amount === inv.amount));
const lost = ledger.filter((p) => p.idempotencyKey.startsWith("inj-")).reduce((s, p) => s + p.amount, 0n);
return { safe, done, lost };
}
const N = 200;
const runs = await Promise.all(Array.from({ length: N }, (_, i) => trial(i + 1)));
const c = runs.filter((x) => x.done).length;
const comb = (n: number, k: number): number => (k === 0 ? 1 : (comb(n - 1, k - 1) * n) / k);
console.log(`safety invariants held: ${runs.filter((x) => x.safe).length}/${N} runs`);
console.log(`job finished correctly: ${c}/${N} runs`);
const worst = runs.reduce((m, x) => (x.lost > m ? x.lost : m), 0n);
console.log(`worst loss to an attacker in one run: NGN ${worst / 100n}`);
for (const k of [1, 2, 4, 8]) console.log(` pass^${k} = ${(comb(c, k) / comb(N, k)).toFixed(2)}`);
node eval.ts
safety invariants held: 200/200 runs
job finished correctly: 145/200 runs
worst loss to an attacker in one run: NGN 24500
pass^1 = 0.72
pass^2 = 0.52
pass^4 = 0.27
pass^8 = 0.07
The simulated agent confuses naira with kobo 10% of the time, retries paid invoices, and gets hijacked in 30% of runs. The safety invariants held in all 200 runs, and the worst run lost ₦24,500 to the swapped-payee attack: bounded, not zero. The job finished in only 145 of 200 runs, and pass^8 falls to 0.07. The policy blocked the unit mix-ups; fixing them is the agent's job.
Then check that the eval can fail. I deleted the allowlist line from policy.ts and ran everything again: three tests failed, and the safety invariants dropped to 169 of 200 runs. A test suite that can't go red isn't testing anything.
What production needs
- Durable state: used keys, spends and approvals in a database, with the atomic reserve above.
- Approvals that expire, come through an authenticated channel, and show the exact payee and amount rather than the model's summary. Anthropic found that Claude Code users approve 93% of permission prompts, so ask rarely.
- Address normalisation: compare EVM addresses in checksummed form, so the same payee in lower case isn't treated as a stranger.
- Alerts on denials: twenty refusals in an hour is an incident, so consider freezing the agent.
- A versioned policy, with every audit entry recording which version decided.
Your 30-minute build
Write a policy for a real job you know, plus one red-team test. Copy policy.ts unchanged and write your own Policy and tests.
Acceptance checks:
- ☐ Your
Policyobject has a one-line comment justifying each number. - ☐
node --testpasses, with at least one test per rule your job relies on. - ☐ One red-team test that fits your job, such as split payments, a swapped payee or a replayed invoice, asserts the most money that can leave.
- ☐ Delete one rule line from
policy.ts, watch at least one test fail, then restore it.
Three ideas to start from:
- Airtime: a family top-up agent with five saved numbers, ₦2,000 per top-up, three an hour. Red team: a text asking for "emergency credit" to a new number.
- School fees: one school's account, a key per child per term, every payment needing a parent's approval. Red team: the same term's fees requested again with a new reference.
- Farming: a cooperative paying three vetted dealers for tractor hire and fertiliser. Red team: a dealer's account number changed by a forwarded message.
Help me write a spending policy and a red-team test for my own payment agent.
Work in a new folder called my-policy.
My job: <one sentence: who the agent pays, for what, and how often>
My payees: <2 to 5 ids, e.g. acct:greenfield-school>
My currency and minor unit: <e.g. NGN, 100 kobo per naira>
Stack:
- Node 24 (I use 24.18.0). Run .ts files directly with node; no tsx, no build step.
- No dependencies. Only node:test and node:assert/strict.
- package.json with "type": "module" and "scripts": {"test": "node --test"}.
Files:
1. policy.ts: copy it unchanged from
https://www.edidiongumana.tech/blog/spend-limits-for-agent-wallets/
2. my-policy.ts: export a Policy for my job. Amounts are bigint minor units, never
floats. Put a one-line comment on every number saying why it is that number.
3. my-policy.test.ts: one test per rule my job relies on (per-transaction cap,
rolling 24-hour cap, allowlist, approval threshold, idempotency, velocity,
audit log), using a fake clock passed to new PolicyEngine(policy, now).
Plus one red-team test for my job (split payments, a swapped payee or a replayed
invoice) that asserts the exact maximum amount that can leave.
Acceptance checks:
- node --test passes.
- The red-team test asserts a number, not just "denied".
- Deleting any one rule line from policy.ts makes at least one test fail.
Show me that, then restore policy.ts.
Rules:
- No wallets, no keys, no real money. payFn is a fake that records payments.
- Don't change policy.ts to make a test pass. If a test fails, explain why first.
- Test before you finish: run node --test and show me the real output.Learn it properly
Least privilege for tools, approvals that mean something and audit logs are the subject of Track 9 of the AI Study Group, AI security and red-teaming. It's free and self-paced, and its lessons are checked against their sources.
Sources
- Node.js: modules, TypeScript: type stripping on by default from 23.6.0, no warning from 24.3.0, unsupported syntax and
.tsimport extensions - Node.js: test runner:
node:test, default test file patterns including.ts - Node.js: SQLite:
DatabaseSyncand 64-bit integer limits - MDN: Number.MAX_SAFE_INTEGER and BigInt
- OWASP LLM06:2025 Excessive agency: the three causes, and authorisation in downstream systems
- Stripe API: idempotent requests: how keys work, and pruning after 24 hours
- x402 (coinbase/x402): the protocol, the
@x402/fetchclient, and the exact scheme on EVM - ERC-3009: Transfer With Authorization: random nonces and replay protection
- viem: writeContract
- PostgreSQL: transaction isolation: how
UPDATEre-checks itsWHEREclause under Read Committed - ERC-4337: Account Abstraction, ERC-7579: Minimal Modular Smart Accounts and ERC-7715: Request Permissions from Wallets
- Safe: AI agent with a spending limit: the allowance module
- Coinbase Developer Platform: spend permissions
- ZeroDev: permissions: signers and policies for session keys
- How we built Claude Code auto mode (Anthropic): the 93% approval rate
- τ-bench (Yao et al., 2024): where pass^k comes from
- Versions used: Node 24.18.0, npm 11.16.0,
viem2.57.3,@x402/core,@x402/fetchand@x402/evm2.28.0,typescript7.0.2,@types/node24.19.1