Skip to content

CursorStateManager

Tracks the last processed block number per blockchain chain for the SubscriptionEventWatcher.

High-Level Design

What the Cursor Represents

The cursor is a blockchain block number — the last block whose events have been successfully processed. Each supported chain (Arbitrum, Ethereum) has its own independent cursor, stored as a single row in SQLite. This is a standard blockchain indexer pattern: "everything up to block N is done, start from N+1 next time."

Data Flow

The cursor sits at the boundary between "what we've seen" and "what's new." Each 30-second alarm cycle runs this loop sequentially per chain:

getWatcherState(chain) → lastProcessedBlock
fetchLatestBlock(chain) → current chain tip
fromBlock = lastProcessedBlock + 1
toBlock = min(fromBlock + 999, latestBlock)   // bounded batch

fetch logs → deduplicate → process events

updateCursor(chain, toBlock)                  // advance cursor

The cursor advances to toBlock regardless of whether events were found — it tracks block coverage, not event count. This prevents re-scanning empty ranges on every alarm.

If getWatcherState returns null during processing (uninitialized chain), it triggers initialization and skips that cycle — no events are processed until the cursor is established.

Initialization Strategy

On first encounter (no cursor row exists), the manager checks getWatcherState first — only initializes if no row exists. It fetches the current chain tip and sets the cursor to latestBlock - 100. This 100-block buffer ensures recently-emitted events aren't missed during initial setup. Math.max(0, ...) guards against negative values on young chains.

Bounded Catch-Up

If the watcher falls behind (e.g., after an outage), it catches up in 1000-block increments per alarm cycle. At 30-second intervals, this means ~2000 blocks/minute. The health check reports blocksBehind per chain and flags unhealthy if any chain is 2000+ blocks behind.

Per-Chain Independence

Arbitrum and Ethereum cursors advance independently. Chains are processed sequentially in a loop, each in its own try/catch — an RPC failure on one chain does not block the other from advancing.

Manual Recovery via resetCursor

resetCursor(chain, blockNumber) is exposed as a public RPC method for operators to rewind the cursor. It uses identical SQL to updateCursor but is semantically distinct (logged as an operator action). Rewinding may cause duplicate event processing, but the DeduplicationManager (which retains txHash-logIndex keys for 10,000 blocks) acts as a safety net.

Relationship with DeduplicationManager

The DeduplicationManager reads the cursor to calculate its retention cutoff: lastProcessedBlock - 10,000. This means dedup records are kept long enough to cover any reasonable cursor rewind, but old entries are cleaned up to bound storage growth.

Edge Cases & Error Handling

  • If fetchLatestBlock() fails during initialization, the error propagates and the chain remains uninitialized (retried on next alarm).
  • INSERT OR REPLACE makes initializeChainCursor idempotent if called multiple times.
  • updateCursor does not enforce forward-only movement — the DeduplicationManager is the safety net against re-processing after a rewind.

See Also