Keeper strategy
SubscriptionHub.charge(id) (EVM) and the Anchor charge instruction (Solana) are public and permissionless. Any EOA can call them. A "keeper" is just a scheduler that does.
This page explains how OrcaRail triggers charges, what happens when a keeper is unavailable, and what SLOs we publish.
Primary keeper: NestJS scheduler
The primary keeper lives in api/src/scheduler/processors/subscription-auto-charge.processor.ts. It is a BullMQ processor that:
- Reads due subscriptions from the database (mirror of on-chain state maintained by the indexer).
- Groups by chain id.
- Signs and submits a
charge(ids[])transaction per chain using a per-chain keeper EOA. - On success, waits for confirmation and updates the database via indexer events.
- On revert, parses the revert selector and maps it to a
SubscriptionAutoChargeEntitystate:ChargeSkipped(InsufficientAllowance)→past_due+ allowance emailChargeSkipped(InsufficientBalance)→past_due+ balance emailChargeSkipped(CapExceeded)→canceled(payer must re-approve)ChargeSkipped(NotDue)→ benign (indexer rounding); retry in 1 cycle
Fallback keeper: Chainlink Automation (preferred)
From Phase 3 onward, each SubscriptionHub deployment registers an Upkeep with Chainlink Automation:
checkUpkeep(bytes)returnsperformData = abi.encode(ids[])for IDs whereisDue(id) && !chargedThisBlock(id).performUpkeep(bytes)callscharge(ids[])in a batch.- Funded with LINK from the platform treasury.
- Configured for "conditional" mode with a gas limit suitable for batch size.
Fallback keeper: Gelato Web3 Functions (alternative)
If Chainlink is not available on a chain (or the pricing doesn't fit), a Gelato Web3 Function polls the hub's isDue() view and submits charge(ids[]) when due. Paid post-pay from Gelato's 1Balance.
Solana keeper
Solana's NestJS branch uses the same scheduler pattern with a SOL-signing keeper. Solana has no Chainlink Automation equivalent with equivalent coverage today; fallback options:
- Clockwork (if its maintenance status is acceptable at rollout time) — Solana-native cron.
- A second NestJS instance in a different region as a simpler redundancy.
Solana fees are low enough that gas-balance monitoring is less critical than EVM.
Gas management
EVM
- Chains with Alchemy paymaster policy: keeper calls
charge()as a sponsored UserOperation via the existing logic in api/src/withdrawals/services/gas-strategy. - Chains without paymaster: keeper self-funds from its hot-gas balance. Funded daily by the platform wallet.
- Gas-price ceiling: charges are deferred one block if gas price exceeds a per-chain threshold.
- Merchant-subsidized gas (optional): merchants can set a
gas_refund_address; reimbursement is pulled through the fee-split logic.
Solana
- Keeper runs with a stable SOL float per chain.
- Priority fee is set dynamically based on recent blocks; retries bump priority one step.
SLOs
| Metric | Target | Applies to |
|---|---|---|
charge() within 10 minutes of nextChargeAt | 99.5% | All supported chains |
charge() within 1 hour of nextChargeAt | 99.9% | All supported chains |
| Keeper EOA hot-gas balance above alert threshold | 99.99% | All supported chains |
Paid / Charged events indexed within 2 minutes | 99.5% | All supported chains |
charge() revert rate (non-benign reverts only) | < 0.1% | All supported chains |
Non-benign reverts exclude ChargeSkipped(InsufficientAllowance), ChargeSkipped(InsufficientBalance), and ChargeSkipped(CapExceeded) — those are expected payer-side conditions.
Monitoring and alerts
Using the existing Sentry + Datadog pipeline:
charge()revert rate per hour > threshold → PagerDuty- Keeper EOA native balance below
gasPrice × gasLimit × 100→ PagerDuty - Chainlink upkeep balance below threshold → warning
- Gelato
1Balancebelow threshold → warning - Indexer event lag > 5 minutes → warning
Dashboards: one per chain, plus a global roll-up. Runbooks for outages are linked from the on-call rotation.
Reorg protection
SubscriptionHub.charge(id)enforcesblock.timestamp > lastChargedAtmonotonically. A reorg that would replay a charge is reverted withAlreadyChargedThisPeriod.- The Solana program uses
last_charged_atthe same way; Solana has much rarer reorgs, but the same invariant holds.
Manual intervention
Merchants can trigger charge(id) manually via a dashboard action in emergencies (e.g. NestJS + Chainlink simultaneously offline). The transaction signs from the merchant's own wallet; it is not custodial.
What the keeper cannot do
Explicit list, to make the scope obvious:
- Create subscriptions without a payer signature.
- Raise
cap,amount, orintervalon an existing subscription. - Move funds to an address not in the subscription's
Split. - Bypass
nextChargeAtorlastChargedAt. - Disable pause, cancel, or refund flows.
The keeper holds gas and nothing else.