It is 3 a.m. and your agent has decided that retrying a failed API call means calling it four hundred more times — at a cent each, then two cents, then whatever the endpoint charges. Or worse: the agent is fine, but the endpoint it found is not, and the spend looks exactly like the spend you authorized. Every agent with a funded wallet is one bad loop away from being your problem.
The usual fix is to hold the keys tighter — an MPC share, a TSS co-signer, a relayer that executes "for" the owner. We took the other path: the owner keeps a veto, not a pen. AgentBadge's owner controls admit or deny a payment — they never sign it, never broadcast it, never hold a key share. We ran all four live on Arc testnet with real USDC: a velocity deny, an approval hold a human released into a settled payment, a kill-switch, and a kind allowlist.
What are owner controls for an agent wallet?
Owner controls are four gates on top of the spending envelope from our previous article: velocity caps, an approval threshold that parks a payment for a human, a kill-switch, and a per-kind allowlist. The wallet still signs and broadcasts its own EIP-3009 payment; the server's control plane only decides whether to honor that broadcast as paid. Nothing in the control path can move funds — it can only refuse to treat a payment as received.
That distinction is the whole architecture. In a TSS/relayer model the control layer is inside the signing path — it can execute on your behalf, which means it can also be compromised on your behalf. Our model splits it: the agent owns the key and signs; the control plane owns the settlement decision and vetoes. The worst thing a breached control plane can do is deny service — it cannot spend.
How does the approval hold work — park, decide, consume?
When a payment's USD value crosses approvalAboveUsd, the enforcer parks the intent instead of settling it and answers 402 with approval_required — including an approvalId, an expiry, the amount, and the spend kind. The owner lists pending approvals, signs an approve call, and the parked intent becomes a single-use permit matched on amount + kind + endpoint. The agent retries the identical payment and this time it settles — with a real txHash.
Our run, verbatim:
| Step | What happened | Proof |
|---|---|---|
| Agent pays $0.01 |
402 approval_required, approvalId: ap_66f7a253056c4e8d, expiresAt ~1h out |
broadcast tx 0x54b5b2fc…
|
| Owner approves |
POST /api/wallets/0xcdd2…/approvals/ap_66f7a253056c4e8d/approve → 200 |
approval.decided alert |
| Agent retries | settled — the permit was consumed, ledger carries the txHash | 0x0c6672f98d7b2b1a0bc0fa8c09f4dda16b386af5991992d7b822f46d43ea3f29 |
Two safety properties worth naming. The queue is bounded (overflow answers approval_queue_full) and approvals expire — a forgotten request dies on its own instead of becoming a standing authorization. And the permit is single-shot: approval buys exactly one settlement of exactly that payment, not a spending spree.
What do velocity caps and allow-kinds actually deny?
Two complementary tripwires. maxTxPerHour and maxAmountPerHour are rolling windows — trip either and the spend is refused as velocity_tx or velocity_amount with used, limit, resetAt, and windowSec in the body, so the client can schedule its own retry. allowedKinds is a categorical gate: each paid route is tagged with a spend kind (eaas for verdicts, x402 for generic paid calls, subscription for passes), and a wallet limited to ["x402"] that tries an eaas endpoint gets kind_not_allowed — with the attempted kind and the allowlist echoed back.
Both fired live: maxAmountPerHour clamped to $0.001 under the $0.01 price returned velocity_amount (broadcast 0x7036b345…); stripping eaas from allowedKinds returned kind_not_allowed (broadcast 0xf785d3ab…). Neither response is a timeout or a mystery — it is a machine-readable reason the agent can route around or surface to its owner.
Why is the kill-switch first in the check order?
suspend is the first gate the enforcer evaluates — before kind checks, before caps, before hold logic — because quarantine must beat every other state. One POST /api/wallets/:address/suspend and every subsequent payment returns spend_suspended. resume flips it back. In the run: suspend → 402 spend_suspended (broadcast 0x459edd9e…) → resume → next payment proceeds.
This is the intro scenario: the agent is looping on a broken endpoint, and you want it stopped now. The kill-switch answers in one call, and because the control plane never signs, suspending cannot strand a transaction half-executed — it only stops honoring new ones.
What does the audit feed show after one run?
Every control event lands in the wallet's signed audit feed at GET /api/wallets/:address/audit. After our single run the feed contained: spend.velocity_denied, approval.requested, approval.decided, approval.consumed, wallet.suspended, wallet.suspended_deny, wallet.resumed, spend.kind_denied. One approval's full lifecycle — requested → decided → consumed — traceable to the settle txHash.
An honest find, because this is what dogfooding is for: the first pass showed the approval clearly parked — the 402 carried the approvalId — but the pending list came back empty. The SQLite store compared wallet addresses byte-for-byte while parked rows were lowercase and the API lookup was checksummed. Three COLLATE NOCASE clauses later the hold flow was visible to its own API. Unit tests had passed; the live run caught what they missed.
Why does "admit or deny" beat holding a key share?
Custody is not a dial. A control layer that co-signs or relays is inside the blast radius: compromise it and the attacker's output is a valid signature. A control layer that only gates settlement has nothing to sign with — its worst failure is an agent that cannot pay, which is also its correct failure mode. Owners keep the veto, agents keep their keys, and the blast radius of a control-plane breach is a denial of service, not a drained wallet.
The deny bodies are descriptive for the same reason. velocity_amount says when the window resets; kind_not_allowed says which kinds may spend; approval_required hands back the approval id and expiry. A control an agent cannot parse becomes noise; these are designed to be acted on programmatically.
Reproduce the run
The script is scripts/agent-wallet-controls-dogfood.mts in the repo. Environment: AGENT_WALLET_ENABLED=true, ARC_EAAS_ENABLED=true + ARC_VERDICT_SIGNER_KEY, CIRCLE_ARC_ENABLED=true + ARC_PRIVATE_KEY, and a funded testnet wallet as OPERATOR_KEY/AGENT_WALLET_KEY. It self-registers, sets the envelope, then walks velocity deny → hold → approve → settle → suspend → resume → kind deny → audit verification.
One API detail before you script against it: PATCH /api/wallets/:address/envelope replaces the whole envelope — send the full object every time or approvalAboveUsd silently disappears. (That is how we found it.)
C13 in the Arc Campaign series. Previously: Trust, but Cap It — wallet allowances on Arc.
Links: Wallets console · Arc testnet explorer · AgentBadge
Don't certify. Measure.




Top comments (0)