Blog
·Orion Engineering

Why event-driven indexing misses state changes on Blend and Soroban

We built a Blend indexer that waited for events, folded them, and treated the result as the source of truth. Two things broke it. The protocol expresses its highest-frequency action under a name our classifier could not match, and Soroban archives a position out of the ledger with no user action at all. A full-history cross-engine replay put numbers on both.

The intuitive shape of a blockchain indexer is a loop: watch for events, decode each one, fold it into the balance. That is how we built our first pass at indexing Blend, and it is what most people reach for — it is a reasonable assumption, and it is how comparable systems are built on other chains. The assumption is that events are a complete description of state change.

On Stellar they are not, and we found out from two directions. Blend expresses its most consequential actions under names our event classifier could not match, so those actions never reached the activity feed. And underneath the protocol, Soroban’s state archival evicts a wallet’s position from the ledger entirely, with no user action at all — so our fold kept valuing a position the chain no longer held.

We caught both by building a second, independent implementation and replaying fifteen months of mainnet through both engines. This is what it found, and what we got wrong along the way — including once when our own fix over-corrected.

QuirkThe assumption it breaksWhat actually happensWhat it cost us
Interest via rate driftbalances change only on a transactionthe amount is shares × rate, and the rate lives on the reserve, not on youa balance that silently never grows
Liquidation as auction fillactions are named (liquidate)emitted as fill_auction with a numeric auction_typethe highest-frequency action missing from the activity feed
State eviction (TTL lapse)an entry exists until a user deletes itthe protocol evicts unmaintained entriesvaluations published for a position that was off-ledger

The action we could not name

Our activity classifier decoded event names by keyword. To catch liquidations it looked for the substring liquid. On real Blend v2 mainnet data, that branch never fires — because Blend does not emit a liquidate event. Liquidations, bad-debt fills, and interest fills all run through the same auction machinery and surface as fill_auction, carrying a numeric auction_type topic (0 = user liquidation, 1 = bad debt, 2 = interest). A substring match never inspects a numeric field.

The action vocabulary underneath is the pool’s RequestType enum, and it is numbers all the way down:

#[repr(u32)]
pub enum RequestType {
    Supply = 0,                     Withdraw = 1,
    SupplyCollateral = 2,           WithdrawCollateral = 3,
    Borrow = 4,                     Repay = 5,
    FillUserLiquidationAuction = 6, FillBadDebtAuction = 7,
    FillInterestAuction = 8,        DeleteLiquidationAuction = 9,
}

We went and counted. Reading the Blend v2 contracts against fifteen months of observed mainnet events across six position-bearing pools produced a catalogue of 43 distinct actions. Our classifier decoded 12 event names and silently quarantined 9 others that actually occur on mainnet. The largest by volume was fill_auction3,835 occurrences on one pool alone, none of them producing an activity row.

What makes it high-stakes is who calls these:

RequestWho initiates itWhose balance moves
FillUserLiquidationAuction (6)a third-party liquidatorthe liquidated user’s — who signed nothing
FillBadDebtAuction (7)backstop / liquidatorthe defaulted user’s, via socialization
FillInterestAuction (8)a fillerthe reserve’s, distributed to suppliers

The most consequential balance changes on the pool are third-party actions the observed wallet never authored. An indexer that only records what a wallet does to itself has a hole shaped exactly like its liquidations.

One correction to the story we first told ourselves, because it matters and because it is the whole lesson in miniature: this did not corrupt position balances. Our state fold reads the resulting ledger entries — the user’s positions map, the reserve’s config and data — and is entirely independent of event names. A liquidation still rewrites the liquidated user’s Positions entry, and the fold still picks that up. So the balances stayed right while the activity feed went blind. The concrete user-facing consequence is narrower than “your position is wrong”, and stranger: a user could be liquidated, watch their collateral vanish from the balance, and find no activity explaining it.

The path that read state survived. The path that waited for events did not. That is the thesis of this post, and we did not arrive at it by argument.

The keyword matcher is gone. The decoder now maps event names exactly rather than by substring, and reads the auction subtype from its u32 topic discriminator — the liquidation, bad-debt and interest cases the symbol itself never distinguishes.

Interest is a number that changes while nothing happens

The same split shows up before you get anywhere near a liquidation. A Blend supplier’s balance is not stored as a token amount. It is stored as a share count, and the amount is derived: balance = shares × b_rate, where the reserve’s b-rate (and the borrower-side d-rate) is an exchange rate that drifts upward as interest accrues.

The important structural detail is where that rate lives. The b-rate is reserve state, carried in the reserve’s data entry — not in the user’s entry. Our own decoder tests pin that separation explicitly: a reserve’s config payload carries c_factor but not b_rate, because config and data are different entries with different lifecycles.

ledger N     :  shares = 1,000   b_rate = 1.020000   →  balance = 1,020.00
ledger N+50k :  shares = 1,000   b_rate = 1.020411   →  balance = 1,020.41
             ^ no transaction, no event on the user's entry — only the rate moved

A poll-the-events indexer sees nothing between those two ledgers and reports the balance unchanged, because the only thing that moved was a rate on the reserve. To get this right you cannot wait for a deposit — you have to read the reserve’s rate state and revalue the share count. The balance is a function of state, not the sum of a stream of events.

That same fact — that interest ticks write the reserve’s entries and not the user’s — is what set up the second failure, and it is the one that actually reached served data.

The ledger evicts your state while you sleep

Every Soroban contract-data entry has a time-to-live. A Blend wallet’s persistent Positions entry has one clock, and that clock anchors at its last on-chain write — a deposit, borrow, withdraw, or repay. Interest ticks do not refresh it; they write the reserve’s entries, not the wallet’s. Leave a position untouched past its TTL and the protocol evicts it: while evicted, the entry does not exist on-ledger at all. A later restore brings it back. None of this is a transaction the user sends.

These are not soft limits. Read over RPC, mainnet’s archival settings put the persistent-entry minimum at 2,073,600 ledgers and the maximum entry life at 3,110,400 ledgers.

Our fold kept every position in memory and never consumed the ledger’s eviction list. So it sailed straight through the evicted window, re-emitting share × reserve_rate at its flush cadence for an entry the chain no longer held. The share count was right — it is the retained pre-eviction share — but the valuation was a fiction: it priced a balance that was off-ledger.

What the replay actually found

We only know the size of this because we built a second implementation and compared. A full-history cross-engine replay covered 37,973 wallets across 6,848,288 mainnet ledgers — roughly fifteen months — running the production fold against an independent engine. Of 885,796 rows compared: 94.21% matched exactly, 5.78% were value-consistent at a cadence offset, and 100 rows — 0.011% — were real divergences. Every one of the 100 was a fold defect; the independent engine was right.

The 100 split into two families: 58 where the fold valued entries after they had been evicted, and 42 phantom-zero emissions.

Getting to that conclusion took three rounds, and our first read was wrong. The initial dossier called the divergences a revaluation-cadence difference and treated the second engine’s silence as the anomaly. An independent refutation then argued TTL archival was impossible for most of the sample. Only the third pass — reconstructing each entry’s write history — settled it, on three independent quantitative signatures that agree:

  • Across a 33-row sample, every entry sat unwritten across its window longer than the protocol’s guaranteed persistent lifetime. 19 of 33 sat unwritten longer than the maximum lifetime any entry can hold, making eviction protocol-forced rather than merely possible.
  • The independent engine stops valuing each entry at a median of 2,096,298 ledgers past that entry’s own last write — the minimum persistent TTL plus a small scan lag. A cadence model has no reason to halt at a protocol constant, reproduced across unrelated wallets each anchored at its own write ledger.
  • Valuation floors cluster: eight unrelated wallets stop at the same ledger within a ~1,700-ledger band, which is what a single batched eviction-scan pass looks like and is not what a per-wallet cadence produces.

Walked end to end on one public wallet: it deposits at ledger 59,476,041, and its Positions entry is not written again for 3,481,895 ledgers — past the maximum entry life, so eviction is forced. The reserve kept ticking interest the whole time, but those ticks wrote the pool’s reserve entry, not this wallet’s. The independent engine emits nothing after 61,552,737. The fold emitted a row deep inside that window, carrying the retained share at a fresher rate.

The fix has two halves. The fold now decodes the ledger close meta’s evicted-key list and synthesizes the removals the ordinary change stream never reports; and the adapter archives a TTL-lapsed entry in place rather than purging it, so the fold stops revaluing it instead of carrying it forward. A regression test pins that an archived, untouched entry emits exactly zero rows across its evicted window.

Through the evicted windowEvent-driven in-memory foldArchival-aware fold
Share countretained (correct)retained (correct)
Valuationshare × fresher_rate (fiction)none — sealed at eviction
Entry statestill livearchived, with the archival ledger recorded

The over-correction

We then got it wrong in the other direction, which is the part most worth writing down.

Having identified 58 bad rows, we retracted all 58 from served data under a row-count guard. Re-adjudication later showed the classifier behind that retraction had mishandled a subclass: rows written at a restore ledger are the entry coming back on-ledger with chain-exact values, not ghosts. On the final split, only 6 of the 58 were genuine post-eviction ghosts. The other 52 were chain-true rows we deleted.

The evidence was unambiguous once we looked for it — decoded close-meta showing LedgerEntryRestored with exactly the retracted share, and an RPC lastModifiedLedgerSeq equal to the row’s own ledger. Our classifier’s restore-detection field was simply empty for those rows; it had not found the restore that was plainly there. Serving data has since been re-derived end to end from the deploy floor, so the 52 re-enter through the fold itself and the 6 never do.

Both mistakes have the same shape, and it is not a coding error. Distinguishing an eviction from a close, and a restore from a deposit, cannot be done from current state. getLedgerEntries serves only now: an archived entry looks identical to one that never existed, and a restore write looks exactly like continuous liveness. It takes the entry’s full write history to know whether a gap was an eviction. We went wrong first by ignoring that history and then by reading it with a classifier that missed half of it.

What you observeWhat it might meanThe trap
A balance disappearsevicted (archived), not closed to zeroan archived entry is indistinguishable from one that never existed
An entry reappearsa restore, not a new depositcounting the restore ledger as activity invents a deposit that never happened
A read inside a gapthe entry was off-ledger thenre-emitting a value through the gap prices a balance the chain did not hold

Build for the actions nobody announces

One discipline covers all of it: derive state from what the chain holds; don’t accumulate it from the events you happen to receive.

Interest accrues with no event on your entry, so revalue from reserve rate state rather than waiting for a deposit. Liquidations arrive as numeric auction fills initiated by third parties, so enumerate what the protocol can do from its RequestType enum instead of trusting what your handlers happen to catch. And the ledger archives entries on its own clock, so treat a vanished balance as possibly-archived and a reappearance as possibly-a-restore until the write history says otherwise.

The part we would emphasise to anyone building the same thing: none of these failures announced themselves. Nothing crashed, no error rate moved, and the pipeline reported healthy throughout. We found the eviction defect because we had a second implementation to disagree with the first, and we found the over-correction because someone re-checked a fix that had already shipped. On a chain where state changes without emitting an event, a check that only watches the event stream is a check that passes by not looking.