An Agent Wallet Must Account for the Authority It Leaves Behind
· 13 min read
- architecture
- custody
- agents
An Agent Wallet Must Account for the Authority It Leaves Behind
Prepared from retained primary sources and a local executable model. No production wallet or live payment service was exercised.
The agent stops, the allowance survives
A wallet holding 100 token units pays out 20 after its owner revokes the agent. The agent's own request to pay 1 fails. A separate spender takes the 20 using an allowance that the owner had approved earlier. That's the first local test here.
The owner had given two actors different rights. The agent could make direct payments through the wallet, and the spender could withdraw through the token contract. Revoking the agent closes the first route. The second route still has its own permission. I would want both routes visible before accepting any claim about how much value a compromised agent can reach.
A grant is authority to spend. An ERC-20 allowance is one such grant, recorded by the token contract for a particular owner and spender. The spender uses transferFrom to draw on it, within the available allowance and balance. The ERC-20 specification defines that withdrawal route separately from a direct transfer. For this decision, custody means the authority to cause an asset to leave its owner. It includes the authority that an account has already lent to someone else.
That make the scope of revocation a concrete design question. Saltzer and Schroeder's complete mediation principle requires an authority check on each access, and requires remembered authorization decisions to be updated when permissions change. I apply that principle here by following the spending permission across the wallet and token contracts. The local cases below make the distinction observable. A rejection at the wallet can coexist with a successful withdrawal at the token.
For autonomous spending from a shared balance, I would enforce the spending grant at the execution boundary and account for authority already granted elsewhere. A separately funded account is the strongest alternative when the owner can accept losing everything it can reach. That alternative gets much less convincing if the account can refill itself or spend assets held elsewhere.
Follow the allowance past revocation
The demonstration uses Solidity 0.8.30, Cancun and OpenZeppelin ERC20 v5.4.0. The owner stays trusted throughout. The agent and spender are local contracts that forward calls, and the tests execute those calls sequentially. They exercise authority checks without implementing a production signer or credential system.
The three local scenarios each start with a fresh wallet. In the prior allowance scenario, the trusted owner calls Wallet.approveSpender with address(spender), giving the spender an allowance independently of the agent's grant. In the address only scenario, the agent calls AddressOnlyWallet.execute, which calls token.approve with address(spender) on a separate fresh wallet. The spender is distinct from the agent in both cases, but the allowance in the second case comes from using the agent's authority. So recovery in the first case has to account for a separate owner grant, while recovery in the second has to account for authority the agent created for another contract. The cumulative payment scenario starts with another fresh Wallet and no spender allowance. Its agent payment requests share the same Wallet.spent counter.
The first scenario preserves an independent owner grant. The fresh wallet holds 100 token units. The agent has a direct payment cap of 10, and the owner approves a separate spender for 30. The owner then revokes the agent. In the reduced model, revoking the agent leaves an earlier token allowance usable by its spender.
The attempted direct payment of 1 fails at the wallet's agent check. The spender then calls the token through transferFrom and withdraws 20. The wallet ends with 80, while the token records 10 of remaining allowance. Both results follow the grants that the owner made. I wouldn't call the owner's allowance an escape from the agent's cap, because the owner created that permission independently. I would reject an account recovery claim that overlooked it.
The excerpt shows the wallet and that first test. Its omitted setup supplies the token, the forwarding actors and the fresh wallet funded with 100.
Role: reduced executable model, excerpt from the executed local harness.
contract Wallet {
address public immutable owner;
address public immutable agent;
Token public immutable token;
uint256 public immutable limit;
uint256 public spent;
bool public revoked;
constructor(Token t, address a, uint256 cap) {
owner = msg.sender;
agent = a;
token = t;
limit = cap;
}
modifier onlyOwner() { require(msg.sender == owner, "OWNER"); _; }
modifier onlyAgent() { require(msg.sender == agent && !revoked, "AGENT"); _; }
function pay(address to, uint256 amount) external onlyAgent {
require(spent + amount <= limit, "BUDGET");
spent += amount;
require(token.transfer(to, amount));
}
function revoke() external onlyOwner { revoked = true; }
function approveSpender(address spender, uint256 amount) external onlyOwner {
require(token.approve(spender, amount));
}
}
// ... omitted whole lines ...
function testPriorAllowanceSurvivesRevocation() public {
Wallet w = fresh();
w.approveSpender(address(spender), 30);
w.revoke();
(bool paid,) = address(agent).call(abi.encodeCall(agent.pay, (w, address(spender), 1)));
require(!paid, "revoked agent still paid");
spender.pull(token, address(w), 20);
require(token.balanceOf(address(w)) == 80, "prior allowance pull missing");
require(token.allowance(address(w), address(spender)) == 10, "allowance accounting");
emit log("prior allowance: funded 100, approved 30, revoked agent, pulled 20, balance 80, allowance 10");
}Authority paths in the model
owner -> wallet grant -> agent -> wallet.pay -> token.transfer
owner or admitted agent -> token allowance -> spender -> token.transferFromThe first line of the diagram ends in transfer, called by the wallet. The second ends in transferFrom, called by the spender. The token can honor the second permission without entering Wallet.pay or reading the wallet's revoked flag.
The clearing control starts from a fresh wallet with the same balance of 100 and the same owner approval of 30. The owner revokes the agent and sets the spender's allowance to zero before any withdrawal attempt. The spender's request for 20 then fails, and the wallet still holds 100. This is a matched comparison with the first case. The failed withdrawal follows a completed clear, so neither a depleted balance nor an allowance consumed by an earlier withdrawal explains the result.
The second scenario lets the agent create the allowance. A separate AddressOnlyWallet starts with 100 and retains the direct payment cap of 10. It adds an execute function that accepts arbitrary call data when the target is the token's address. That function checks the destination contract but leaves the operation and approval amount unrestricted.
The agent uses execute to call approve for 30. The owner then revokes the agent, and the spender withdraws 20. The wallet holds 80 after the withdrawal, but its direct payment counter is still zero. The approval has created another spending route, and the spender can use it after the agent loses access to the wallet.
Here I would reject the execution surface if the promised total budget were 10. The agent has caused 20 to leave the shared balance without consuming that budget. Restricting the target to the token address doesn't resolve the problem in this model. The same target accepts an operation that gives a second actor authority to spend. The relevant question is what the admitted call can authorize, including what can happen after that call returns.
That distinction also changes what revocation owes the owner. In the first scenario, the owner independently granted the spender its allowance. In the second, the agent created the allowance through its own permitted execution path. I would include that second allowance in the rights that must be terminated to finish revoking the agent's grant.
The third scenario makes direct payments share a budget. Another fresh wallet holds 100, has a cap of 10 and has no spender allowance. The agent submits a payment of 6, then another payment of 6. The first succeeds and the second fails. After spending 6, the remaining budget is 4. Accepting the next 6 would bring the total to 12, beyond the cap of 10. The rejected request leaves the wallet balance at 94 and the direct payment counter at 6.
The pay function enforces that cumulative limit by checking spent + amount and adding the accepted amount to spent before transferring the tokens. If you checked each request against 10 independently, each request for 6 would pass. The arithmetic would still total 12.
I need both properties for the shared balance decision. Direct payments must consume the same budget, and any admitted operation that creates another spending right must be included in the authority being controlled. The second scenario shows why the direct payment counter can't stand in for the whole account. Its value is zero after the agent has arranged a withdrawal of 20.
Choose how much authority to lend
A separate funded account avoids much of this work when the whole reachable loss is acceptable. If the owner can place the job's entire working balance in that account, the agent can exhaust it and still remain inside the loss that the owner chose. That reasoning depends on the account having no replenishment, no credit and no authority over other assets. I would choose that arrangement for a finite job that fits those conditions. The owner can bound the loss through the assets exposed to the agent, without having to understand every call that the agent might make.
That is a conditional architecture argument from balance and allowance semantics. The local experiment doesn't test an isolated account deployment. The isolation premise belongs to the actual integration. An account that looks small but can replenish itself has access to more value than its current balance shows. An account with permission to spend another account's assets has the same problem.
A shared balance gives a different answer. If the job authorizes 10 from an account holding 100, accepting loss of the whole account changes the permission that the owner intended to give. I would keep the smaller spending grant and make the admitted operations explicit.
Coinbase Spend Permissions restrict the delegated operation to spending native currency or ERC-20 tokens. Coinbase's design overview at the retained revision describes the spender calling SpendPermissionManager. The manager checks the approved allowance and then calls the user's account to transfer the tokens. The documented permission excludes arbitrary external calls by the delegated app.
I prefer that narrow shape when the agent's job is payment. It keeps the delegated action small enough to name, and the allowance check sits on the route that performs the spend. My comparison adds a separate question about the surrounding account. Which other admitted routes can grant or exercise authority over the same balance? The local AddressOnlyWallet fails that question because its token call can create an allowance outside the direct payment budget.
But Coinbase's SpendPermissionManager becomes an owner of the user's smart wallet in the documented design at commit e0004e63edc4e17de7aa978293800ac7a16892e5. The user gives the manager authority to move wallet funds and relies on its permission logic to restrict what an application can spend. Those constraints still narrow the application's requests, but the owner role puts the manager among the components that the user must trust. So I'd assess the manager's authority alongside the spending constraints when deciding whether to rely on this integration.
Coinbase is a documented reference for the narrower operation in this comparison. The local wallets are separate demonstration contracts. I draw no conclusion about a deployed Coinbase vulnerability from their behavior.
The operator inherits the grant inventory
The operator pays for a restricted spending surface by reviewing each new action before the agent can use it. Approval is the concrete example here. Admitting the token address permits more behavior than admitting a direct transfer, because approve lets the agent leave a spending right with another actor. I would require that difference to be resolved when the action is admitted, while the operator can still decide how much authority to lend.
That review also determine what the operator must retain for recovery. I would keep the token, spender and origin of each admitted allowance visible, together with the authority needed to clear it. The origin distinguishes an allowance created through the agent's grant from a separate permission that the owner chose to give. Without that distinction, an operator can't say which surviving rights belong to the revoked grant.
The first scenario shows why I wouldn't erase all owner permissions as a definition of agent revocation. Its allowance of 30 came directly from the owner. Whether that allowance should remain is a separate recovery decision. If the owner's recovery goal is to stop every outgoing path from the account, I'd include that independent allowance in the review as well. The local clear demonstrates how the owner closes that particular spender path.
The second scenario gives a stricter obligation. The agent's admitted call created the allowance, so disabling the agent's entry point leaves behind authority derived from the grant being revoked. Revocation is complete only across the spending paths that the revoked grant could create.
For this model, I would make the recovery record cover the revoked flag, the relevant token allowances and the resulting balance. Those three observations answer different questions. The flag shows that the agent's wallet entry is closed, the allowance shows what the spender may still withdraw, and the balance shows the funds that remain. A balance of 80 beside an allowance of 10 means that another spending right still exists, even though the agent has stopped.
The owner must retain the independent ability to revoke and clear those grants. That owner authority is trusted in every local scenario. I would make recovery part of the action review, because admitting a grant also creates the obligation to understand how it ends.
The clearing control establishes one ordering. The owner finishes revocation and clearing before the spender tries to withdraw. I wouldn't interpret it as a guarantee that submitting a clearing transaction prevents an earlier withdrawal on a live chain. Chain reordering and competing transactions weren't exercised. The operator's recovery judgment has to use the completed state.
Use the smaller boundary when it closes
If a separate funded account contains the entire acceptable loss and has no replenishment or authority over other assets, I would choose that balance boundary first. Credit also has to be absent for that containment argument to hold. Under those conditions, the owner has already accepted losing the whole reachable balance, so closing every residual spending route isn't needed to cap that chosen loss.
The executable cases establish behavior in a reduced local model with an ordinary token, and leave production integration security untested. The retained run passed seven tests, including the three spending scenarios, the clearing control, a zero allowance control, an unauthorized caller and spending exactly at the cap. The tests leave malicious or callback tokens, permit signatures, owner compromise, chain reordering and provider backends outside their reach.
For a shared balance, I'd judge the total grant against every admitted route that can spend or create a spending right. The direct cap of 10 is useful only for the operations it actually controls. In the allowance scenario created by the agent, the wallet has already paid out 20 while that counter still reads zero.
Abdel KIARI
I’ve owned the EVM side of a DeFi protocol with $110M+ in funding, including backing from Coinbase Ventures. I redesigned its architecture from scratch and built the Solidity infrastructure end to end.
Always happy to talk about interesting opportunities
abdel.kiari@gmail.com