The charge lifecycle
From a signature to a row marked paid, and what has to be true at each step.
- The customer signs a permission naming Retainer's router as spender and encoding
(executor, recipient)inextraData. Off-chain; no gas. - Retainer registers it by submitting
approveWithSignaturefrom the executor. The executor pays. The permission row is stored with the exact struct and signature as the consent record. apps/web/app/api/permissions/route.js - A charge is enqueued for the current billing period. The period start is derived from the permission's
startandperiod, never chosen, andUNIQUE (permission_id, period_start)makes it impossible to create two charges for one period. packages/db/migrations/001_init.sql - The amount is resolved at charge time. Fixed charges carry their amount; usage charges are enqueued at zero and summed from recorded usage when the worker runs. resolveAmount
- Pre-flight classification reads chain state — revoked, window, registered, remaining allowance, balance — and simulates the exact call. If any check fails the charge is classified and no transaction is sent. packages/chain/src/classify.js
- Sign, persist, then broadcast. The transaction is signed locally, written to the database with its hash and nonce, committed, and only then sent. apps/worker/src/charger.js:107
- The router pulls and forwards atomically.
spendAndRoutecalls the manager'sspend, which transfers to the router, and the router forwards the full value to the recipient in the same transaction. contracts/src/SpendRouter.sol:114 - Reconciliation. An indexer reads
SpendPermissionUsedfrom the manager andSpendRoutedfrom the router at three confirmations. A charge becomesconfirmedonly when both events are present for its transaction and the router paid the intended recipient. apps/worker/src/reconciler.js:5
What “paid” means
A receipt says a transaction executed. It does not say where the money went. Retainer treats a charge as paid only when the two events say the customer's allowance was consumed and the router routed that value to the recipient on record; a mismatch is logged as charge.recipient_mismatch and the charge stays unconfirmed.apps/worker/src/reconciler.js:83
The reconciler is the only writer of confirmed. The charger records mined_success on the attempt and stops.awaitReceipt
Who pays what
- The customer pays nothing in gas — not for registration, not for charges.
- The executor pays gas for registration and for every charge attempt. An empty executor stops billing silently, so the worker checks its balance and refuses to start new charges below a threshold. apps/worker/src/gastank.js
- Measured on Base Sepolia:
spend()106,454–126,773 gas, and a routed charge 134,114–147,804 gas in the Phase 1 drills. At the Base fees observed, roughly $0.002–$0.011 per charge. phase0-addendum.md:111
Running it
worker + cli
node --env-file=.env apps/worker/src/index.js # loop: recover, index, confirm, then one charge
node --env-file=.env apps/worker/src/index.js --once --charge 12 # one specific charge, once
node --env-file=.env apps/cli/src/index.js enqueue --permission 13 --amount 900000
node --env-file=.env apps/cli/src/index.js enqueue --permission 10 --usage
node --env-file=.env apps/cli/src/index.js ledger # confirmed charges -> tx hashesThe scheduler is Retainer's, not the contract's
Base's own documentation says a spend permission “is not a subscription scheduler” and tells integrators to run billing from a durable job queue with an idempotency key per period and to reconcile before marking an invoice paid. That sentence is the boundary Retainer is built on the far side of.