Skip to main content

Phase 2: Allowance-Pull Subscriptions

· 5 min read
OrcaRail
Crypto payment rails for web2 apps

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 with InsufficientAllowance for clean webhook copy.
  • token.balanceOf(payer) >= amount — same, with InsufficientBalance.

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-*.hbs fire unchanged.

Payer UX in pay gets two adjustments, both minor:

  1. The approve() step now points spender to the SubscriptionHub address instead of our hot wallet. That is one copy change and one config value — the flow the payer sees is identical.
  2. A "revoke" button that calls SubscriptionHub.cancel(id) then prompts an on-chain approve(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 the SubscriptionHub.charge branch runs.
  • OrcaRail-owned smart account as approval_spender_address — new subs store the hub address instead.
  • The approval_spender_address field stays in the API response, it just points somewhere new and auditable.

Migration plan

Existing subscribers are never force-migrated. We offer two paths:

  1. Renewal-triggered: at the next cycle, the dashboard banner asks the payer to resign approve(hub, cap) on the new SubscriptionHub. One click. Old allowance to the legacy hot wallet can be left or revoked; it no longer matters.
  2. 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.hbs language; 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-initiated charge() is also possible. Full plan in Phase 3.
  • Reorg double-charge. Mitigation: lastChargedAt monotonic invariant; unit test with fork reorgs.
  • Payer revokes between cycles. Mitigation: charge() emits ChargeSkipped(AllowanceRevoked); the existing subscription-insufficient-allowance.hbs email template fires; sub moves to past_due.
  • Token allowlist drift. Mitigation: hub only accepts tokens on SubscriptionHub.isAllowed(token); changes are multisig-gated.

Status checklist

  • SubscriptionHub implemented with all invariants
  • Fuzz tests for cap, allowance, reorg, and skip cases
  • Audit delta covering SubscriptionHub landed
  • Keeper EOA provisioned per chain, gas monitored via Sentry + Datadog
  • Pay app migrated approve target to SubscriptionHub
  • approve-auto-charge endpoint 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