Blog
·Orion Engineering

Blend doesn't owe you an event, and neither does the ledger

An indexer built on "wait for the event, then fold it" breaks twice on Stellar. Blend moves balances with no transaction and names its biggest action numerically; and Soroban's state archival evicts a position from the ledger with no user action at all. Two mutators of user state that never emit the thing you'd poll for.

The intuitive shape of a blockchain indexer is a loop: watch for events, decode each one, fold it into the balance. It works right up until you index something real. On Stellar it breaks twice, from two directions, for the same underlying reason — user state changes without the protocol or the chain emitting the event you were waiting for. Blend accrues interest with no transaction at all and expresses its single most frequent action as an anonymous numeric code; and underneath it, Soroban’s state archival can evict a wallet’s position from the ledger entirely, with no user action, and resurrect it later. Neither owes you an event.

QuirkThe naive assumption it breaksWhat actually happensCost if you miss it
Interest via rate driftbalances change only on a transactionshares × rate, and the rate drifts every ledgerpositions silently never grow
Liquidation as auction fillactions are named (liquidate)fired as fill_auction with a numeric auction_typethe highest-frequency action is dropped
Dust / phantom closesa close means the bucket is emptyresidual stroops remain on-ledgera bucket reported closed that the chain holds open
State eviction (TTL lapse)an entry exists until a user deletes itthe protocol evicts unmaintained entriesa balance vanishes; a stale value is re-emitted through the gap

Interest is a number that changes while nothing happens

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 every ledger as interest accrues.

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, no user action — 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, not the user’s entry. To get this right you cannot wait for a deposit — you have to read the reserve’s rate state and revalue the share count on every ledger the rate ticks. The balance is a function of state, not the sum of a stream of events.

The highest-frequency action has no name

Blend does not emit a liquidate event. Liquidations, bad-debt fills, and interest fills are all expressed through the same auction machinery and surface as fill_auction, carrying a numeric auction_type topic (0 = user liquidation, 1 = bad debt, 2 = interest). The action vocabulary 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,
}

A classifier that matches on event names — looking for the substring liquid — never fires on real V2 data, because the event that represents a liquidation does not contain the word. (We’ve written about that failure from the indexer’s side in Every signal was green; this is the protocol side of the same coin.) 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. (Positions can also close carrying residual stroops — a dust “phantom close” the chain still holds open; we walk one 26-stroop case in Rebuild our numbers yourself.)

The ledger evicts your state while you sleep

Underneath the protocol, Soroban itself mutates user state without a user. Every contract-data entry has a time-to-live, and a Blend wallet’s persistent Positions entry has one clock that 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 user’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 resurrects it. 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 — and evictions run as batched sweeps, so unrelated wallets that lapsed in the same window are collected together rather than one at a time.

The bug this shape creates

A fold that keeps every position in memory and never consumes the eviction list will sail straight through the evicted window, re-emitting share × fresher_rate for an entry the chain no longer holds. The share is right — it is the retained pre-eviction share — but the valuation is a fiction: it prices a balance that is off-ledger. This is exactly the divergence an independent verification engine caught over the pools’ full life, sealing its output at eviction and staying silent while the in-memory fold kept talking. The lidapters v0.9.0 state-fold work is the fix: a TTL-lapsed or evicted entry is now archived in place rather than purged, so the fold stops revaluing it instead of carrying it forward.

Through the evicted windowNaive in-memory foldArchival-aware fold
Share countretained (correct)retained (correct)
Valuationshare × fresher_rate (fiction)none — sealed at eviction
Entry markedstill liveArchived / ArchivedLedgerSeq
On mainnetdrops 300+ dormant-but-live holders from statekeeps them, correctly dormant

The cleanup tax

Once you accept that the chain archives state on its own schedule, three rules follow for anything built on Soroban — an indexer, a wallet, a portfolio tracker. Each is a place a naive reader posts a wrong number, and telling the cases apart is the tax you pay for building on an archived ledger:

What you observeWhat it might meanHow to tell — and the trap
A balance disappearsevicted (archived), not closed to zerogetLedgerEntries returns current state only; an archived entry looks identical to one that never existed
An entry reappearsa restore, not a new depositcounting the restore ledger as activity invents a deposit that never happened
A historical read of a gapthe entry was off-ledger thenre-emitting a value through the gap is a post-eviction ghost — it must be suppressed, not carried

Distinguishing an eviction from a close, and a restore from a deposit, cannot be done from current state — getLedgerEntries serves only now, 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. A read model that gets this wrong doesn’t crash; it quietly reports a balance for a position the chain had archived, or logs a phantom deposit the day an old wallet is restored.

Build for the actions nobody announces

Both halves of this reduce to one discipline, and it is the same one that defends against silent failures generally: derive state from what the chain holds, don’t accumulate it from the events you happen to receive. Interest accrues with no event, so revalue from rate state every ledger. Liquidations arrive as numeric auction fills by third parties, so enumerate what the protocol can do from its RequestType enum, not what your handlers 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. Blend doesn’t owe you an event, and neither does the ledger — so stop waiting for one.