
Three posts on what a subgraph actually is, what it cost us to run our own graph-node, and the snapshot-first indexer we replaced it with.
Rebuilding Staking Indexing From Scratch
Snapshot-first, self-contained, and fast to recover
In Parts 1 and 2 we covered what our staking subgraph did and why running our own graph-node — with its Postgres database, its IPFS node, and its replay-from-genesis recovery model — had become a cost we no longer wanted to carry just to keep five contracts’ worth of staking state queryable. This post covers what we replaced it with.
The core idea: stop replaying history, start from current truth
graph-node’s model treats “current state” as something you can only get by re-deriving it from every event since genesis. But for our staking contracts, that’s not actually true — the contracts themselves already know the current state. stakingData / stakingKnights view functions on each staking contract can tell you, right now, exactly how much a given hero has staked. We don’t need the history of how it got there to serve a correct dashboard — we need the current number, plus everything that happens from now on.
So instead of a graph-node-style replay, the new indexer’s startup sequence is a snapshot:
- Resolve the current owner of every hero ID (0 through max supply) via the MoonKnight NFT contract’s
ownerOf. - For each of the five staking contracts, read that hero’s current staked balance directly off-chain.
- Aggregate those balances straight into
Knight,User,UserStake, andFaraStakingtotals.
The naive version of this is tens of thousands of individual eth_call requests — just as slow as a replay. The trick is batching: all of those view calls go through Multicall3 (the same canonical 0xcA11bde0… deployment available on nearly every EVM chain), which folds thousands of calls into a handful of RPC round-trips. What used to require replaying the better part of BSC’s history now takes on the order of seconds to low minutes.
Recovery is no longer “replay from block 8,738,570.” It's “take a fresh snapshot” — fast enough to run casually, not just as a last resort.
What happens after the snapshot
Once the snapshot lands, the service switches into live polling — decoding Staked, Unstaked, and EmergencyWithdrawn events with ethers.js and applying the exact same business rules the original AssemblyScript mappings used, quirks included (the LP pools’ weight multiplier on User.totalStaked, the old contract’s different “create user on unstake” behavior versus the newer contracts — all preserved intentionally, so query results match what the old subgraph used to return).
A few things make that polling loop resilient in ways a fixed-interval poller isn’t:
- Adaptive polling, three tiers. Far behind chain head → poll every few seconds to catch up fast. Within a small window of it → moderate interval. Fully caught up → back off to a slow interval, since there’s nothing new to fetch most ticks anyway. This went through a real iteration after launch: it started as a two-tier catch-up/idle split, and a follow-up change added a third “fully synced” tier so a caught-up indexer isn’t polling as aggressively as one that’s actively racing to catch up.
- Self-healing freshness checks, not just at startup. If the indexed data ever falls further behind chain head than a configurable threshold — even while the process has been running the whole time — it automatically pauses the poller, wipes the database, and re-runs the snapshot. No manual intervention, no redeploy.
- Split RPC endpoints. Current-state calls (the snapshot’s
eth_call/multicall traffic) go to one endpoint; historicaleth_getLogscatch-up goes to a separate, ideally archive-capable endpoint — because, as covered in Part 2, providers often tier those two access patterns very differently. The poller also detects a provider’s “block range too large” rejection and automatically shrinks its request window instead of retrying the same doomed call forever.
Same API, zero frontend changes
The GraphQL surface deliberately mirrors what graph-node generates: plural query fields with where / orderBy / orderDirection / first / skip, singular by-ID lookups, _meta { block { number } } for sync-status checks, and BigInt/BigDecimal values serialized as strings exactly the way graph-node does it. The frontend didn’t need to change a single query to point at the new service.
What we ended up with
| Before | After |
|---|---|
| graph-node + Relational Database + IPFS, always on | One server process + Relational Database |
| Recovery = replay from block 8,738,570 | Recovery = one Multicall-batched snapshot |
| Staleness only checked by redeploying | Continuous self-healing freshness checks |
| graph-node’s generated GraphQL API | Same query shape, zero frontend changes |
This pattern didn’t stay a one-off. The same snapshot-first, self-contained approach has since been applied to indexing Faraland’s Land and Guild contracts too — same shape, same tradeoffs, same reason: an indexer’s job is to answer “what’s true right now, and what just happened,” and it turns out we don’t need to own a general-purpose multi-tenant indexing platform to do that well.
**Disclaimer: Digital asset prices are subject to high market risk and price volatility. $FARA token and NFT are designed solely for use within our on-chain gaming ecosystem and is intended exclusively for participation in gameplay experiences. They are not, and should not be considered as, an investment vehicle, security, financial instrument, or speculative asset. Holders should have no expectation of profit, return on investment, or appreciation in value from the purchase, holding, or use of this token and NFT.
