edidiong umana · writing
home
Build it10 min read

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 .ts files directly: type stripping is on by default since 23.6.0, with no warning since 24.3.0. node --experimental-strip-types still works, but neither the flag nor tsx is needed.
  • The engine has no dependencies. Steps 1 to 5 need nothing from npm.
  • For the payment adapters only (step 6), pinned: viem 2.57.3, @x402/core, @x402/fetch and @x402/evm 2.28.0. For an optional type check: typescript 7.0.2 and @types/node 24.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. decide records 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:

OptionWhat it enforcesNotes
ERC-4337 smart accountsThe account's own contract validates each operation, so it can run custom rulesThe standard for smart contract accounts; several options below run on them
ERC-7579 hook modulesChecks the account calls before and after each executionDraft; aims to let one module work across different smart account implementations
Safe allowance moduleA per-token allowance for one delegate, one-off or resetting on an intervalThe reset is set in minutes: 1440 is a day
Coinbase spend permissionsAn allowance per period for one spender and token, checked by a contractAmounts in the token's smallest unit; the owner can revoke
Session keys, e.g. ZeroDev permissionsA key limited by policies such as allowed calls, rate limits and time windowsThe agent holds a narrow key, never the owner key
ERC-7715A way for an app to request scoped, expiring permissions from a walletDraft

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 Policy object has a one-line comment justifying each number.
  • ☐ node --test passes, 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.
Hand this to your coding agent
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