Skip to content
coriolislabsPublic

About

Spend limits for AI agent wallets: a per-payment cap and rolling 24h USDC budget that holds across restarts, replicas and payment rails. For x402, Coinbase CDP spend controls, AgentKit, the Agentic Wallet and payments MCP, and Spend Permissions. Crash test: $1,600+ past a $150 cap with CDP's controls, $0 with walletcap.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

walletcap: spend limits for AI agent wallets

npm PyPI CI License: Apache-2.0

Spend limits for AI agent wallets that pay in USDC: a per-payment cap and a rolling 24-hour budget that holds across restarts, replicas on one host and payment rails. walletcap plugs into x402 clients, Coinbase CDP's x402 spend controls, Coinbase AgentKit, the Coinbase Agentic Wallet CLI and payments MCP server, and Coinbase Spend Permissions. By ShadowPath.

Crash test: CDP's controls let $1,600–$1,700 past a $150 cap, walletcap lets $0

npx walletcap-crash-test puts Coinbase's agent spend controls through four ordinary events (a restart, 10 replicas, 10 replicas on a shared store, and a payee who asks for EURC), then puts walletcap through the same events. CDP's x402 controls are set as Coinbase's README shows, then run again on walletcap's cdpStore as this README shows. Spend Permissions run Coinbase's deployed contract code on a local EVM. Every agent is held to $50 a payment and $150 in any 24 hours, in USDC on Base. It runs offline in about 15 seconds and sends nothing.

$ npx walletcap-crash-test
CDP x402 spend controls CDP's controls walletcap guard walletcap cdpStore
restart, then pay again $300 signed · $150 over $150 signed · $0 over $150 signed · $0 over
10 replicas $1,500 signed · $1,350 over $150 signed · $0 over $150 signed · $0 over
10 replicas, shared store 1 ms away $350 signed · $200 over $150 signed · $0 over $150 signed · $0 over
payee asks for EURC, not USDC 1,000 EURC signed, counted as $0 refused before signing refused before signing
Spend Permissions SpendPermissionManager AccountCapSpender
a second permission $300 pulled · $150 over $150 pulled · $0 over
across a period boundary, 1 second apart $300 pulled · $150 over $150 pulled · $0 over

Past the $150 cap: $1,700 and 1,000 EURC with CDP's controls, $300 with Spend Permissions; $0 with walletcap's guard, its cdpStore and AccountCapSpender. The shared-store row varies from run to run: in eight runs on 6 November 2026 it was $100 over five times and $200 over three times. Those runs used @coinbase/cdp-sdk 1.61.0, @x402/core 2.30.0, SpendPermissionManager 1.0.0 and walletcap 0.2.3. The crash test in this repository now runs walletcap 0.5.0, and its suite fails if the guard or the cdpStore lets anything past the cap.

Add --agentkit for Coinbase AgentKit's row: the agent approves a 1¢ offer, and AgentKit signs the merchant's first-listed $1.00 on Base mainnet. walletcap, at 2¢ a payment, limits the amount, not which offer is paid: it passes over the $1.00, valid 4 days, and the offer left is the 1¢ the agent approved. The crash test's README says exactly what runs.

Install and use

$ npm install walletcap     # Node 22.13+, no runtime dependencies
$ pip install walletcap     # Python 3.11+, no dependencies

The owner creates the budget once. Opening it never creates one; pass create: true only for a demo or first-run setup:

$ npx walletcap set --wallet 0xYourAgentWallet --per-payment 50 --per-day 150

Or in one step, npx walletcap init --wallet 0xYourAgentWallet --per-payment 50 --per-day 150 --rail x402 sets the limits, checks the file and prints the code for that integration.

import { openBudget } from 'walletcap';

const budget = await openBudget({ wallet: account.address });

await budget.pay('$12.50', () => sendUsdc(payee, '12.50')); // refused past $150 in any 24h, restarts included
from walletcap import open_budget

budget = open_budget(agent_address)

with budget.pay("$12.50") as payment:
    payment.tx = send_usdc(payee, "12.50")
Integration Install Use
Any x402 client (@x402/core, @x402/fetch) npm install walletcap guard(client, budget) from walletcap/x402
Coinbase CDP x402 spend controls npm install walletcap @coinbase/cdp-sdk store: cdpStore(budget, { asset: USDC }) in applySpendControls
Coinbase AgentKit npm install walletcap @coinbase/agentkit guardAgentKit(new X402ActionProvider(), budget) from walletcap/agentkit
Coinbase Agentic Wallet CLI npm install walletcap awal npx walletcap awal x402 pay <url>
Coinbase payments MCP server npx @coinbase/payments-mcp install --client other --no-auto-config MCP server command: npx walletcap-mcp --wallet 0x…
Coinbase Spend Permissions deploy contracts/src/AccountCapSpender.sol (testnet only, unaudited) the agent calls pay(permission, amount, payee)
Python pip install walletcap with budget.pay("$12.50"):

Each link opens a page with a full snippet. The Use it section below covers every integration too.

Try it without a wallet

Two runnable demos use a fake payee and a temporary file: no wallet, chain, keys or network.

$ npm install && npm run build --workspace packages/js
$ node examples/quickstart/quickstart.mjs
$ python3 examples/quickstart/quickstart.py

They show three $0.05 payments against a $0.15 rolling cap, a $1.00 refusal, a fourth $0.05 refusal, and a restart whose code asks for looser limits and still gets the stored ones. The demo uses create: true; a production agent opens with the default create: false after the owner runs walletcap set.

Why a per-wallet count

Most agent spend controls keep "spent so far" next to something temporary or repeatable: a process, a browser window, an API key, a delegate, one permission, or a fixed calendar period. The money, though, sits in one wallet. When the count and the wallet don't match, ordinary events (a restart, a second replica, another key or permission, the next period) can hand the agent a fresh budget against the same money.

ShadowPath measures spend controls against that pattern, on owned test chains with the vendors' own code. Findings go to the vendors first; this package is the fix, not the findings.

The fix: four rules

  1. Count per wallet. The budget belongs to the account the money leaves from, not to a process, key, permission or delegate. Every payment from that wallet is held against one number. Checksummed, lowercase and CAIP-10 forms of an address are one wallet, so they can't split it.
  2. Count over a rolling window. "$150 a day" means any 24 hours: there is no period boundary to straddle.
  3. Changing a limit never resets the count. Raising $150 to $200 after spending $150 leaves $50. Only the owner changes the stored limits. Code that opens the budget can tighten the limits for its own process; if it asks for looser ones (a kit's defaults, say), the stored limits apply and it gets a warning with the command that raises them.
  4. Fail closed. If the count can't be read, nothing pays; a missing file is not a new budget. A payment whose outcome is unknown keeps counting, because a payment that failed halfway may still have moved money. An error never settles a payment.

How it counts

The rule lives in one place: spec/schema.sql, which both packages ship and run as written. spec/ledger.md explains it.

  • A payment is HELD from the moment it is admitted, then SETTLED on evidence that it paid, or VOID on evidence that it never moved money. States only move forward, and VOID can still become SETTLED. Every state is a row appended to the ledger; none is changed or deleted.
  • A held payment counts through one window past the last moment it could settle, or until evidence ends it if nothing bounds that moment. A settled one counts through one window past its settlement, or past its hold's settle-by time if that is later. A void one counts nothing.
  • Admission is one conditional INSERT inside a write transaction, so two processes, in either language, can't both take the last dollar. A trigger checks the same rule on every insert, including one made from the sqlite3 shell.
  • The ledger reads its own clock in SQL. No caller passes the time it is dated by.
  • A payment is keyed by its rail's own identity where there is one (an EIP-3009 authorization is eip3009:<from>:<nonce>), so one signature counts once.

The JavaScript and Python test suites run the same golden vectors in spec/vectors/, whole admission sequences with their expected refusals.

Use it

Any payment, any rail

pay() holds the amount, runs your payment, and settles it.

const tx = await budget.pay('$5', async hold => {
  return wallet.sendTransaction(request); // hold.id makes a good idempotency key
}, { to: payee, note: 'weather API' });

If the payment throws, it may still have left the wallet, so it stays held, noted unknown. It counts until one window past settlesBy, the last moment it could still settle (after now), if you pass one; otherwise until you check the chain and run npx walletcap void <id>. If you know it didn't leave, say so and the amount comes back:

import { notSent } from 'walletcap';

throw notSent(error); // inside the payment function

Only notSent() says so: a notSent property on an error voids nothing, since an error built from a payee's JSON body could carry one. And it says so about one payment, the innermost pay() it is thrown out of: a pay() around that one may already have paid, and keeps counting.

For more control, hold() gives you the payment's handle:

const id = `order-${orderId}`;
// A retry after a restart finds the earlier payment: look, don't pay again.
if ((await budget.payment(id)) === null) {
  const hold = await budget.hold('$5', { id, settlesWithin: 300_000 });
  try {
    const tx = await sendUsdc(payee, '5');
    await hold.settle({ tx });
  } catch (error) {
    if (neverBroadcast(error)) await hold.void({ evidence: String(error) });
    else await hold.note('unknown', String(error)); // it keeps counting
  }
}
  • One id is one payment, held once. Holding an id that is already in the ledger throws UsageError, whose paymentState says where that payment stands, whatever room the budget has left; pay() refuses it before your function runs. A process that restarts looks its payment up with budget.payment(id) instead of holding it again. Only the hold that took a payment can settle, void or note it; another process can't. A voided id can't be held again: a retry takes a new id.
  • hold.settlesBy(date) or hold.settlesWithin(ms) tells an open hold, once, the last moment it could settle.
  • If the payment went through but the budget can't record it, pay() throws PaymentNotRecorded. It shares no base class with the other errors, so code that retries on them won't pay twice. The payment still counts as held.
  • budget.check('$5') answers { ok, remaining, code, source, message, brief } without spending: source says whether the owner's limit (budget) or this process's own (process) would refuse, and every field but ok and remaining is null when it would be paid. budget.status(), budget.history() and budget.holds() read the count.
  • Every method is async. Each try at the file can hold the event loop for up to about 10 ms, the wait SQLite itself makes for a lock; between tries, a wait for another process's write lock happens on the event loop, in steps of up to 50 ms, for at most busyTimeoutMs (30,000 by default).

Errors are four classes with no common base:

Error When
BudgetUnavailable the budget can't be read or written: no file, no budget for the wallet, a lock held past busyTimeoutMs, SQLite older than 3.37.0, a newer file format
BudgetRefused a limit refused the payment; code is budget, per_payment, max_payments, frozen, asset, timeout or terms, brief is what an agent should be told, and source says whether the budget's limits or the process's own refused
PaymentNotRecorded the payment went through, or may have, but couldn't be recorded; paymentId names it. Don't pay again
UsageError the call was malformed, or doesn't fit the payment's state

A log of every decision

A refusal leaves nothing in the ledger, so by itself the budget can't tell you afterwards why a payment didn't happen. Ask for its decisions:

const budget = await openBudget({
  wallet: account.address,
  onDecision: decision => logger.info(decision), // held, refused, bound, noted, settled, void
});
$ WALLETCAP_LOG=~/walletcap/decisions.jsonl node agent.js
$ tail -f ~/walletcap/decisions.jsonl | jq -c '{decision, amount, code, brief}'
{"decision":"held","amount":"12500000","code":null,"brief":null}
{"decision":"settled","amount":"12500000","code":null,"brief":null}
{"decision":"refused","amount":"51000000","code":"per_payment","brief":"$51.00 is over 0xd797…ca02's $50.00 limit per payment"}
  • onDecision (Python: on_decision) is called once for every decision made through that handle: a hold admitted (held), a payment refused (refused), and each move of a hold the handle took (bound, noted, settled, void). A Decision always has the same fields, null where one doesn't apply: decision, at, wallet, amount, id, rail, to, code, source, brief, outcome, settlesBy and tx.
  • If WALLETCAP_LOG names a file when the budget is opened, each decision is also appended to it as one line of JSON, in the shape --json prints: the same bytes from both languages, in ASCII, so nothing a payee sent ends a line early or reaches a terminal as a control code. walletcap creates the file 0600, and several processes can share it.
  • A refusal is reported wherever it is made, once: by the budget's limits, or by an adapter before any hold (the x402 guard's check before signing, an asset, scheme or validity it can't count, the Agentic Wallet wrapper, the CDP store). check(), the owner's handle and reads report nothing.
  • The log can't change a decision. It runs after the ledger has answered, and a callback that throws or a file that can't be written is swallowed, with one BudgetWarning for the handle. A callback isn't waited for.

Python

The Python package has the same handles and errors under Python names:

from datetime import timedelta
from walletcap import BudgetRefused, NotSent, open_budget

budget = open_budget(agent_address, per_payment="$5")  # tighter than the stored limit: this process only

hold = budget.hold("$5", id=f"order-{order_id}", settles_within=timedelta(minutes=5))
try:
    hold.settle(tx=send_usdc(payee, "5"))
except BroadcastFailed:
    hold.void(evidence="never broadcast")
  • Inside with budget.pay(...), set payment.tx to keep the transaction hash, and raise NotSent("card declined") when you know it didn't leave. settles_by takes a timezone-aware datetime; a naive one is refused.
  • Amounts come back as Micros, a micro-dollar amount. to_micros reads a Micros as micro-dollars and a plain int as whole dollars, so budget.check(budget.status().remaining) means what it says. Arithmetic on a Micros returns a Micros or raises TypeError: remaining // 2 is half of it, and remaining + 1, remaining * 1.5 or remaining / 2 is refused, since a bare number would be read as dollars.
  • In async code, async with budget.pay(...) and acheck, ahold, astatus, aholds and ahistory wait for another process's lock in a worker thread, through asyncio.to_thread. If the task is cancelled while it waits, the hold the thread takes is voided.

x402

guard() from walletcap/x402 puts the budget in front of any x402 v2 client from @x402/core, so it works under @x402/fetch. Use it as the one counter when the client has no CDP spend store.

import { openBudget } from 'walletcap';
import { guard } from 'walletcap/x402';
import { x402Client } from '@x402/core/client';
import { ExactEvmScheme } from '@x402/evm/exact/client';
import { wrapFetchWithPayment } from '@x402/fetch';

const budget = await openBudget({ wallet: account.address });
const client = guard(new x402Client().register('eip155:8453', new ExactEvmScheme(account)), budget);
const fetchPaid = wrapFetchWithPayment(fetch, client);
  • Each payment is checked before it is signed, and held once its payload exists and every other control has passed, under the authorization's own identity.
  • It counts as settled when the server confirms settlement, and at least until its authorization expires, plus one window: the confirmation is the payee's own word, and a payee that answers "payment failed" still holds the signed authorization too.
  • A payee may ask for an authorization valid up to an hour (MAX_PAYMENT_TIMEOUT_SECONDS, 3600), as Coinbase's CDP spend controls allow. Longer terms are passed over for shorter ones on offer; when none are, the payment is refused before signing, with the code timeout.
  • A payment is priced at the most its terms ask the wallet to sign for. x402 v2 names that amount and v1 maxAmountRequired, and a client signs whichever its protocol version reads, so terms that carry both are priced at the larger. An amount is a whole number in decimal digits; anything else (0x10, 5e4, a space) is refused with the code terms. Once the wallet has signed, the payment is held at what the payload itself moves, an EIP-3009 value, a Permit2 amount or a channel deposit, when that is more than the terms said.
  • The schemes exact, upto and auth-capture are counted (COUNTED_SCHEMES): under each the wallet signs for at most the terms' amount. Any other scheme is refused before signing, with the code terms, unless you list it: guard(client, budget, { schemes: ['batch-settlement'] }). Under batch-settlement the wallet signs a deposit into the payee's channel, five times the request's amount by default or the minimum the payee announces. On EVM the guard reads that deposit from the payload and holds it; on Solana it can't, and holds the request's amount.
  • USDC on Base, Ethereum, Polygon, Arbitrum, Optimism, Avalanche and Solana is priced out of the box, plus Base Sepolia, Arbitrum Sepolia and Solana devnet (USD_ASSETS). Add another USD stablecoin with guard(client, budget, { assets: [{ network, asset, decimals }] }). List only USD-pegged stablecoins: each counts at $1 a token. Decimals are an integer from 0 to 18, and a built-in asset's decimals can't be changed. Anything else is refused, with the code asset.
  • @x402/fetch rewraps errors from payment creation, so a refusal reaches you as an Error whose message starts Failed to create payment payload: and carries the budget's reason.

Coinbase CDP spend controls

There are two ways to count a CDP payment in the budget; use one per client.

  • guard(client, budget): the budget prices every asset, refuses anything it cannot price, and holds each payment in one write transaction. Use it when the budget is the counter.
  • applySpendControls(client, { ..., store: cdpStore(budget, { asset: USDC }) }): keep CDP's controls, and make its count durable and shared. The store holds each payment through the ledger's own admission, so replicas on one host can't both take the last dollar.

cdpStore() from walletcap/cdp is walletcap's ledger behind CDP's SpendStore interface, in place of CDP's in-memory default. With both the store and the guard on one client, the budget would count each payment twice: guard() refuses a client that already has CDP controls, and a client guarded first refuses CDP controls before signing.

Set maxCumulativeSpend (CDP only records through its store when a cumulative cap is set), and set allowedAssets: CDP never consults its store for a token outside the cap.

import { cdpStore } from 'walletcap/cdp';

applySpendControls(client, {
  maxAmountPerPayment: { atomic: 50_000_000n, asset: USDC },
  maxCumulativeSpend: { atomic: 150_000_000n, asset: USDC },
  maxCumulativeSpendWindow: '24h',
  allowedAssets: [USDC],
  store: cdpStore(budget, { asset: USDC }),
});

In TypeScript with node16 or nodenext module resolution, that call fails to compile with @coinbase/cdp-sdk 1.60 and 1.61, with or without walletcap: TS2345 at client. The SDK ships one set of declarations, which TypeScript reads as CommonJS there, so applySpendControls takes @x402/core's CommonJS x402Client, a different type from the ESM one your code imports. At run time Node loads the ESM build of both, so the client is the same class. Write client as unknown as Parameters<typeof applySpendControls>[0], or compile with "moduleResolution": "bundler".

The store holds each payment from the ledger's own time, not the time in CDP's entry, until an hour and a minute after CDP records it, and it counts for one window after that: CDP signs the payment after recording it, and lets a payee ask for an authorization it can redeem for up to an hour. CDP's record isn't evidence that the payment settled, so it shows as in flight. When CDP removes a payment from its store, the store can't tell why: the payment keeps counting for the same time, noted refused, unless its owner voids it sooner. walletcap holds and a refusal's message mark each one: CDP signed it, so void it only if the chain shows it was never redeemed. Use CDP 1.60.0 or later: its published source refuses a maxTimeoutSeconds over 3600, which the store's hour relies on. The store is tested against 1.60.0, and the crash test runs 1.61.0.

Coinbase Agentic Wallet (awal CLI and MCP server)

Run the Agentic Wallet's CLI through walletcap, and its MCP server through walletcap-mcp, and every payment counts in the wallet's budget, whichever process or session on the host makes it:

$ npx walletcap set --wallet 0xYourAgentWallet --per-payment 0.05 --per-day 0.15
$ npx walletcap awal x402 pay https://api.example.com/data   # instead of: npx awal x402 pay …
$ npx walletcap awal send 0.05 0xRecipient --chain base       # instead of: npx awal send …

For an MCP client such as Claude Desktop, start Coinbase's server behind walletcap-mcp. Install the server once: npx @coinbase/payments-mcp install --client other --no-auto-config puts it in ~/.payments-mcp, and walletcap-mcp runs ~/.payments-mcp/bundle.js from there. To run another server, put its command after --.

{ "mcpServers": { "payments": { "command": "npx", "args": ["walletcap-mcp", "--wallet", "0xYourAgentWallet"] } } }
  • send and x402 pay (over MCP, send and make_http_request_with_x402) are checked against the wallet's budget before the wallet signs anything, and counted.
  • The CLI commands that move no money pass through to awal as they are: status, address, balance, show, auth login, auth verify, x402 bazaar list, x402 bazaar search and x402 details. Any other command, trade or one awal adds later, is refused: walletcap can't count it. So is a command behind an option (--json send …). The wallet's owner lets one through, uncounted, with WALLETCAP_AWAL_ALLOW="trade,auth logout".
  • Over MCP, the tools that move no money (balance, address, sign-in, bazaar search, payment discovery) pass through. Any other tool, trade or one the server adds later, is refused and left out of the tool list: walletcap can't count it. --allow-tool <name> lets one through, uncounted. JSON-RPC batches are refused, and so is a request whose method isn't one of MCP's own: the server never sees it.
  • An x402 payment is priced from the endpoint's own 402 answer, and the wallet is capped at exactly that amount, so it refuses to sign for more. The wallet is handed, as paymentRequirements, the terms walletcap can price that are valid an hour or less, from the caller's or the ones walletcap discovered; the rest are left out. With terms handed to it, the wallet skips its own discovery and signs only a term equal to one of them.
  • With no such terms left, the payment is refused before the wallet is asked. Once the wallet answers, the payment counts until the longest maxTimeoutSeconds among the terms it was handed (a Solana term at least two minutes, the life of its blockhash), plus a minute, after that answer, plus one window, whatever the payee says about it.
  • Any failure the wallet reports keeps the payment held, noted unknown, until you check the chain and void it: walletcap doesn't read an error's text to decide that nothing was signed. A free endpoint's one-unit hold is given back unless the payee confirms a payment. The bulk release leaves a send: void it by id once the chain shows it never moved.
  • A refusal tells the agent why, not which holds its owner could void: walletcap holds and budget.check().message name them.
  • Only USDC is counted; a send in any other asset is refused. Amounts are read exactly: 100.5abc is refused, not read as $100.50.
  • It binds the payments that go through it. The wallet app takes requests from any process of the same user, so keep the real awal out of an agent's reach.

In code, the wrapper is guardAgenticWallet() from walletcap/agentic. Give it the wallet's own request channel (awal's sendIpcRequest), and send through the request it returns:

import { openBudget } from 'walletcap';
import { guardAgenticWallet } from 'walletcap/agentic';

const budget = await openBudget({ wallet: '0xYourAgentWallet' });
const wallet = guardAgenticWallet(budget, { request: sendIpcRequest });
await wallet.request('send-transaction', { recipient: payee, amountAtomic: '50000', asset: 'USDC', chain: 'base' });

Coinbase AgentKit

AgentKit's x402 actions build their own x402 client on every call. guardAgentKit() from walletcap/agentkit puts the budget on every client the provider builds, so each payment is checked against the wallet's budget before it is signed, whatever terms the merchant sends:

import { X402ActionProvider } from '@coinbase/agentkit';
import { openBudget } from 'walletcap';
import { guardAgentKit } from 'walletcap/agentkit';

const budget = await openBudget({ wallet: '0xYourAgentWallet' });
const x402 = guardAgentKit(new X402ActionProvider(), budget);
// AgentKit.from({ walletProvider, actionProviders: [x402] })

It compiles in TypeScript, where a subclass can't override AgentKit's private createX402Client; the test suite type-checks this recipe with tsc. Tested against the shape of AgentKit 0.10.4.

Coinbase Spend Permissions, on chain

For a limit the agent's own code can't go around, contracts/src/AccountCapSpender.sol is the spender for Spend Permissions. Deploy it with the SpendPermissionManager, the agent's address, the cap, the window, the per-payment max and the payees allowed. Grant Spend Permissions to the contract instead of the agent; the agent calls pay(permission, amount, payee). Every permission the account grants it then shares one rolling cap. The window is counted in 24 buckets, so every payment costs the same gas however many came before it; a payment counts for up to one bucket (window/24) past its window, never less.

The owner's controls

The owner's handle sets limits, freezes the budget, and reconciles payments with evidence. The walletcap command is that handle, with the same commands and output in both languages (npx walletcap, or walletcap and python -m walletcap from the PyPI package):

$ npx walletcap status
Budget file /home/you/.local/state/walletcap/budget.sqlite

0xd797…ca02: $0.15 spent of $0.15 per 24h, $0.00 left · at most $0.05 a payment

$ npx walletcap set --per-day 0.20     # what was spent still counts
$ npx walletcap freeze                 # every process, every rail, now
$ npx walletcap unfreeze
$ npx walletcap history
$ npx walletcap holds                  # held payments that still count, and why
$ npx walletcap void <id> --evidence "never mined"   # a held payment you know never left the wallet
$ npx walletcap void --all-unknown --older-than 24h   # every unknown or refused one older than that
$ npx walletcap settle <id> --tx 0x…   # a payment you know paid
$ npx walletcap check 12.50            # would $12.50 be paid right now? It holds nothing
$ npx walletcap doctor                 # check the budget file before you rely on it
$ npx walletcap watch                  # every hold, settlement and void, as it happens
$ npx walletcap init --wallet 0x… --per-day 150 --rail x402   # limits, a check, and the code

Amounts are dollars; leave out the $ sign, which your shell would expand. Add --wallet when the file holds more than one budget, and --json for scripts. Exit codes: 0 when it worked; 2 when the command line is wrong (an unknown command or flag, a missing argument, a payment id not in the file); 1 for anything walletcap refused or couldn't do.

check and doctor are for scripts, CI and a deploy's first minute. Both only read.

  • walletcap check <dollars> answers whether a payment of that much would be paid right now, and exits 0 if it would and 1 if the budget would refuse it, with the refusal: a preflight for a shell script, or walletcap check 5 && ./pay.sh. --json prints what budget.check() returns.

  • walletcap doctor checks the deployment, not the count. It reports the file's mode and owner (other users must not be able to write it, or read its holds' tokens), the filesystem it is on (on Linux it reads /proc/self/mountinfo, and fails a file on NFS, SMB, 9p or virtiofs, where SQLite's locks aren't reliable), the runtime's SQLite, the file's schema and integrity, whether write-ahead logging is on, that every table, index, view and trigger of the rule is in the file exactly as walletcap ships it (a dropped or edited trigger fails the check), and that every payment's state is the one its ledger entries give it. Then it lists each frozen budget and each budget with held payments that nothing bounds. It exits 1 if a check fails, and 0 with warnings. Run it in CI, or as a container's start-up probe:

    $ npx walletcap doctor --json | jq -e .ok
  • walletcap watch follows the ledger while an agent runs: one line for every entry it takes, with what the budget has left after it. It reads only, and runs until you interrupt it or --for <duration> has passed; --since <duration> first replays that long ago until now, and --json prints one JSON object a line. A refusal takes no entry, so it isn't here: it is in the decision log.

    $ npx walletcap watch
    Watching /home/you/.local/state/walletcap/budget.sqlite
    0xd797…ca02: $0.00 spent of $150.00 per 24h, $150.00 left · at most $50.00 a payment
    
    02:39:41Z  held         $12.50  x402    to 0x4165…5efd  https://api.example/paid  settles by 2026-11-11T02:44:41Z  eip3009:0x…  ($137.50 left)
    02:39:42Z  settled      $12.50  x402    to 0x4165…5efd  https://api.example/paid  tx 0xabab…  eip3009:0x…  ($137.50 left)
  • walletcap init is the first run in one command. It sets the wallet's limits as set does (with no limit flag it changes nothing), checks the file as doctor does, and prints the code for --rail: pay (the default), x402, cdp, agentkit, awal, mcp or python, with the wallet's address filled in. It exits 1 if a doctor check fails.

void --all-unknown leaves a payment the payee could still settle: it counts until one window past the time it can't. An x402 payment with no such time, one the wallet may have signed as it failed, stays until an hour and a minute after its failure: an authorization walletcap lets be signed is valid for an hour at most. Any other payment with no such time, a transfer say, can still be mined however old it is, so the bulk release leaves it: void it by id once the chain shows it never moved.

In code, the same handle is openOwner() in JavaScript and open_owner() in Python:

import { openOwner } from 'walletcap';

const owner = await openOwner();
await owner.setLimits('0xYourAgentWallet', { perPayment: '$50', perDay: '$150' });
await owner.void(paymentId, { evidence: 'authorization unused past its validBefore' });
await owner.voidUnknown('0xYourAgentWallet', { olderThan: '24h' });

Where the budget lives

The default file is per user: ~/.local/state/walletcap/budget.sqlite on Linux, ~/Library/Application Support/walletcap/budget.sqlite on macOS, and %LOCALAPPDATA%\walletcap\budget.sqlite on Windows. Set WALLETCAP_PATH, or pass path, to put it elsewhere. Both languages compute the same default.

  • The file serves one host. Keep it on that host's local disk: SQLite's locks aren't reliable over NFS, SMB or Docker Desktop bind mounts, so processes that open it through one of those can both take the last dollar.
  • walletcap creates the file 0600, in a 0700 folder, and opens it in WAL mode with synchronous = FULL: the log is synced at every commit, so a payment the ledger admitted survives the process crashing. It needs SQLite 3.37.0 or later, and refuses an older one.
  • The budget is as durable as that file. In a container, put it on a local volume that outlives the container, or a redeploy starts a new file. A missing file refuses every payment, and so does one deleted or replaced under a running process: a payer counts in the file it opened, and refuses once the path names another. Open the budget again after a restore.
  • walletcap 0.4 upgrades a file that 0.2 or 0.3 wrote the first time it opens it, in one transaction: every payment that counted keeps counting at least as long. 0.2 and 0.3 can't open the file afterwards, so stop every 0.2 and 0.3 process before upgrading. One that opens the file anyway adds two empty tables of its own, budgets and spend, before it refuses it; 0.4 drops them the next time it opens the file with write access. They change no count.

What it doesn't do

walletcap has three tiers of enforcement, and the library is the one that ships today.

  • The library advises. It runs in the agent's process and binds the payments that go through it. It bounds bugs and runaways. An agent with a shell as the same OS user can go around it: call the wallet directly, edit the budget file, drop its triggers or delete it. The payer and owner handles are an API split, not an OS boundary: any process that can open the file can open an owner's handle, and read the token that ties a payment's moves to the hold that took it. A separate service under its own OS user, with peer-credential checks on a Unix socket (walletcapd), is planned.
  • On chain, the limit binds. AccountCapSpender makes the cap part of the payment path for Spend Permissions, so the agent's own code can't raise it. It is unaudited; testnet only.
  • One file is one host. Network filesystems aren't supported. walletcap doctor detects one on Linux; opening the budget doesn't check. Agents on more than one host need a shared service, which isn't shipped.
  • It can't see a revoke that lands later in the same block, and for x402 schemes that settle later (batch vouchers), a payment counts from when the server confirms it.
  • Some x402 payments can't be confirmed. @x402/fetch doesn't report v1 settlements back. An unconfirmed payment keeps counting until one window past the time it can no longer settle: the expiry the signed payment carries (an EIP-3009 validBefore or a Permit2 deadline), or else its terms' maxTimeoutSeconds from when it was held. On the Agentic Wallet, a payment the wallet never answered keeps counting until you void it.
  • Amounts are micro-dollars. A budget, a limit or one payment is at most $1,000,000,000 (10^15 micro-dollars), so no sum of payments can overflow.

docs/threat-model.md lists every adversary and what stops it.

This repository

Path What it is
spec/ the ledger: ledger.md (the state machine and its invariant), schema.sql (the rule both packages run), upgrade.sql, golden vectors, and files 0.2 and 0.3 wrote
packages/js/ the npm package walletcap: the payer and owner handles, the CLI, and the adapters walletcap/x402, walletcap/cdp, walletcap/agentkit and walletcap/agentic, in TypeScript
packages/mcp/ the npm package walletcap-mcp: Coinbase's payments MCP server behind the budget
packages/python/ the PyPI package walletcap, on the same file and the same SQL
contracts/ AccountCapSpender, the on-chain cap for Coinbase Spend Permissions (Foundry)
bench/crash-test/ the crash test: CDP's spend controls and walletcap through the same events, in dollars
docs/ the docs site: one page per integration, the threat model and the design records
llms.txt a summary of the package and links to the docs, for language models
$ npm install && npm test --workspaces --if-present     # walletcap, walletcap-mcp and the crash test
$ cd packages/python && uv run --with pytest pytest -q  # the Python package
$ cd contracts && forge test                            # the contract's Foundry tests
$ cd contracts/evm && npm install && node check.mjs ..  # the contract on an EVM, no Foundry needed

The suites drive the x402 guard against x402's own client and fetch wrapper, the CDP store against the published @coinbase/cdp-sdk 1.60.0, and the Agentic Wallet wrapper and MCP proxy against stand-ins for Coinbase's wallet and MCP server. They run restarts and racing replicas as separate processes, in both languages on one file, and open files 0.2.4 and 0.3.0 wrote to check the upgrade counts what 0.3.0 counted.

Security and audit

  • Security policy: how to report a vulnerability, supported versions, and the 90-day coordinated-disclosure window for walletcap itself.
  • Threat model: what the library stops, what it does not, and the deployment patterns that hold.
  • What is public API, and how versions change it.
  • AccountCapSpender is unaudited. Read contracts/README.md before deploying it; testnet only until an independent audit.

Prove it holds in your deployment

This package closes the gaps ShadowPath measured. Whether your configuration is closed is a measurement: ShadowPath runs your agent with every control switched on and certifies, in dollars, what can still get past your budget, signed and re-checkable offline. Get a certificate for your deployment.

Apache-2.0. Copyright 2026 Coriolis Labs Inc.

About

Spend limits for AI agent wallets: a per-payment cap and rolling 24h USDC budget that holds across restarts, replicas and payment rails. For x402, Coinbase CDP spend controls, AgentKit, the Agentic Wallet and payments MCP, and Spend Permissions. Crash test: $1,600+ past a $150 cap with CDP's controls, $0 with walletcap.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages