Custody model
Retainer holds the executor key, which can trigger a charge, and never holds funds. This page is the argument and the test that backs it.
Customer → router → merchant, in one transaction
executor (Retainer's key)
│ spendAndRoute(permission, value) msg.sender must equal extraData.executor
▼
SpendRouter
│ PERMISSION_MANAGER.spend(permission, value)
│ └─ manager executes on the customer's account: approve + transferFrom → router
│ emit SpendRouted(account, executor, recipient, hash, token, value)
│ _routeTokens(token, recipient, value) forwards exactly `value` to the merchant treasury
▼
merchant treasuryAll of that is one transaction. If the forward to the recipient fails, the whole call reverts, including the pull from the customer. There is no state in which the router has received funds and not yet forwarded them. contracts/src/SpendRouter.sol:114
The invariant, as a test that fails the build
RouterCustodyInvariant.t.solasserts the router's token balance is zero before and after a charge, that a sequence of charges accretes no dust, that the executor is never enriched, that a reverted charge leaves no residue and consumes no allowance, and that only the encoded executor can trigger a charge. Each property runs 256 fuzz iterations. If any fails, the build fails.
| Test | Asserts |
|---|---|
test_routerHoldsNothing_afterSingleCharge | router balance 0 after; recipient received exactly the value |
test_routerHoldsNothing_afterManyCharges | router balance 0 after every charge in a sequence of up to 20 |
test_executorNeverReceivesFunds | executor token and native balances unchanged |
test_revertedChargeLeavesNoResidueAndNoAllowanceSpent | after a revert: router 0, recipient 0, period spend 0 |
test_onlyEncodedExecutorCanCharge | any other caller reverts and moves nothing |
contracts/test/retainer/RouterCustodyInvariant.t.sol:24 — passing under both the default profile and the optimiser profile the deployed bytecode was built with.
And on the live chain
After the six confirmed Phase 1 charges, the merchant treasury held exactly their sum — 7,050,000 base units, 7.05 USDC — and the router and executor held 0 USDC. Those balances were read live before being written here, on 2026-09-09. They are on Base Sepolia for anyone to check.
- router:
0x337099eE403C090388A66cc9370F7b0Fe4CDcC79 - merchant treasury:
0x47dB2024f26E4bDe719178F567785d8A10106A67 - executor:
0xE3Fe27364750d62Cbb14fd648E8cf451eF227087
A structural consequence: no fee in the flow
The router encodes exactly one recipient and forwards the full value. There is no fee split. Retainer cannot take a percentage of a charge without becoming the recipient and forwarding — which is precisely the custodial path this design rules out. Any Retainer revenue therefore has to be a flat fee to the merchant, collected outside the flow. That is a business-model decision, made, not a problem to solve in code. contracts/src/SpendRouter.sol:17