Recurring USDC billing that never holds the money.
Retainer charges on schedule against Base spend permissions. Funds move customer → router → merchant in one atomic transaction, and the router's balance is zero after every charge. A billing engine, not a wallet.
// 1. The customer signs once. Retainer registers it on-chain and pays the gas.
const permission = await requestSpendPermission({
account,
spender: RETAINER_ROUTER, // 0x3370…cC79, verified on Basescan
token: USDC,
allowance: 20_000_000n, // 20 USDC per period — a ceiling, not a price
periodInDays: 30, // resets on-chain; nothing carries over
extraData: encodeExtraData(executor, merchantTreasury),
provider,
});
// 2. Charge whatever this period actually cost, up to the cap, when it's due.
await enqueueCharge({ permission, amount: usageThisPeriod });
// 3. "Paid" means the on-chain events say so — never that a transaction was sent.How a charge works
One signature from the customer. Everything after that is on-chain-enforced.
The customer signs a bounded permission
A Base spend permission names the spender, the token, an allowance per period, a period length, a start and an end. The cap resets on-chain at every period boundary and unused allowance does not carry forward. The customer can revoke at any time, and revocation takes effect the block it's mined. None of this is enforced by Retainer — the contract does it.
Retainer registers it and pays the gas
approveWithSignature is permissionless, so our executor submits it. The customer signs, never transacts.
Funds never rest with Retainer
A charge is one transaction: the router pulls from the customer and forwards to the merchant treasury atomically. If the forward fails, the whole thing reverts. The router's USDC balance is zero after every charge — asserted by a fuzz test that fails the build, and observed on-chain after every charge we've run.
The amount is decided at charge time
Fixed or metered. A usage charge is enqueued with no amount and computed from recorded usage when it runs — anything up to the cap.
What the engine guarantees
Each of these has been triggered deliberately on testnet, with the transaction hashes in the repo.
Exactly-once charging
The signed transaction is persisted with its hash before it is broadcast. If the process dies after the send, recovery finds the receipt. If another transaction took the nonce, the attempt is marked superseded and retried fresh. Both branches were forced deliberately; each produced exactly one on-chain spend.
Failures classified before gas is spent
Revoked, expired, insufficient balance, allowance exhausted, not started, not approved — read from chain state before broadcasting, each with its own disposition. Revoked and expired are terminal; the rest know when to try again.
Paid means reconciled
A charge is confirmed only when both the manager's SpendPermissionUsedand the router's SpendRouted events are indexed for that transaction, and the router paid the recipient we intended. Every confirmed row maps to one transaction hash.
A routed charge is roughly 135–150k gas. At the Base fees we measured, that is about half a cent per charge — under 0.4% of a $3 subscription, where card rails take more than ten percent.
The contracts
Nothing here is ours to audit. We deploy an unmodified instance of Coinbase's audited router.
- SpendRouter · Retainer's instance
- 0x337099eE403C090388A66cc9370F7b0Fe4CDcC79
Verified source on Basescan. Deployed by us, logic untouched.
- SpendPermissionManager · canonical
- 0xf85210B21cC50302F477BA56686d2019dC9b67Ad
Not redeployed. Its on-chain EIP-712 typehash matches the source we vendor.
- Source
- github.com/coinbase/spend-permissions
coinbase/spend-permissions @ e0004e6, vendored verbatim. A script re-checks it against upstream by sha256.
- Network
- testnet only
Base Sepolia, chain 84532. There is no mainnet deployment.
Questions worth asking
Answers are only as good as what the repo can back.
Status
Custody
Charging
Read it, run it, or sign a permission yourself.
Below are the six confirmed charges from the test drills and the crash-recovery runs — real Base Sepolia transactions. Every hash links to Basescan. Requested and settled amounts are shown side by side so the match is checkable, not asserted.
| Charge | Amount | Requested | Settled on-chain | Block | Transaction |
|---|---|---|---|---|---|
| #1 | fixed | 1.000000 | 1.000000 USDC | 46565605 | 0x91a4980a…8a1e7c |
| #8 | fixed | 1.500000 | 1.500000 USDC | 46565695 | 0x35043fd1…8aaa93 |
| #9 | usage · computed at charge time | 1.250000 | 1.250000 USDC | 46565760 | 0x55c0e2b5…9bea3d |
| #10crash A | fixed | 1.100000 | 1.100000 USDC | 46565785 | 0x8e4ce873…68e2df |
| #11crash B | fixed | 1.300000 | 1.300000 USDC | 46565838 | 0xe0ac2a1e…b5244d |
| #12 | fixed | 0.900000 | 0.900000 USDC | 46566640 | 0xa9043281…545103 |
| Sum of settled amounts | 7.050000 USDC | equals the treasury balance; router balance 0 — checked 2026-09-09 | |||
Crash recovery A · killed after the send, before the row updated
Recovery found the receipt on-chain. Exactly one spend.
- the one transaction (charge #10, nonce 22)
- 0x8e4ce873…68e2df
Crash recovery B · killed after signing, before the send; nonce then taken by another transaction
Recovery marked the attempt superseded and did not re-broadcast. Charged once on a fresh nonce.
- superseded attempt — never landed, by design
- 0x0a078798…9131a3not on Basescan — it was never mined
- competing transaction that consumed nonce 24
- 0x21cd8eb3…22c32d
- the one spend (charge #11, nonce 25)
- 0xe0ac2a1e…b5244d