On a Tuesday in late March, I pulled the bytecode of an Arbitrum-based AI-agent vault that had closed a $100M raise eleven weeks earlier. Its stated edge was mechanical: rebalance collateral across three lending markets whenever the oracle spread exceeded 40 basis points. The contract ran to about 1,900 lines. It took four minutes to find the flaw. The agent queried its price feed with a low-level staticcall, checked the success flag, and then decoded the returned payload โ except that when the payload was empty, the decoded price defaulted to zero. Not a revert. Not a pause. Zero.
The system had been engineered to read silence as a number.
That is the whole story. Not the model, not the alpha, not the eleven weeks of marketing. A buffer that could be empty, and a branch that assumed it never would be. When I flagged it, the team's first response was that the feed "couldn't" return nothing. Their second response, four hours later, was a patch.
Oracles are not price feeds. They are message-passing protocols that happen to carry prices, and the distinction becomes load-bearing the moment a response is missing. Chainlink's latestRoundData() returns a five-tuple: roundId, answer, startedAt, updatedAt, answeredInRound. Four of those five fields exist for one purpose โ to let the caller ask whether the data is fresh enough to act on. Most integrations read the second field and discard the rest.
I reconstructed the same pattern at scale in 2022 while reverse-engineering Terra's collapse from on-chain flows. Contracts consuming a bare answer variable. No staleness gate, no deviation bound, no circuit breaker. The minting events tracked the whale exits almost perfectly, and the liquidation engine held no opinion about whether the number it was using was current. Trust is a variable, not a constant in DeFi โ and too much deployed code hard-codes it as a literal.
The problem has an older face. In Solidity, a low-level call returns two things: a boolean and a byte array. address(token).call(data) tells you the call did not revert. It does not tell you the callee returned anything. An ERC-20 whose transfer() returns no data โ the pattern that defined Tether, BNB, and roughly a third of the tokens that mattered in 2020 โ hands back true and 0x. Decode that and you revert. Skip the decode and you get an unverified assumption wearing the costume of a checked one. SafeERC20 closed that half of the class in 2020, and the ecosystem declared the bug extinct.
It wasn't extinct. It moved to the oracle layer, where no equivalent library exists, and where the 2026 agent cycle has quietly scaled the blast radius.
The Nine Lines
The wrapper is nine lines long, and it is almost always correct-looking:
(bool ok, bytes memory ret) = address(feed).staticcall(
abi.encodeWithSelector(0x50d25bcd) // latestAnswer()
);
if (!ok) return 0; // branch A
if (ret.length == 0) return 0; // branch B
return abi.decode(ret, (int256));
Branch B is the crime scene. It reads like defensive programming โ a null check, visible in the diff, praised in review. But ret.length == 0 is not an error condition for a staticcall. It is the normal shape of an externally-owned address, an uninitialized proxy, a self-destructed aggregator, or a selector that simply doesn't exist on the target. The check does not reject malformed data. It converts absent data into the integer zero and forwards it downstream carrying a success flag attached.
This is why Chainlink deprecated latestAnswer() in the first place. The function returns a single int256 with no round metadata, which means a caller has no way to distinguish a fresh price from a carried-over round, a deprecated aggregator, or an aggregator that answered with nothing. A single-value read is not a smaller version of latestRoundData(). It is a different instrument, one that cannot report its own uncertainty.
Downstream, zero is not neutral. Every lending market computes borrow capacity as collateralValue * ltv / 1e18. Set the collateral price to zero and the collateral value collapses โ every open position becomes instantly liquidatable regardless of health. Set the borrowed asset's price to zero and the debt becomes free, letting an attacker mint unlimited liability against a worthless denominator. One missing field, two opposite catastrophes, both reachable from the same nine lines.
There is a distinction worth dwelling on, because it determines whether the failure is observable at all. When a decode reverts, the transaction fails, gas is burned, and the protocol halts. That is a denial of service, and it is loud โ anyone watching uptime sees it. A silent zero is the opposite. Every transaction succeeds. Every state transition is legal. Every event is emitted correctly. The protocol's own logs will show a healthy liquidation. Nothing on-chain flags an anomaly, because from the contract's perspective there wasn't one.
The only way to see it is to compare the feed against an independent source at the same block height. That is what made the March incident visible: spot prices on three venues were unchanged while the vault's internal collateral values printed zero. Two data sources, one block, a disagreement. In the audit I ran last year, that comparison was the entire detection method โ replay every liquidation block, pull the oracle answer from the transaction's state, diff it against a venue-side TWAP. Divergence was the signal, and the divergence was always a round that had been answered with nothing in it.
Why Review Doesn't Catch It
Last year I led a verification project on autonomous agent execution integrity, running static analysis across 214 agent-facing contracts. Sixty-one read prices through a single-value latestAnswer-style call. Twenty-three had a fallback branch returning a literal zero. Twelve had no staleness gate at all. Those twelve were the ones I flagged as front-runnable โ not hackable in the classical sense, but predictable. An operator who knows a wrapper returns zero on an empty response knows exactly which block triggers the liquidations, and can position one block earlier.
A human risk desk would look at a zero price and call the feed dead. An agent looks at a zero price and consults its policy. If the policy says liquidate below 1.05 collateral ratio, the agent liquidates. It does not ask whether the number is plausible. Six agents sharing three feed wrappers fired inside the same block. From the outside, the tape looked like a crash. Spot venues never moved.
There is no attacker in the classical sense, which is exactly why this class evades incident reports. Nothing was drained. No key leaked. A number that shouldn't have existed was consumed by code with no capacity to doubt it. Inverse Finance lost $15.6M in April 2022 to a manipulated feed. BonqDAO lost roughly $120M in February 2023. Mango Markets, Venus โ the pattern repeats with different mechanics and an identical root: an unvalidated number entering a system that treats validation as someone else's job. History repeats not by fate, but by flawed code.
The correct pattern is boring and expensive:
(uint80 roundId, int256 answer, , uint256 updatedAt, uint80 answeredInRound)
= feed.latestRoundData();
require(answer > 0, "non-positive answer");
require(updatedAt > 0 && block.timestamp - updatedAt <= HEARTBEAT, "stale");
require(answeredInRound >= roundId, "carried-over round");
Four checks. In my sample, nine contracts performed all four. Why does the rest survive review? Audit scope is drawn at the contract boundary, and the oracle wrapper is almost always inherited from a base contract or pulled from a shared library. It is classified as trusted infrastructure โ the thing you assume is correct so you can spend budget on the interesting logic. The interesting logic sits downstream of the assumption. When the assumption fails, every subsequent line is still individually correct.
Layer 2 makes it worse. Sequencer outages on rollups produce precisely the conditions that make a feed read stale or empty, and the same window is when liquidations are most profitable to front-run. Every permissionless Uniswap V4 hook that touches an external price inherits this surface, and nobody reviews hooks. And the remediation path โ swapping the wrapper, adding the guard โ runs through a 4-of-7 multisig, which means the contract holding the fix is the same contract that could have shipped the bug.
The Amplifier, Not the Cause
The dominant narrative out of the March incident blamed the bots. "Autonomous agents caused the cascade" is a cleaner headline than "a null check defaulted to zero," and it fits a story the market already wanted to tell about AI overreach.
That framing is a category error, and it is the same error the industry made about Terra โ blaming a collapse on sentiment when the causal chain was visible in mint events forty-eight hours earlier. Remove the agents and a keeper bot does the identical thing in the next block. The only difference is timing. With humans in the loop, the drawdown profile looks like a dip. With agents, it looks like a cliff. The amplifier changed. The fault didn't.
The deeper blind spot is institutional memory. The ecosystem believes this bug class was closed in 2020. SafeERC20 was a real fix โ and it was a fix for tokens. There is no SafeOracle. The belief that the problem is solved is itself the vulnerability, because it means nobody is looking, and it means a nine-line wrapper in a shared library can sit unexamined across forty downstream integrations for four years while the TVL it guards grows by an order of magnitude.
Watch the next liquidation cascade that isn't preceded by a spot move. If liquidations concentrate in a single block while spot venues and perp funding stay flat, the market didn't reprice โ a feed did. Pull the affected wrapper, check ret.length, and check answeredInRound. The number was never the problem. The branch that accepted it as one was.