Data First
Three numbers before any argument.

- 41 minutes. The gap between the last successful indexer write and the moment the alert reached my desk. During that gap, my console displayed a fully healthy book.
- 7. The number of distinct states a price feed can occupy that a two-state dashboard collapses into the single word fine.
- 0.0. What
pandasreturns when you sum a column containing onlyNaN.
Hold those three. The rest of this article is about what they cost.
Hook
The alert fired at 03:12. Not a price alert. A silence alert.
My team's risk console had been showing collateral coverage at 214 percent, bad debt at zero, liquidation queue empty. Clean numbers. Green numbers. The kind of numbers that let a desk lead go back to sleep. The console was not lying. It was answering a different question than the one I had asked. I asked: what is our bad debt? It answered: what was our bad debt forty-one minutes ago, expressed in the present tense.
The subgraph had stopped writing at 02:31. The RPC endpoint had begun returning partial logs at 02:29 after a provider-side failover. The cache layer, doing exactly what it was built to do, kept serving the last good payload. Three independent components failed in three different ways and produced one coherent, reassuring picture.
Zero is a measurement only when the instrument is on. When the instrument is off, zero is a decoration.
Two weeks later I received a risk report. Nine dimensions. Technical, tokenomics, market, ecosystem, regulatory, team, risk, narrative, supply-chain transmission. Every cell read N/A โ insufficient information. Someone had built a genuinely correct analytical framework and fed it nothing. The framework did not crash. It did not warn. It produced a document. A well-formatted, internally consistent, entirely empty document that could have walked into an investment committee and been read as no findings.
That is the failure mode. Not a wrong number. A blank that has been formatted to look like a right one.

The most expensive number in crypto is not a wrong number. It is an absence that has been rendered into a float.
Context
Crypto risk data moves through five hops before it reaches a human or an execution engine.
Chain state. RPC provider. Indexer. Warehouse or cache. Dashboard or model.
At every hop, a failure must be rendered into a type. That type is almost always a float, a double, a uint256, or a nullable column. None of those types carries a first-class notion of I did not look. NaN exists in floating point and is, by design, contagious in arithmetic โ but every aggregation function in every mainstream language has a default that quietly deletes it. uint256 has no NaN at all, which is why protocol designers invented sentinels. type(uint256).max. 0. address(0). Each sentinel is a promise that no legitimate value will ever equal it. Each promise eventually breaks, and it breaks silently, at the exact moment the protocol is under stress and the value it is carrying happens to be legitimate.
The industry has spent a decade on oracle manipulation. Far less on oracle absence. Manipulation is loud โ it moves a price, triggers a liquidation cascade, generates a post-mortem, gets a conference panel. Absence is quiet. It freezes a number and waits. Nobody writes a blog post about a feed that stopped updating on a Tuesday.
Consider how different oracle designs express null. They do not share a signature, which is why a single monitoring rule never catches all of them.
Chainlink push feeds. The null looks like updatedAt far in the past. It also looks like answer == 0 during a feed migration, and like a round that never completed, where answeredInRound < roundId on older aggregator versions. Note the deprecation: in newer aggregators answeredInRound is populated with the round ID and carries no staleness information. Code copied from a 2021 tutorial and pasted into a 2026 deployment will pass its staleness check on a dead feed.
Pyth pull feeds. The null looks like publishTime behind block.timestamp. It also looks like a very wide conf band โ a confidence interval that has ballooned is a feed telling you it does not know, in the only grammar it has. Most integrators read price and discard conf entirely. They are discarding the uncertainty channel.
TWAPs. Uniswap V3 observe reverts with OLD if you request a window the ring buffer does not hold. That revert is good design. The oracle refuses to answer rather than answering badly. Compare that to a protocol that falls back to spot on TWAP failure โ it has converted a refusal into a lie.
Internal accounting. Liquid staking exchange rates, lending indices, vault share prices. The null here looks like a rate that has not moved in eight hours on a day when the underlying moved four percent. There is no revert. There is no event. There is just a number that stopped.
In 2020, building the Aave V1 liquidation engine, my team cut false positives by fifteen percent with one structural change. We stopped treating the feed says the position is unhealthy and the feed did not answer as the same branch. Before that change, a timeout looked identical to a healthy position in one code path and identical to a critical position in another, depending on which hop dropped the payload. We replaced the boolean with a tri-state enum and let the compiler force an explicit decision at every branch. It was not a clever change. It was a boring change. It was the single highest-return engineering decision of that quarter.
Here is the bull-market problem, and it is specific. In a bull market, coverage ratios are high. A frozen number is plausible for hours because plausible is the default state. Nobody notices a stale feed when the answer it is serving would have been roughly right anyway. Bull markets are where you build null handling. Bear markets are where you discover whether you built it. Every desk that survived 2022 had, somewhere in its stack, a rule written in 2021 by someone who was bored and suspicious.
Core
Seven states, not two
Start with the type. A price is not a number. A price is a tuple.
(value, source, observed_at, quality)
The quality field must be an enum, and the enum must be exhaustive enough that no branch can fall through to fine. My taxonomy:
FRESH. Value present, timestamp within the feed's own heartbeat, source agrees with at least one independent source within tolerance.
STALE. Value present, timestamp outside heartbeat. The value may be correct. You do not know that it is. Treat as unknown.
ABSENT. No value. The call reverted, the indexer lagged, the provider returned an error, the field is null.
ZERO_LEGITIMATE. The value is genuinely zero. A token that has been exploited to zero. A dead pool. A delisted asset. This state exists and it is the reason require(answer > 0) is not a complete staleness check.
ZERO_SENTINEL. The value is zero because a contract initialised a storage slot, or because a try block failed and returned a default, or because a mapping lookup missed. Indistinguishable from ZERO_LEGITIMATE at the type level. Distinguishable at the provenance level.
CONTRADICTORY. Two sources, both fresh, disagree beyond tolerance. This is the state that most systems do not have, which means the disagreement gets resolved by whichever source is checked last. Ordering is not a risk policy.

PARTIAL. Some assets in the basket priced, some not. The basket total is computable and meaningless.
Seven states. A dashboard has two: number, or no number. The gap between seven and two is where every one of these incidents lives.
The contract layer: make absence loud
A revert is a loud failure. It stops the transaction, emits nothing, and forces the caller to have an opinion. A return 0 is a silent failure. It propagates into arithmetic and becomes a price.
Push every check you can into the revert direction.