Vladyslav Kovalchuk
← All cases

Rewriting a chain indexer to zero on-chain reads: from a burned RPC quota to a free public endpoint

Problem

Arkenia is an onchain fundraising protocol on Base: believers deposit USDC into a refundable pool, the angel deploys capital which mints pro-rata cohort shares, returns are distributed across cohorts, and shares trade on a signature-based premarket. All of that needs an indexer — the chain can answer 'what is this campaign's pool total right now' cheaply, but not 'show me every account's activity across every campaign, ranked by time'. My first indexer worked and was completely wrong architecturally. It treated on-chain events as notifications rather than as data: every event handler called back into the contract to re-read the state it had just been told about. A single Deposited event triggered three recompute functions — recomputeCampaignStats (seven contract reads: creator, token, floor, ceil, totalRaised, returnedAmount, withdrawnAt), recomputeCreatorForCampaign (one more read), and recomputeUserInvestorStats (three aggregate SQL queries with joins over the whole events table) — plus a full Postgres scan of every event for that campaign just to count distinct investors into a Set. Eight eth_calls and a table scan, for one deposit. On top of that, every poll unconditionally called factory.getCampaigns() (an unbounded array read) plus two separate getLogs, and on Base with two-second blocks the 'has the head moved' check is true essentially always, so that fixed cost was paid every single cycle. It ate an Alchemy free tier in three days. My first fix was a one-line commit raising the poll interval from 15s to 60s — which is exactly the shape of fix you write when you have not yet understood the problem.

Constraints

  • The running cost had to be zero, not 'small'. This is a self-funded project with two live contours; any design that needed a metered RPC to survive was disqualified.
  • It handles real USDC. Users read a number and then sign a transaction against it, so 'cheap' could never mean 'approximately right'.
  • The free public Base RPC caps eth_getLogs at a 1000-block range and rate-limits, so the design had to be bounded per request and polite under backoff.
  • A crash or redeploy mid-batch must not lose events or double-count them — the indexer runs under pm2 and gets restarted for ordinary reasons.
  • The contract's settlement is lazy and path-dependent: RAY fixed-point accumulators, floor division at every step, and an internal jump that skips ahead when a balance zeroes out. Any off-chain derivation has to reproduce it exactly, not approximately.
  • Two contours (Base mainnet + Base Sepolia) run from one build selected purely by env, so the fix could not be a per-environment special case.

Solution

I inverted the data flow: the indexer no longer asks the chain anything about state, it derives state from the event log. The whole settlement math from CampaignV3.sol is transliterated into a pure TypeScript module (domain/projection.ts) — RAY = 1e27, multiplication always before division, floor everywhere, bigint end to end. Because TypeScript's BigInt division truncates toward zero and every quantity here is non-negative, `a * b / RAY` in TS is the same operation as `a * b / RAY` in Solidity. Grep the whole backend for readContract, multicall, eth_call or simulateContract and you get zero matches: the RPC surface collapsed to getBlockNumber for the head, getLogs for events, and getBlock for timestamps (deduped, fetched ten at a time, memoized in a Map that clears past 50k entries). Correctness then had to be bought back somewhere else. Recompute-from-source had been idempotent for free — replay any event and you get the same answer, because you re-read rather than accumulate. A projection accumulates deltas, so exactly-once delivery becomes mandatory rather than nice to have. That is a chain_events journal with a primary key of (chain_id, tx_hash, log_index) and an insertEventOnce that does onConflictDoNothing().returning() and gates application on whether a row was actually inserted. The whole batch — every event, every projection write, and the block cursor — commits inside a single Postgres transaction, so a process that dies mid-batch rolls back all three and refetches the identical range cleanly on restart. Two further passes trimmed what was left: one multi-address getLogs across [factory, ...knownCampaigns] instead of two separate calls, and a poll interval of 15 seconds. Both contours now run on https://mainnet.base.org and https://sepolia.base.org, and the keyed Alchemy endpoint was deleted from the config rather than downgraded.

Key technical decisions

Derive state from events; never read contract state
This is the whole case study in one line. An indexer that calls the contract is a cache with extra steps: it pays RPC to learn something the event log already told it, and its cost scales with events multiplied by the recompute fan-out rather than with events alone. Going from eight eth_calls per deposit to zero is not an optimisation, it is a different architecture — the old cost curve had a multiplier on it that no amount of tuning removes. The rule I use now: if an event handler makes a network call to the same chain that produced the event, the event schema is wrong or the projection is missing. Fix that, not the poll interval.
The price of 'derive, don't ask' is a bit-exact transliteration
Zero eth_calls is not free — you have just taken on the obligation to reimplement the contract's arithmetic in another language with another integer type, and to be wrong nowhere. That is why domain/projection.ts is a line-for-line mirror of CampaignV3.sol rather than an idiomatic TypeScript rewrite: same RAY constant, same operation order, same floor at each step, same `if (conv !== 0n)` guard so sub-unit conversions leave the balance untouched exactly as they do on-chain. The bit-exactness is not pedantry sitting next to the cost saving — it is what the cost saving is made of. Give up exactness and you are back to reading the contract to check yourself, which is the architecture you just left.
Recompute is idempotent for free; a projection is not, so exactly-once became mandatory
This is the trade nobody mentions when they tell you to stop calling the contract. The naive recompute design has one genuine virtue: it is trivially replay-safe. Re-process the same event twice and the answer is identical, because each handler re-reads the world rather than adding to it. A projection accumulates, so a duplicate event is a silent corruption. So the journal is not defensive plumbing bolted on afterwards, it is the thing that makes the cheap architecture legal: chain_events with a primary key of (chain_id, tx_hash, log_index), insertEventOnce doing onConflictDoNothing().returning(), and application gated on whether a row was truly inserted. Stated as a rule: recompute is expensive but forgiving, projection is cheap but demands exactly-once discipline. Choose the second only if you are willing to build the discipline.
Two sources of truth on purpose — so the UI outlives its own backend
The frontend reads live figures — pool total, cohort ledger, your position and claimable — straight from the contract via wagmi, never from the API. The indexer owns only what the chain answers badly: history, activity feeds, cross-account aggregation. Two sources of truth for one protocol is normally a smell. Here it is a split along the axis of what each side is actually good at, and it buys three things at once. Point reads are issued by each viewer's own browser against a public endpoint, so that cost is distributed across users and is zero to me. They are fresher than any polling interval, because they resolve the instant a transaction confirms. And when the backend is down, the parts of the app that touch a user's money keep working — the activity feed degrades, the balance does not. Deciding which data may go stale is a frontend architecture decision, not a backend one, and it is the reason a fifteen-second poll interval was safe to ship.
What the approach still owes, and why I am naming it rather than implying otherwise
The architecture is right and the numbers are real, but two gaps are open and tracked rather than quietly hoped away. First, reorg safety is confirmation lag only — five blocks on testnet, eight on mainnet — with no block hash stored and no automatic unwind, so a deeper reorg corrupts the projection until a manual reindex. The fix is nearly free and that is what makes it embarrassing to have skipped: getLogs already returns blockHash on every log and I was discarding it, so storing it costs zero additional requests, and comparing the last indexed block's hash once per poll costs one cheap call. Second, the bit-exactness is a careful transliteration verified against hand-computed scenarios, not an automated proof — there is no differential test against a deployed contract, and the drift clamps in the projection currently log a warning rather than raising an alarm. The reconciliation job I designed for it samples accounts on a rotating cursor and compares against refundableOf, pendingRewardOf and cohortSharesOf through Multicall3, which lands at roughly seventy extra requests a day against an existing eleven thousand — under one percent, so it stays inside the same zero-cost envelope the rewrite bought. An architecture is worth writing about when you can also say precisely what it has not yet earned.

Outcome

Per-event RPC cost went from eight eth_calls to zero, verified rather than asserted: grep the backend for readContract, multicall, eth_call or simulateContract and there are no matches. Per-poll fixed cost went from four requests (getBlockNumber, an unbounded getCampaigns, and two getLogs) to two, and the poll interval went from 3s to 15s, which the config comment prices at roughly a tenfold reduction. The practical result is not a cheaper bill but the absence of one: the keyed Alchemy endpoint was removed from both contour configs rather than downgraded, and mainnet and testnet now run on the free public Base RPC, with a note in the env template that a keyed provider is only worth it at real scale. Along the way the backend picked up the things the first version had no room for — Clean Architecture with eight ports so the whole HTTP surface can be constructed by hand in tests, every uint256 stored as numeric(78,0) mapped to bigint so money never touches a float (the old indexer summed PnL in JavaScript numbers), and 38 tests running against an in-process PGlite database with the real committed migrations, no Postgres and no network, gating every merge in CI. The lesson I keep returning to is about the shape of the first fix. Raising the poll interval from 15s to 60s was a one-line commit that made the graph go down and taught me nothing; it treated a multiplier as if it were a constant. The question worth asking of any indexer is not how often it polls but whether it is asking the chain to tell it something the chain has already told it.