Phase 2: Allowance-Pull Subscriptions
April 20, 2026 — Phase 2 moves recurring billing to an on-chain SubscriptionHub. The payer signs one approve in their wallet, and anyone (including our keeper) can call charge(id) when a period is due. Funds move directly from the payer to the merchant splits — OrcaRail never holds them.
TL;DR
SubscriptionHub.createSubscription(...)records the schedule on-chain.- Payer signs
approve(USDC, hub, cap)once in their normal wallet. That is the same step we already document in Auto-charge. - Any EOA can call
charge(id). Our NestJS scheduler does it by default; Chainlink/Gelato will back it up from Phase 3 onward. - Reorg protection via monotonic
lastChargedAt. No double-charges. - Existing subscribers migrate opt-in, at renewal.
What this phase changes
Today, api/src/scheduler/processors/subscription-auto-charge.processor.ts uses an OrcaRail-controlled smart account to pull from the payer via existing approve. It works, but the smart account is ours — trust flows through us.
After Phase 2:
The allowance is still from the payer to a contract, but the contract is auditable and immutable, and anyone — not just us — can be the keeper.
Contracts introduced
SubscriptionHub
One instance per chain. Minimal surface:
function createSubscription(
bytes32 id,
address payer,
IERC20 token,
uint256 amount,
uint64 interval,
uint256 cap,
Split calldata split
) external;
function charge(bytes32 id) external; // any caller
function cancel(bytes32 id) external; // payer only
function subscription(bytes32 id) view returns (Subscription memory);
event SubscriptionCreated(bytes32 indexed id, address indexed payer, ...);
event Charged(bytes32 indexed id, uint256 amount, uint64 nextChargeAt);
event ChargeSkipped(bytes32 indexed id, bytes32 reason);
event Canceled(bytes32 indexed id);
Invariants enforced in charge()
block.timestamp >= nextChargeAt— cannot charge early.block.timestamp > lastChargedAt— monotonic, so reorgs cannot double-charge.amountCharged + amount <= cap— enforces the approval limit in addition to ERC-20 allowance.token.allowance(payer, hub) >= amount— explicit revert withInsufficientAllowancefor clean webhook copy.token.balanceOf(payer) >= amount— same, withInsufficientBalance.
The Split is reused from Phase 1 — same FeeSplitter, same basis-points semantics.
API changes
The big change is that api/src/scheduler/processors/subscription-auto-charge.processor.ts stops pulling from our smart account and starts calling SubscriptionHub.charge(id) with a keeper EOA.
Concretely:
- Keeper EOA is a per-chain hot wallet with only gas. It has zero authority over merchant funds.
- If the chain has an Alchemy paymaster policy (see api/src/withdrawals/services/gas-strategy), the keeper uses the sponsored path; otherwise it self-funds from its gas balance.
- On revert, the processor parses the error selector and updates the same SubscriptionAutoChargeEntity states we use today (
past_due,insufficient_balance, etc.). - Webhooks and emails from
api/src/mail/mail-templates/subscription-*.hbsfire unchanged.
Payer UX in pay gets two adjustments, both minor:
- The
approve()step now points spender to theSubscriptionHubaddress instead of our hot wallet. That is one copy change and one config value — the flow the payer sees is identical. - A "revoke" button that calls
SubscriptionHub.cancel(id)then prompts an on-chainapprove(hub, 0)for full cleanup. Today's Auto-charge revoke docs remain accurate.
Deprecations
- The custodial pull path in
subscription-auto-charge.processor.ts— behind the flag, only theSubscriptionHub.chargebranch runs. - OrcaRail-owned smart account as
approval_spender_address— new subs store the hub address instead. - The
approval_spender_addressfield stays in the API response, it just points somewhere new and auditable.
Migration plan
Existing subscribers are never force-migrated. We offer two paths:
- Renewal-triggered: at the next cycle, the dashboard banner asks the payer to resign
approve(hub, cap)on the newSubscriptionHub. One click. Old allowance to the legacy hot wallet can be left or revoked; it no longer matters. - User-initiated: a "Move to non-custodial" button in the subscription detail page does the same thing immediately.
Subs that never migrate stay on the custodial path until cancellation. That path is turned off in Phase 7, with plenty of notice.
Risks and mitigations
- Payer confusion at migration. Mitigation: email template mirrors existing
subscription-auto-charge-approved.hbslanguage; banner in dashboard; FAQ entry at /docs/non-custodial/migration-guide. - Keeper outage misses a charge. Mitigation:
charge()is permissionless — any Chainlink Automation or Gelato instance can be a fallback; manual merchant-initiatedcharge()is also possible. Full plan in Phase 3. - Reorg double-charge. Mitigation:
lastChargedAtmonotonic invariant; unit test with fork reorgs. - Payer revokes between cycles. Mitigation:
charge()emitsChargeSkipped(AllowanceRevoked); the existingsubscription-insufficient-allowance.hbsemail template fires; sub moves topast_due. - Token allowlist drift. Mitigation: hub only accepts tokens on
SubscriptionHub.isAllowed(token); changes are multisig-gated.
Status checklist
-
SubscriptionHubimplemented with all invariants - Fuzz tests for cap, allowance, reorg, and skip cases
- Audit delta covering
SubscriptionHublanded - Keeper EOA provisioned per chain, gas monitored via Sentry + Datadog
- Pay app migrated
approvetarget toSubscriptionHub -
approve-auto-chargeendpoint updated to accept hub-spender allowances - Dashboard banner for existing subs
- Canary cohort migrated and stable for 14 days
What is next
Multi-chain rollout and a second keeper: Phase 3.
Reference docs: Phase 2 status · Migration guide · Contract reference · Why allowance-pull
