Chapter 8: Event Indexing
The indexer is the bridge between on-chain events and Convex state. When a Safe module executes a payment, when an employee claims funds, when tokens move in or out of a treasury — the indexer detects these events and calls Convex mutations to keep the platform’s data current.
Without the indexer, invoices stay stuck in payment_pending, claim receipts are never generated, treasury balance on the dashboard is stale, and the bidirectional link between on-chain and off-chain never closes.
Architecture Decision: Why a Custom Indexer
Section titled “Architecture Decision: Why a Custom Indexer”Four approaches were evaluated against a rubric weighted by Capxul’s priorities: cost first, then latency, then reliability, then operational simplicity.
| Criterion | Convex-Native Polling | The Graph | Ponder + Sync | Custom Indexer |
|---|---|---|---|---|
| Cost | High — burns millions of empty calls/month | Medium — hosted infra | Medium — $7-12/mo | Lowest — $5-7/mo |
| Latency | 2-3 seconds | 5-15 seconds | 3-4 seconds | 3-4 seconds |
| Reliability | Self-healing but no reorg handling | Reorg built in, but sync layer adds failures | Reorg built in | Cursor in Convex survives crashes |
| Operational simplicity | Best — zero services | Worst — AssemblyScript, Graph Node | Medium — Ponder + Postgres | Good — one small process |
Decision: Custom indexer. Lowest cost, meets sub-5-second latency requirement, keeps Convex as the single source of truth with no intermediate databases. The tradeoff is hand-built cursor management and reorg handling. For Base (centralized sequencer, no practical reorg risk) and current volume (under 1,000 txs/day), this tradeoff is correct.
Upgrade path: If volume grows past 10,000/day or the team expands, Ponder becomes the natural upgrade. The Convex HTTP action interface remains identical — only the caller changes.
How It Works
Section titled “How It Works”A small Node.js/TypeScript process using viem for chain interaction. It polls eth_getLogs on a 2-second interval per chain, decodes events using contract ABIs, and calls Convex HTTP actions to process each event. The block cursor (last processed block per chain) lives in a Convex table. If the process crashes, it reads the cursor from Convex and resumes exactly where it stopped.
No intermediate database. No Postgres. No subgraph. Convex is the only state store. The indexer process is stateless.
Event Sources
Section titled “Event Sources”The indexer watches four categories of on-chain events.
Category 1: Payment Module
Section titled “Category 1: Payment Module”Event: PaymentExecuted — emitted when a discrete payment executes.
Indexer action: Match-and-update. Find the financialDocument by document hash, transition to paid, record the tx hash.
Category 2: Streaming (LlamaPay)
Section titled “Category 2: Streaming (LlamaPay)”Events:
StreamCreated— new payment stream establishedStreamModified— rate or parameters changedStreamCancelled— stream terminated early
Indexer action: Create or update stream records in Convex. Feeds payslip generation and dashboard metrics.
Category 3: Claims
Section titled “Category 3: Claims”Event: FundsClaimed (or equivalent) — employee claims accrued funds.
Indexer action: Create. Births a claim receipt. No prior document exists. Creates a new receipt in financialDocuments with status available.
Category 4: Safe-Level Events and ERC20 Transfers
Section titled “Category 4: Safe-Level Events and ERC20 Transfers”ERC20 Transfer events to/from Safe addresses: every token transfer changes treasury balance. The indexer watches Transfer events on all token contracts where either from or to is a registered Safe.
Safe native events: Module enabled/disabled, ownership changes. Lower priority, useful for audit.
Indexer action for transfers: Update the treasuryBalances table. Decrement or increment per-token balances. Store each transfer as a treasuryActivity record for the payment activity feed.
Why Index Balances Instead of Reading balanceOf
Section titled “Why Index Balances Instead of Reading balanceOf”- The dashboard needs historical balance data for charts and trends, not just the current number
- A single
balanceOfcall requires an RPC round trip on every dashboard load - Tracking transfers gives raw data for payment activity feeds
The tradeoff: indexed balances can drift from actual on-chain balances if a Transfer event is missed. The system includes periodic reconciliation (see Failure Handling).
Token Discovery
Section titled “Token Discovery”Reactive: When a Transfer event is detected with a Safe address as recipient for an untracked token, register it and begin tracking.
Registry-based: Org admin explicitly registers token addresses through the UI.
Initial setup: Backfill by querying Transfer events from the Safe’s deployment block.
Multi-Chain Architecture
Section titled “Multi-Chain Architecture”Chain Registry
Section titled “Chain Registry”A Convex table chainConfigs stores per-chain configuration: chain ID, name, RPC endpoint, block time, confirmation depth (0 for Base), and active flag. Adding a chain is a data operation — no code changes.
Safe Registry
Section titled “Safe Registry”A Convex table safeRegistry maps Safes to organizations: Safe address, chain ID, org ID, role (primary, child, standalone), module addresses, and active flag.
Per-Chain Polling Loops
Section titled “Per-Chain Polling Loops”One independent polling loop per active chain. Each reads its cursor from Convex, calls eth_getLogs, decodes events, calls Convex HTTP actions, and advances the cursor. Loops run concurrently. A failure on one chain does not affect others. Each chain’s cursor is a separate row in indexerCursors.
Polling Interval
Section titled “Polling Interval”2 seconds per chain (matching Base’s block time). Worst-case detection latency: 2 seconds + HTTP action round-trip (typically under 500ms). Total: well under the 5-second budget.
Convex Integration Layer
Section titled “Convex Integration Layer”HTTP Actions as the Interface
Section titled “HTTP Actions as the Interface”The indexer calls Convex HTTP actions to push event data. Each event type maps to a dedicated endpoint. The HTTP action validates the payload, authorizes via shared secret, and delegates to an internal mutation with full ACID guarantees.
Event Processing Mutations
Section titled “Event Processing Mutations”processPaymentExecuted
- Look up
financialDocumentbydocumentHash(indexed field) - Not found: log warning, store in
unmatchedEventstable - Found but already
paid: skip idempotently - Transition to
paid, write tx hash/block/timestamp - If invoice: trigger receipt generation
- Update treasury balance
processClaimEvent
- Look up org by claimer’s address
- Create receipt document: amount, token, address, tx hash, block timestamp, status
available - Set
sourceDocumentReferenceif stream ID is determinable
processStreamEvent
- StreamCreated: insert/update stream record
- StreamModified: update rate and effective timestamp
- StreamCancelled: mark cancelled, record final accrued amount, trigger partial-period payslip if applicable
processTransferEvent
- Determine direction: inbound (Safe is
to) or outbound (Safe isfrom) - Update treasury balance for org/chain/token
- Register new tokens if first seen
- Store as
treasuryActivityrecord
Idempotency
Section titled “Idempotency”Every mutation is idempotent. The deduplication key is chainId + transactionHash + logIndex. Before processing, check the indexedEvents table. If the key exists, return early. If not, process and insert the key atomically within the same transaction.
Two Modes
Section titled “Two Modes”Match-and-update: For events tied to existing documents (invoice payments, stream modifications). Find by hash or stream ID. If the document does not exist, something is wrong — the event goes to unmatchedEvents.
Create: For events that birth new documents (claim receipts, stream creation records). No pre-existing document expected. The mode is determined by event type, not runtime detection.
Block Cursor Management
Section titled “Block Cursor Management”Cursor Table
Section titled “Cursor Table”indexerCursors with one row per chain: chain ID, last processed block number, last processed timestamp, last updated at.
Cursor Advancement
Section titled “Cursor Advancement”The cursor advances only after all events in a block range have been processed:
- Fetch logs for [cursor+1, latestBlock]
- Process each event via Convex HTTP action
- All succeed: advance cursor to latestBlock
- Any fail: do not advance. Next cycle retries from the same position.
Events may be delivered more than once after a failure. This is fine because mutations are idempotent.
Startup and Recovery
Section titled “Startup and Recovery”On start: read cursors from Convex. If the gap is small (under 1,000 blocks), process normally. If large (extended outage), enter backfill mode in batches of 500-1,000 blocks.
Initial Deployment
Section titled “Initial Deployment”For new orgs: cursor starts at the Safe’s deployment block (captures all events from inception). For existing orgs onboarding: cursor starts at the current block with a separate backfill batch job.
Failure Handling
Section titled “Failure Handling”Process Crash
Section titled “Process Crash”Restarts automatically (Railway). Reads cursors from Convex. Resumes. Maximum data staleness: equal to outage duration.
RPC Provider Failure
Section titled “RPC Provider Failure”Retry with exponential backoff (2s, 4s, 8s, 16s, capped at 60s). Other chains unaffected. Alert after 5 minutes of consecutive failures. Consider configuring a fallback RPC endpoint.
Convex HTTP Action Failure
Section titled “Convex HTTP Action Failure”Retry up to 3 times per event with exponential backoff. All retries fail: log to local error queue, do not advance cursor. Next cycle re-fetches and re-processes.
Circuit breaker: If a specific event consistently fails (mutation bug), skip after N failures (default 10). Log prominently. Advance cursor. Skipped event goes to unmatchedEvents for manual resolution. This prevents a single bad event from permanently stalling the indexer.
Periodic Reconciliation
Section titled “Periodic Reconciliation”A Convex scheduled function runs every hour:
- For each org’s Safe, call
balanceOffor each tracked token and compare to indexed balance. Flag discrepancies beyond dust threshold. - Check for
financialDocumentsstuck inpayment_pendinglonger than 10 minutes. Query the chain for tx status. - Surface
unmatchedEventsentries in admin alerts.
Reorgs on Base and L2s
Section titled “Reorgs on Base and L2s”Base uses a centralized sequencer with near-instant soft finality. Reorgs are not a practical concern today. However, the architecture supports recovery:
- Every event is keyed by
chainId + txHash + logIndex. A reorg produces a different hash. - Financial document transitions are auditable. A recovery process can identify documents transitioned by orphaned transactions.
- The
chainConfigstable includesconfirmationDepthper chain. For Base: 0 (process immediately). For chains with reorg risk: 3-12 blocks.
Active reorg detection should be built when Capxul expands to chains where reorgs occur in practice.
Monitoring and Alerting
Section titled “Monitoring and Alerting”Health Metrics
Section titled “Health Metrics”- Cursor lag per chain (current head minus last processed block, should be 0-2)
- Events processed per minute per chain
- Convex HTTP action success rate (should be >99.9%)
- RPC call latency per chain
- Process uptime (heartbeat)
Alerting Thresholds
Section titled “Alerting Thresholds”- Cursor lag > 50 blocks for more than 2 minutes
- HTTP action error rate > 5% over a 1-minute window
- RPC failures for a chain for more than 5 minutes
- Heartbeat missing for more than 30 seconds
- Reconciliation balance discrepancy > $1
unmatchedEventsentries older than 15 minutes
Dashboard Staleness Indicator
Section titled “Dashboard Staleness Indicator”The org admin dashboard shows a “data may be delayed” badge when the last cursor update for the org’s chain is more than 30 seconds old. A Convex query on indexerCursors — trivial to implement, important for user trust.
Deployment and Infrastructure
Section titled “Deployment and Infrastructure”- Runtime: Node.js / TypeScript
- Dependencies:
viem, standard HTTP client for Convex - Hosting: Railway (always-on, auto-restart, $5-7/month)
- Configuration: Env vars for RPC endpoints, Convex deployment URL, HTTP action auth secret
- RPC: Single
eth_getLogscall per polling cycle per chain (most efficient pattern) - Deployment: Separate repo or package from the Convex backend. Deploys independently. Version coordination with Convex via shared TypeScript types.
Cost Projections
Section titled “Cost Projections”| Scale | Railway | RPC | Convex | Total |
|---|---|---|---|---|
| Pre-launch | ~$5/mo | $0 (free tier) | $0 (within limits) | ~$5/mo |
| 100-1,000 txs/day, 3 chains | ~$7/mo | $0-49 | Included in Pro | ~$7-56/mo |
The indexer is one of the cheapest components in the stack. The dominant cost at scale is the RPC provider.