Retainer/DocsBASE SEPOLIA

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

one call to SpendRouter.spendAndRoute
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 treasury

All 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.

TestAsserts
test_routerHoldsNothing_afterSingleChargerouter balance 0 after; recipient received exactly the value
test_routerHoldsNothing_afterManyChargesrouter balance 0 after every charge in a sequence of up to 20
test_executorNeverReceivesFundsexecutor token and native balances unchanged
test_revertedChargeLeavesNoResidueAndNoAllowanceSpentafter a revert: router 0, recipient 0, period spend 0
test_onlyEncodedExecutorCanChargeany 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

What custody does not cover
Retainer cannot execute refunds. There is no reverse-spend; a refund is a push from the merchant's treasury with the merchant's key. A non-custodial billing layer can only instruct, not perform, a refund. This is a real gap against card processors and it is listed under limitations.