Retainer
Payments & matching

Watch mode, and why it exists

Some payers cannot sign a spend permission at all — not by policy, but because the contract that enforces permissions cannot call their account. Watch mode is how those payers are billed.

Pull is the better mechanism where it works: the merchant does not have to chase anyone, and the cap is enforced on-chain. But it has a hard boundary, and the boundary is not a matter of taste or of Coinbase's product decisions. It is a cast in the contract.

A Safe multisig can never be the account of a spend permission

SpendPermissionManagermoves a customer's funds by calling their account. That call is not made through an interface the account may or may not satisfy — the manager casts the address to CoinbaseSmartWallet and callsexecute(target, value, data) on it. contracts/src/SpendPermissionManager.sol:765

contracts/src/SpendPermissionManager.sol — _execute
function _execute(address account, address target, uint256 value, bytes memory data) internal virtual {
    CoinbaseSmartWallet(payable(account)).execute({target: target, value: value, data: data});
}

A Gnosis Safe does not implement execute(address,uint256,bytes). It exposes execTransaction, and for module calls execTransactionFromModule, neither of which matches that selector. So the call does not merely fail a permission check — it reverts for want of a function. The contract's own summary line says as much before any of the logic does: it allows spending “from a CoinbaseSmartWallet”.contracts/src/SpendPermissionManager.sol:19

Stated at the right strength
This is established by reading the deployed manager's source, which Retainer vendors verbatim at a pinned upstream commit and re-verifies by hash. contracts/src/PROVENANCE.md:16 It has not been demonstrated by submitting a Safe-account permission on-chain and observing the revert. The claim is a reading of the code, and is graded that way deliberately.

There is one escape hatch, and it is worth naming precisely because it changes what the claim means. _execute isvirtual, and its own comment invites overriding it for other account implementations.contracts/src/SpendPermissionManager.sol:758 A different deployment of the manager could therefore support Safes. The canonical instance that Base Account signs against does not override it, so for the address Retainer transacts with, the boundary holds. Whether Coinbase intends to ship such an override is unknown, and nothing here assumes either way.

What follows from that

DAOs, funds and company treasuries are exactly the payers who hold money in a multisig, and they are a large share of who pays a recurring invoice in crypto. If pull were the only mechanism, Retainer would be unable to bill them at all. Watch mode is not a convenience feature or a fallback for the impatient; it is the only path to that segment.packages/db/migrations/003_expected_payments.sql:12

How watching works

A transfer becomes a candidate for matching only after it is confirmed and indexed. Nothing is matched from a pending transaction, and nothing is matched from the mempool.

StepWhat happensWhere
1USDC Transfer logs addressed to the merchant treasury are read forward from a cursor.apps/worker/src/watcher.js:40
2Only blocks at least three confirmations behind the head are indexed.apps/worker/src/watcher.js:49
3Each transfer is stored with its block_hash, unique on (tx_hash, log_index), in state pending.packages/db/migrations/003_expected_payments.sql:158
4The matcher classifies each pending transfer and either attributes it or sends it to review.matching

Watch mode keeps its own cursor rather than sharing the charge engine's. The two read different logs at different depths and must be able to fall behind independently; one cursor would couple them for no benefit.apps/worker/src/watcher.js:21

Our own router is a state, not a filter

Every historic incoming transfer to the treasury came from Retainer's own router — because every one of them was a pull that the reconciler had already accounted for. Matching those again would double-count each of them. The obvious fix is to skip transfers whose sender is the router; the fix actually taken is to classify them, asinternal / router_fulfilment. apps/worker/src/matcher.js:67

The difference matters. A filter is invisible: nothing records that a transfer was seen and dismissed, so a bug in the filter looks exactly like a transfer that never arrived. A state is on the row, countable and auditable, and the dashboard can show that six transfers were recognised as our own rather than silently absent.

The reorg gap, stated rather than papered over

Three confirmations makes a reorg beneath an indexed transfer unlikely, not impossible. Storing block_hash means a reorg can be detected — the indexed hash stops matching the canonical block at that height.apps/worker/src/watcher.js:92

Nothing automatically unwinds a match whose transfer no longer exists. A human would have to reverse it. That is a real gap, it is listed as one, and it is not described as handled.apps/web/app/docs/limitations/page.tsx:26See current limitations.