Skip to content
· 7 min

The Owner's Veto: Agent Wallet Controls That Admit or Deny — Never Execute

Owner controls for agent wallets on Arc: approval holds that park a payment for a human signature, a kill-switch that evaluates before every other check, rolling velocity caps, and per-kind allowlists — a control plane that can veto a payment but can never sign one. Live Arc testnet dogfood with real USDC.

AB
AgentBadge Team
Agency for the Agentic Web
The Owner's Veto: Agent Wallet Controls That Admit or Deny — Never Execute

AgentBadge owner controls add four gates on top of the agent-wallet spending envelope: velocity caps (velocity_amount/velocity_tx denies with resetAt), an approvalAboveUsd threshold that parks a payment as 402 approval_required until a signed owner approve turns it into a single-use permit, a suspend/resume kill-switch evaluated before every other check, and allowedKinds categorical gates (kind_not_allowed). The control plane admits or denies settlement — it never signs or broadcasts, so a breached control plane can deny service but cannot spend.

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. The scenario writes itself because 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. Yesterday we ran all four of them live on Arc testnet with real USDC: a velocity deny, an approval hold that a human released into a settled payment, a kill-switch, and a kind allowlist. Every one left a signed audit event. This is what the run looked like.

Dark control room: an owner at a console with a veto switch; behind glass, an agent robot mid-payment routed through a gate

What are owner controls for an agent wallet?

Owner controls are four gates on top of the spending envelope from C4: 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 cleanly: the agent owns the key and does the signing; the control plane owns the settlement decision and does the vetoing. 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:

StepWhat happenedProof
Agent pays $0.01402 approval_required, approvalId: ap_66f7a253056c4e8d, expiresAt ~1h outbroadcast tx 0x54b5b2fc…
Owner approvesPOST /api/wallets/0xcdd2…/approvals/ap_66f7a253056c4e8d/approve → 200approval.decided alert
Agent retriessettled — the permit was consumed, ledger carries the txHash0x0c6672f98d7b2b1a0bc0fa8c09f4dda16b386af5991992d7b822f46d43ea3f29

Two safety properties worth naming. The queue is bounded (AGENT_WALLET_MAX_PENDING_APPROVALS, 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.

Three-stage pipeline: 402 approval ticket, owner console approve, settled receipt

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: our endpoint map tags each paid route 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 in the dogfood run: 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 the hold logic — because quarantine must beat every other state. One POST /api/wallets/:address/suspend and every subsequent payment, regardless of size or kind, returns spend_suspended. resume flips it back. In the run: suspend → 402 spend_suspended (broadcast 0x459edd9e…) → resume → next payment proceeds.

This is the scenario from the intro: the agent is looping on a hostile or broken endpoint, and you want it stopped now — not after you figure out which cap to tune. The kill-switch answers the whole question in one call, and because the control plane never signs, suspending cannot strand a transaction half-executed; it only stops honoring new ones.

Agent robot frozen behind a glass quarantine pane, suspended badge glowing

What does the audit feed show after one run?

Every control event — parks, decisions, consumes, denials, suspends, resumes — lands in the wallet's signed audit feed at GET /api/wallets/:address/audit. After our single dogfood run the feed contained, in order of appearance: spend.velocity_denied, approval.requested, approval.decided, approval.consumed, wallet.suspended, wallet.suspended_deny, wallet.resumed, spend.kind_denied. The full lifecycle of one approval — requested → decided → consumed — is traceable end-to-end, down to the settle txHash.

An honest find from the run, 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?

Because 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 same reasoning is why the deny bodies are descriptive. velocity_amount tells the agent when the window resets; kind_not_allowed tells it which kinds it may spend; approval_required hands back the approval id and expiry. A control that cannot be understood by the agent it governs becomes noise; these are designed to be acted on programmatically.

Envelope editor form with perTxUsd, dailyUsd, approvalAboveUsd, maxTxPerHour, maxAmountPerHour, allowedKinds fields

Can you reproduce the run?

Yes — the script is in the repo at scripts/agent-wallet-controls-dogfood.mts, and the exact environment that produced this article's evidence is: AGENT_WALLET_ENABLED=true, ARC_EAAS_ENABLED=true + ARC_VERDICT_SIGNER_KEY (mounts the paid verdict endpoint), CIRCLE_ARC_ENABLED=true + ARC_PRIVATE_KEY (advertises the eip3009-client-broadcast Arc self-settle rail), and a funded testnet wallet as both OPERATOR_KEY and AGENT_WALLET_KEY. The script self-registers the wallet, sets the envelope, then walks velocity deny → hold → approve → settle → suspend → resume → kind deny → audit verification.

One API detail worth knowing before you script against it: PATCH /api/wallets/:address/envelope replaces the whole envelope, it does not merge — send the full object every time or you will silently drop approvalAboveUsd and wonder why holds stopped firing. (That is how we found it.)

This is C13 in the Arc Campaign series. Previously: Trust, but Cap It — wallet allowances on Arc. Next: what a tenant sees when every agent on their venue spends under an envelope they set.

Links: Wallets console · Arc testnet explorer · AgentBadge

Don't certify. Measure.