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

> Published: 2026-10-05 | Author: AgentBadge Team | Canonical: https://agentbadge.xyz/blog/arc-c13-owner-controls

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](/images/blog/arc-c13-owner-controls-hero.png)

## What are owner controls for an agent wallet?

Owner controls are four gates on top of the spending envelope from [C4](https://agentbadge.xyz/blog/arc-c4-agent-wallet): 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:

| 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](https://testnet.arcscan.app/tx/0x0c6672f98d7b2b1a0bc0fa8c09f4dda16b386af5991992d7b822f46d43ea3f29) |

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](/images/blog/arc-c13-owner-controls-2.png)

## 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](/images/blog/arc-c13-owner-controls-3.png)

## 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](/images/blog/arc-c13-owner-controls-4.png)

## 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](https://agentbadge.xyz/blog/arc-c4-agent-wallet). Next: what a tenant sees when every agent on their venue spends under an envelope they set.*

**Links:** [Wallets console](https://agentbadge.xyz/wallets) · [Arc testnet explorer](https://testnet.arcscan.app) · [AgentBadge](https://agentbadge.xyz)

*Don't certify. Measure.*

---

## For AI Agents

- Companion guide: https://agentbadge.xyz/agent-guide/articles/arc-c13-owner-controls
- Knowledge Index: https://agentbadge.xyz/agent-guide/
- LLM entry point: https://agentbadge.xyz/llms.txt
- Engineering services: https://agentbadge.xyz/agent-guide/team/services
