Assumptions: The Root of All Bugs

Share
Assumptions: The Root of All Bugs

Last week while presenting findings to a client, I saw a familiar frustration on their faces: How did we make these mistakes? Why do we have so many bugs? And the question nobody says out loud: what if the auditor had missed them too?

Every bug is born from the gap between what a developer assumes to be true and what is actually true, at every conceptual level, from architectural boundaries down to raw bytecode.

It is impossible to fully resolve this, but through a different way of thinking, the gap can be narrowed.

I Think, Therefore I Assume

We run on assumptions. Treating unverified beliefs as established facts is how we navigate a world too complex to verify from scratch. Without them, you would be paralyzed by the sheer volume of verification required.

Kahneman (2011) calls this WYSIATI — What You See Is All There Is. Your mind takes whatever information is available, constructs the most coherent story it can and delivers it to your consciousness as certainty. It does not flag what is missing. In DeFi, where immutable code transports billions of dollars daily, a cognitive system that filters its own blind spots is a latent disaster.

Experience does help, but it is a costly and painful teacher. And even senior developers carry assumptions they cannot see. The assumptions that have been confirmed rather than corrected become more rigid with every repetition, until they stop being questioned entirely.

Level 1: Cross-Domain Assumptions

The most elusive bugs often do not live in any single component or system, but at the intersection between different domains, where on-chain meets off-chain. Each system can be perfectly correct within its own world, yet the assumptions they make about each other can be wrong.

I discovered this firsthand during an audit of a staking protocol that had designed a migration mechanism. Every action within the staking protocol (deposits, withdrawals, staking, unstaking) emitted an on-chain event. An off-chain monitoring service captured these events and maintained what it believed to be a complete picture of the system's state. When the migration triggered, TypeScript code within the same repository queried the off-chain service to retrieve the full state and the new contract was initialized with that state.

Two separate security teams audited this system. Web3 researchers reviewed the on-chain contracts, and Web2 researchers reviewed the off-chain service. Each team found their respective component to be correct, and each team was right. The critical bug lived in neither component, but in the assumptions between them.

The off-chain service captured all events faithfully. But the SQL query that retrieved state for the migration function filtered exclusively on Staked events. The reasoning was logical: if someone has staked, they have money in the system, and that value needs to be migrated. But before a user can stake, they must first deposit, and there is always a small number of users who have deposited but not yet staked, whose funds sit in the protocol's deposit contract, real and present, waiting. They emitted a Deposit event, which the off-chain service captured correctly. But the migration query did not include it.

If the migration had been executed, every user who had deposited but not yet staked would have had their funds silently erased. Not stolen by an attacker, not drained through a clever exploit, just assumed out of existence by a SQL query that reflected a mental model that was almost, but not quite, complete.

The on-chain developers assumed the off-chain system correctly transmitted the complete state. The off-chain developers assumed that Staked events represented the totality of user value. Both assumptions were reasonable, and both were wrong. Because two separate teams each verified their own component in isolation, the assumptions at the boundary went unexamined.

Level 2: Composability Assumptions

The intersection between on-chain and off-chain is only the outermost layer where assumptions hide. On-chain protocols routinely extend trust to other protocols, and those trust relationships carry their own assumptions: implicit beliefs about how the other protocol behaves, rarely stated and often unverified.

In March 2025, Abracadabra lost $13 million because it integrated with GMX V2's asynchronous deposit system without accounting for failed deposits. Most DeFi protocols process deposits atomically: you call a function, your tokens move, the state updates, all within a single transaction. In GMX V2 however, users submit deposit orders that keepers execute later, and those orders can fail.

Abracadabra used GMX's liquidity tokens as collateral and tracked the value of pending deposits in its solvency calculations. The assumption was that a submitted deposit represented real value backing a position. But in GMX V2, a submitted deposit is not a completed deposit; it is a pending order that can still fail. The attacker submitted deposits with parameters deliberately designed to cause failure. GMX correctly returned the input tokens, but Abracadabra's accounting never registered the failure, continuing to report the full value as collateral. The attacker borrowed real assets against collateral that existed nowhere except in the protocol's internal state.

Both GMX and Abracadabra functioned correctly according to their designs. The gap existed in the developer's mind. Having worked with hundreds of deposit functions over the years and seen every one of them complete atomically, that experience became a confirmation that deposits are final, reinforced to the point that it stopped being questioned. A case of what Dane (2010) calls cognitive entrenchment. In composable systems, a correct protocol integrating with another correct protocol does not automatically produce a correct system, since correctness is not transitive.

Level 3: Function-Level Assumptions

Within a single protocol, functions form a dense web of interactions. Touching one strand reverberates through the rest. The dangerous assumption is not about what a function does, but what it makes possible.

In March 2023, Euler Finance lost $197 million because of a small utility function called donateToReserves. The function had been introduced eight months earlier as part of a protocol upgrade. It did one thing: it moved eTokens (Euler's deposit receipt tokens) from a user's balance to the reserve balance, nothing more.

The flaw was the absence of a health check to ensure the user's position remained solvent after the operation, something all other collateral reducing functions in Euler had. The developers' reasoning for this was intuitive: why would anyone voluntarily destroy their own collateral in a material amount? It made no sense to protect users against an action that could only harm themselves. Yet the absence of this tiny check allowed an attacker to exploit Euler's own liquidation mechanism: by using the function to deliberately make a leveraged position deeply insolvent, then liquidating that position at a steep discount, the attacker extracted the difference as profit.

Every component involved, from the deposit tokens and debt tokens to the donation function and liquidation mechanism, belonged to Euler. The developers understood each piece individually. What they did not see was that a utility function, missing a four-line health check that every sibling function had, could be weaponized through the protocol's own liquidation incentives to extract $197 million. The function did exactly what it was designed to do.

It was the assumption about its insignificance that was wrong.

Level 4: Logic-Level Assumptions

Complexity is not a necessary condition for bugs to appear. At the logic level, the distance narrows to its most fundamental form: what a developer believes their code expresses versus what it actually expresses. When the assumption is strong enough, the simplest of errors can hide in plain sight.

In September 2021, Compound Finance put over $160 million in COMP tokens at risk of incorrect distribution due to a misplaced operator: > where it should have been >= in two locations within its Comptroller contract. The comparison controlled whether a first-time supplier's reward index was initialized to the current market index. The error had always been present, but under the old initialization scheme it had no impact. When Proposal 62 changed a default value, the dormant bug awoke: the supplier's index stayed at zero, and the reward calculation treated the user as if they had been supplying since block zero.

In my own work, I often see errors like this. A developer was attempting to extend a decimal safeguard and wrote:

if (decimals < 6 && decimals > 18) revert InvalidDecimals()

An impossible condition that should have used ||. When I showed the finding to the developer, he told me in a baffled voice that he had read the line dozens of times without seeing the error.These bugs are painful precisely because they are obvious in hindsight. But when the assumption is strong enough, the eyes can only see the intention, while the compiler sees the instruction.

Level 5: Machine-Level Assumptions

If logic-level bugs arise from the distance between intention and expression, machine-level bugs arise from a deeper divide: between the developer's mental model and the actual behavior of the virtual machine. Here, assumptions are inherently obscure since the machine operates so far below the developer's everyday thinking.

In March 2025, an attacker drained the entire $355,000 TVL from SIR Trading's vault contract by exploiting a transient storage slot collision in inline assembly. EIP-1153, introduced in Ethereum's Dencun upgrade one year earlier, added transient storage: a key-value store that persists for the duration of a transaction and is cleared at the end. The SIR vault used inline assembly to write to transient storage slot 1 in two places. In the mint() function, tstore(1, uniswapPool) saved the address of a legitimate Uniswap V3 pool so that a callback function could verify the caller's identity. In that callback, after verification passed and new tokens were minted, tstore(1, amount) overwrote the same slot with the number of tokens minted, using it as a return channel back to mint().

The developer understood that transient storage persists within a transaction — that is precisely why they reused slot 1 to pass the minted amount back to mint(). The assumption was subtler: once the security check had passed, it was safe to overwrite the slot, because the callback would not be invoked again. But nothing enforced this. After mint() completed, slot 1 still contained the minted amount, a stale value that no longer represented a pool address but that the callback's verification check would still read and trust. The attacker used CREATE2 to deploy a contract at an address whose numeric value matched the stale amount, called the callback directly, and passed the identity check. The vault transferred its collateral as if processing a legitimate mint.

The developer assumed that a value, once consumed, could be safely repurposed. The EVM made no such promise.

Our Tools Inherit Assumptions

The natural objection is that better tooling should catch these gaps: more tests, more formal verification. Both help, but neither eliminates the root cause.

Testing is bounded by the same mental model it is meant to challenge. A unit test validates an input-output pair the developer constructed. A fuzz test explores inputs within a space the developer bounded. An invariant test checks a property the developer articulated. Each is derived from the developer's understanding of what can go wrong, an understanding that already contains the flawed assumptions.

Formal Verification is fundamentally different since it checks if a property holds across all possible states. This cuts straight through the mental veil of the developer's assumptions and reveals directly what is and what isn't true.

Yet FV has its own assumption issues.

The proof covers exactly what the specification describes, and nothing more. The practical reality of FV involves a constant negotiation with computational limits. To keep the verification tractable, engineers use abstractions, summaries and over-approximations, each based on a premise that can create distance between what has been verified and what the system actually does.

The assumption shifts from my code is correct to my specification is complete and specification completeness is itself an assumption that can fail.

The Inward Audit

As we have seen, assumptions are a basic and unavoidable feature of human cognition. A developer is not an objective observer of a process, but a subjective participant guided by implicit and unconscious beliefs that pose a latent threat.

A senior developer writes fewer bugs due to the healthy distrust that years of painful failures have instilled in them. They all carry a private catalog of mistakes that cost them sleep, money, or reputation.

Are we then destined to passively endure bugs for as long as we write code?

No. Instead of waiting for bugs to hit us in the face, we turn the lens inward and hunt for them ourselves, while we write code. This is what I call the Inward Audit.

It means asking, at every turn:

  • What assumptions am I making about the architecture as a whole?
  • Do I know or assume the behavior of integrating systems?
  • What is the real influence of this function on the entire protocol?
  • Does the code on this line express what I believe it expresses?
  • What assumptions am I making about the execution environment?
  • Above all: what must hold true for this system not to fail?

To illustrate, let us return to the staking migration bug. I knew from experience that Web2 and Web3 auditors tend to remain in their silos, so I asked myself what assumptions must hold true for this protocol, across its off-chain and on-chain components, to correctly function as one system. In the process of listing and verifying those assumptions, I saw the bug, one that only became visible once I started questioning the assumptions at the boundary.

Now consider the Euler donateToReserves function. What if, before merging the upgrade, a developer had paused and asked: *What is the real influence of this function on the entire protocol? A function that reduces a user's collateral, even voluntarily, interacts with every mechanism that depends on collateral — including liquidation. That single question could have surfaced the missing health check, and the $197 million exploit would never have existed.

Or let's return to the decimal validation bug. Imagine the developer had stepped away, cleared their mental state, and reread the line with one question in mind: Does this code express what I believe it expresses? I have no doubt that any developer reading this article, confronted with that line cold, would see the error within seconds. Less than six AND greater than eighteen? The condition is impossible. Five minutes of deliberate rereading with a fresh perspective would have caught what dozens of habitual readings could not.

By no means am I claiming that the Inward Audit produces bug-free code. You cannot surface every assumption. Some will always remain hidden. But by deliberately searching for them, you will unearth many, and in doing so, close the gaps between what you believe to be true and what actually is true.

Conclusion

A practitioner's relationship to their own cognition is itself a professional skill. The developer who examines their assumptions is not merely more careful. They are more capable.After all, what is the alternative?

  • The sun will probably rise tomorrow.
  • The library function is probably correct.
  • The view function probably returns accurate data at all times.
  • The callback hook is probably harmless.
  • The storage slot is probably cleared after the call.
  • The auditor will probably find all the bugs.

Probably is not a security model.