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.
| Quirk | The naive assumption it breaks | What actually happens | Cost if you miss it |
|---|---|---|---|
| Interest via rate drift | balances change only on a transaction | shares × rate, and the rate drifts every ledger | positions silently never grow |
| Liquidation as auction fill | actions are named (liquidate) | fired as fill_auction with a numeric auction_type | the highest-frequency action is dropped |
| Dust / phantom closes | a close means the bucket is empty | residual stroops remain on-ledger | a bucket reported closed that the chain holds open |
| State eviction (TTL lapse) | an entry exists until a user deletes it | the protocol evicts unmaintained entries | a 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:
| Request | Who initiates it | Whose balance moves |
|---|---|---|
FillUserLiquidationAuction (6) | a third-party liquidator | the liquidated user’s — who signed nothing |
FillBadDebtAuction (7) | backstop / liquidator | the defaulted user’s, via socialization |
FillInterestAuction (8) | a filler | the 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 window | Naive in-memory fold | Archival-aware fold |
|---|---|---|
| Share count | retained (correct) | retained (correct) |
| Valuation | share × fresher_rate (fiction) | none — sealed at eviction |
| Entry marked | still live | Archived / ArchivedLedgerSeq |
| On mainnet | drops 300+ dormant-but-live holders from state | keeps 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 observe | What it might mean | How to tell — and the trap |
|---|---|---|
| A balance disappears | evicted (archived), not closed to zero | getLedgerEntries returns current state only; an archived entry looks identical to one that never existed |
| An entry reappears | a restore, not a new deposit | counting the restore ledger as activity invents a deposit that never happened |
| A historical read of a gap | the entry was off-ledger then | re-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.