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 (1)
Your intro names the case these four gates do not cover: the agent is fine, but the endpoint it found is not, and the spend looks like one you authorized. Velocity, approval, kill switch and kind all look at the agent and the amount; none looks at who receives the money. A fifth gate at the same point in the check order could do that: screen the payTo before admitting, and deny with a reason in the same style, something like payee_flagged, with the list and its version date in the body so the agent can act on it. It fits your model well because it only ever denies, never signs.
Disclosure: we run a signed payee screening service for x402 (OFAC, UN, EU and UK lists), so this is the part I think about most.