Spend permissions
The primitive Retainer is built on. Most readers won't have met it, so this page explains it properly.
A spend permission is a signed message from a Base Account that lets a named spender pull a named token from the account, up to an allowance per period, between a start and an end. Coinbase'sSpendPermissionManager contract enforces every one of those bounds; the spender cannot exceed them, and Retainer does not need to be trusted to respect them. contracts/src/SpendPermissionManager.sol:28
What the customer signs
struct SpendPermission {
address account; // the customer's Base Account
address spender; // who may pull: Retainer's router
address token; // USDC
uint160 allowance; // maximum per period
uint48 period; // seconds; the allowance resets on this cadence
uint48 start; // valid from (inclusive)
uint48 end; // valid until (exclusive)
uint256 salt; // makes otherwise-identical permissions distinct
bytes extraData; // consumed by the spender: for the router, (executor, recipient)
}The five properties that matter
| Property | What it means | Where the contract does it |
|---|---|---|
| Bounded per period | The spender can pull any amount, in any number of transactions, until the period's total reaches allowance. The next pull reverts with ExceededSpendPermission. | _useSpendPermission |
| Self-resetting | Periods are fixed intervals from start. When time enters a new period the used amount resets to zero. Unused allowance does not carry forward. | getCurrentPeriod |
| Revocable | The customer calls revoke; the spender can also call revokeAsSpender. It takes effect in the block it is mined, and it is permanent — there is no un-revoke. | contracts/src/SpendPermissionManager.sol:397 |
| Expiring | end is a hard stop. Pulls after it revert with AfterSpendPermissionEnd; pulls before start revert with BeforeSpendPermissionStart. | contracts/src/SpendPermissionManager.sol:516 |
| Enforced on-chain | All of the above is contract logic. Retainer holds a key that can ask; the manager decides. | spend |
How the reset actually works
From Coinbase's own accounting note, at the commit Retainer vendors: “As apps spend user assets, the recurring logic automatically increments and enforces the allowance for the current period. Once enough time passes to enter the next period, the allowance usage is reset to zero and the app can keep spending up to the same allowance.”The start time and period “set a deterministic schedule infinitely into the future”; the end time “does not have to correlate with a clean period boundary.”upstream docs/SpendPermissionAccounting.md @ e0004e6
Registration is permissionless — so the customer never pays gas
Signing is off-chain. The permission becomes usable when someone submits approveWithSignature(permission, signature), and that function has no caller restriction: anyone holding a valid signature can submit it, and it is ERC-6492-compatible, so it works even if the customer's account is not yet deployed. Retainer's executor submits it and pays.contracts/src/SpendPermissionManager.sol:293
What the cap does to billing
This is the constraint the rest of Retainer is shaped around. The allowance is a ceiling the customer sets before the charge amount is known. A metered bill above the cap cannot be collected, and there is no increaseAllowance: raising the cap means a new permission and a new signature. A charge that fails because the cap is exhausted is not retried until the next period, because retrying sooner cannot succeed. packages/chain/src/classify.js:53
spend rolls back the accounting in the same transaction, so a failed charge consumes no allowance. It does consume gas. Retainer therefore checks chain state before broadcasting and almost never sends a transaction it expects to revert.contracts/test/retainer/RouterCustodyInvariant.t.sol:72The account must be a Base Account
The manager moves funds by calling execute()on the customer's account, so the account must be a smart account that lists the manager as an owner. A plain externally-owned wallet cannot be the payer. Coinbase's wallet adds the manager as an owner inside its approval flow; a scripted account has to do it explicitly, and if it doesn't, every charge reverts.README — the non-obvious prerequisite
- Manager, canonical on Base and Base Sepolia:
0xf85210B21cC50302F477BA56686d2019dC9b67Ad - Its on-chain EIP-712 typehash equals
keccak256of the type string in the vendored source, so the code Retainer reads is the code it transacts against. test_typehash_matchesDeployedManager