The Null Input Problem: What Your Stack Does When the Data Never Arrives

0xMax
Markets

Last month one of my team's research pipelines returned a nine-section report. Every field was populated. Every field read "insufficient information."

The Null Input Problem: What Your Stack Does When the Data Never Arrives

The schema validated. The renderer ran without a warning. A portfolio analyst downstream read the section headers — Technical, Token Economics, Market, Regulatory — and asked the only question that mattered: where is the risk? The answer was nowhere and everywhere. The document was structurally perfect, semantically void, and it had passed every check we had built.

I have spent a decade reading code that makes the same mistake. Not code that computes the wrong number — code that computes nothing and reports success. Over the past seven days, with majors pinned inside a 6% band and realized volatility near yearly lows, the most expensive value in crypto is not a bad price. It is an empty one wearing a fresh timestamp.

Speed is an illusion if the exit door is locked.

In Solidity, every type has a zero value. uint256 defaults to 0, address to 0x0, bool to false, arrays to length zero. There is no distinction between "not set" and "set to zero." The type system has no null, so null leaks into semantics: it becomes a legal value that means both "nothing" and "something," and the compiler will never tell you which one you received.

This is why OpenZeppelin's Initializable tracks initialization with a dedicated boolean instead of testing implementation == address(0). It is why Uniswap V2 mints the first 1,000 LP tokens to address(0) and burns them: the empty state has to be made expensive before it can be occupied. Both are patches over the same hole.

On-chain, the hole has three doors. Oracles that return a zero or a carried-over round. Pools whose reserves are zero, where price is undefined and any default becomes a trading opportunity. Data availability layers that cannot distinguish "no data was posted" from "data was posted and I cannot see it."

All three are the same bug at different altitudes: a system that fails open. And all three get more likely to bite in a sideways tape, because quiet markets are exactly when someone proposes deleting the guard to save gas.

That is not a rhetorical concern. In a trending market, a stale price gets caught because somebody is staring at a chart. Inside a 6% band, a frozen feed and a live feed produce identical P&L for weeks — which is precisely how long it takes for a guard to be reclassified as dead code and removed. Chop is for positioning, and the position worth taking right now is not directional. It is the one where your liquidation engine still functions on the day the sequencer stops.

The Null Input Problem: What Your Stack Does When the Data Never Arrives

The oracle null path

Here is the check I find missing in roughly one in three lending integrations I review:

(uint80 roundId, int256 answer, , uint256 updatedAt, uint80 answeredInRound)
    = feed.latestRoundData();
require(answer > 0, "bad price");
require(updatedAt > block.timestamp - HEARTBEAT, "stale");
require(answeredInRound >= roundId, "carried round");

Read it in order. answer > 0 catches the degenerate zero but not a negative answer, which is why feeds that can legitimately print negative — funding rates, spreads, certain commodity pairs — get bricked by copy-pasted guards from a USD pair. updatedAt > block.timestamp - HEARTBEAT catches staleness up to your heartbeat and no further. answeredInRound >= roundId catches the carried-over round, where the aggregator re-serves the prior answer under a fresh round ID because it could not reach quorum.

The heartbeat is a liveness bound, not a correctness bound. That distinction is where the money goes. A feed with a 24-hour heartbeat that freezes for four hours passes every check above — the answer is fresh by policy and wrong by reality. Add the L2 case: sequencer uptime is a separate oracle, and most integrations I audit read the price feed and never read the uptime feed at all. When the sequencer halts, the price feed does not revert. It simply stops updating, and the lending market keeps quoting liquidations against the last known state.

Model it. A $50M market at 5x max leverage, a four-hour sequencer outage, a 12% move during the outage. Liquidations queue off-chain. The first block after the sequencer restarts contains the entire queue, ordered by whoever paid the highest priority fee. Bots clear it, retail eats the residual, and the protocol's risk dashboard stays green throughout, because the oracle returned a number. A null return is not silence. It is a verdict.

The empty pool

Uniswap V2's library, verbatim:

function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut)
    internal pure returns (uint amountOut)
{
    require(amountIn > 0, 'UniswapV2Library: INSUFFICIENT_INPUT_AMOUNT');
    require(reserveIn > 0 && reserveOut > 0, 'INSUFFICIENT_LIQUIDITY');
    ...
}

Two require statements, three gas each. Two cold SLOADs for the reserves, 2,100 gas each. The guard is 0.3% of the cost of the read it protects. Stripping it saves nothing measurable and buys unbounded liability — and every forked codebase I have reviewed that stripped it did so during low volatility, in a gas-optimization PR that shipped on a Friday.

The Null Input Problem: What Your Stack Does When the Data Never Arrives

The empty pool has a subtler form than division by zero: the first-depositor share inflation attack. When totalSupply == 0, share price is undefined, and the first depositor can donate directly to the pair to set it. Subsequent depositors round down to zero shares. The fix is a virtual offset or a dead-shares mint, but the point is structural. The empty state is a state, and if you do not price it, someone else will. The same logic applies to pools that look full only because incentives filled them: stop the emissions and watch how fast the reserve vector returns to zero — and how fast the math breaks when it does.

The token that returns nothing

ERC-20's transfer is specified to return bool. A meaningful share of deployed tokens return nothing at all. The call succeeds, returndatasize is zero, and a naive require(token.transfer(to, amt)) decodes empty returndata as false — or worse, a low-level wrapper treats the empty return as success. This is the original null input in this ecosystem, and it predates every rollup. SafeERC20 exists because it checks returndatasize explicitly instead of trusting the ABI. Most integrations import it. Fewer understand why.

The empty message

Cross-chain messaging inherits the hole. A payload of length zero is a legal bytes value. A receiver that dispatches on payload.length and falls through to a default branch has just executed an unauthenticated action. I have found this pattern three times in bridge adapters, always in the else branch the developer assumed was unreachable. The unreachable branch is where the null lives.

The availability asymmetry

EIP-4844 gave rollups a separate fee market for blobs: target three per block, maximum six, minimum blob base fee of one wei. Empty or near-empty blobs are cheap to post, which is the intended design. The unintended consequence is that "the blob is present and empty" and "the blob was never posted" are indistinguishable to a client that only checks for the presence of the commitment. Every rollup cost model I have reviewed this year is a model of an empty blobspace — a 1-wei floor extrapolated into a world where the target/max ratio absorbs a 2x demand expansion before price moves at all. That model does not survive contact with saturation.

Data availability sampling has the same shape. Sampling yields a one-sided probabilistic guarantee: enough successful samples, and the data is probably retrievable. It does not yield the converse. If the data was never posted, a sampling routine with a permissive default returns success over an empty column set — the same failure mode as an oracle returning zero with a fresh timestamp, one layer down. When my team reviewed Celestia's blobstream relay in 2024, the finding that mattered was not the KZG math. The commitments were sound. The finding was that the attestation signing set was concentrated enough that a small coalition could, in principle, produce a valid attestation over a range containing nothing. The chain would accept it. Nothing in the verification path encoded the sentence "this range must contain N blobs."

Bitcoin sits at the far end of the same axis. Witness space is the most expensive data availability layer in existence, and inscriptions use it to carry the least dense payload in the market. The car is fine. The cargo is the problem.

The research analogue

Which brings me back to the pipeline. Stage one returned an empty information-point list. Stage two rendered a template with nine section headers and zero facts. That is the stale-oracle bug wearing a suit: a fresh-looking artifact with a zero inside it, passing every check because no check was written for the empty case.

The fix is not more validation. The fix is making the absence unrenderable. If the schema requires at least five sourced information points, stage two refuses to run. Fail-closed. It costs you a retry and saves you a decision. Most teams build the opposite, because failing closed generates tickets and failing open generates silence.

How I test for it: I do not read the happy path first. I read the struct definitions, then grep for every place a zero value can reach a branch. Then one question per branch — if this input were empty, would the contract revert or would it write? Reverting is a design decision. Writing is a bug. In the 0x Protocol v1 audit I ran as an undergraduate in 2017, the integer overflow made the headline, but the reason it was exploitable was an order struct whose zero-value fields passed signature validation because the signing logic never asserted that maker and taker differed.

A cold SLOAD is 2,100 gas. On a 150,000-gas transaction that is 1.4%. That is the entire argument for keeping the guard: your savings are 1.4%, your tail risk is the pool.

Zero-knowledge does not fix any of this, and the industry's assumption that it will is the most expensive consensus in the space right now.

A proof of an empty batch is a valid proof. It is also cheap — the circuit has almost nothing to constrain. Proving absence is orders of magnitude cheaper than proving presence, and that asymmetry is structural, not an implementation detail. As we migrate toward verify-don't-trust, we are building verification paths that are excellent at confirming that nothing happened and structurally indifferent to whether something did.

In the Halo2 proof-of-training work I ran last year, the hardest constraint to write was not the matrix multiplication. It was the constraint that the step list is non-empty and that every step links to its predecessor. Without it, a model emits a perfectly valid proof over zero training steps and the verifier accepts it. The circuit is the policy. If your policy does not say "non-empty," your proof system will certify the void with cryptographic finality.

Logic prevails, but bias hides in the edge cases.

The next nine-figure loss in this industry will not be an integer overflow. It will be an empty array that every party in the system agreed was valid, at a timestamp that looked fresh, in a tape quiet enough that nobody re-read the require. I will put a date on it: within eighteen months, a top-twenty protocol reports a loss it attributes to "unexpected oracle behavior," and the post-mortem shows a zero that passed a freshness check.

Ask your team one question this week. When the data does not arrive, does the system stop, or does it render?