Retainer/DocsBASE SEPOLIA

The charge lifecycle

From a signature to a row marked paid, and what has to be true at each step.

  1. The customer signs a permission naming Retainer's router as spender and encoding (executor, recipient) in extraData. Off-chain; no gas.
  2. Retainer registers it by submitting approveWithSignature from 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
  3. A charge is enqueued for the current billing period. The period start is derived from the permission's start and period, never chosen, and UNIQUE (permission_id, period_start) makes it impossible to create two charges for one period. packages/db/migrations/001_init.sql
  4. 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
  5. 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
  6. 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
  7. The router pulls and forwards atomically. spendAndRoute calls the manager's spend, which transfers to the router, and the router forwards the full value to the recipient in the same transaction. contracts/src/SpendRouter.sol:114
  8. Reconciliation. An indexer reads SpendPermissionUsed from the manager and SpendRouted from the router at three confirmations. A charge becomes confirmed only when both events are present for its transaction and the router paid the intended recipient. apps/worker/src/reconciler.js:5

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 hashes
The 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.