Web3 · 9 min read

Smart Contracts Are Code You Can't Patch — Engineer Accordingly

In normal software, a bug is a hotfix. On-chain, a deployed bug is a public, immutable, adversarially-incentivized liability that anyone can exploit before you finish your standup. The engineering discipline has to change to match.

The hardest mental shift for a web2 engineer moving on-chain isn't Solidity syntax. It's that 'we'll patch it later' stops being a sentence you can say. Once a contract is deployed, the code is public, the bug is public, the money is sitting right there, and the people reading your code are financially motivated to break it. You don't get to fix forward. You have to be right the first time, or you have to have designed for being wrong.

Why immutability changes the whole calculus

A web2 bug has a window: the time between a vulnerability existing and you shipping a fix. Usually your users never notice. On-chain, that window inverts. The vulnerability is visible the instant you deploy, the exploit is permissionless, and there's a bot watching the mempool that will run it before your on-call even gets paged. The asset at risk is liquid and final, transactions can't be reversed, and the attacker keeps the funds.

This is not 'security matters more here.' It's a different failure model. In web2 the expected cost of a bug is low because most bugs are never weaponized. On-chain the expected cost of an exploitable bug approaches the total value the contract controls, because someone will find it and the incentive to do so is denominated in dollars. You engineer for a world where every bug gets exploited, because economically, the ones worth exploiting do.

The bugs are different too

The vulnerability classes that drain contracts aren't the OWASP Top 10 you already know. They come from the execution model, and they're easy to miss if you're reasoning like a normal backend engineer:

  • Reentrancy: an external call hands control back to the attacker mid-execution, before your state updates land. The classic, and still draining contracts decades into the practice.
  • Oracle and price manipulation: your contract trusts a price it reads on-chain, and an attacker moves that price with a flash loan inside a single transaction.
  • Integer and rounding edge cases: a division that rounds in the attacker's favor, repeated a million times, is a withdrawal.
  • Access control gaps: an unprotected initializer or a missing modifier that lets anyone call the function that mints, pauses, or upgrades.
  • Front-running and MEV: your transaction sits in a public mempool where anyone can see your intent and reorder, sandwich, or copy it before it lands.

Design for being wrong, because you will be

Since you can't assume you got it right, you design escape hatches before you need them. This is the part teams skip and regret. The goal is to bound the damage of a bug you haven't found yet.

Upgradeability via proxy patterns lets you replace logic, but it's a tradeoff, not a free fix: the upgrade key becomes the single most dangerous object in your system, and a compromised admin key is its own catastrophe. Pair it with a timelock so changes are visible before they execute, and a multisig so no one person can push them. Add a circuit breaker — a pause function — so you can stop the bleeding while you respond. And cap exposure with per-transaction and per-day withdrawal limits, so even a total logic failure can't drain everything in one block.

The process has to be heavier, and that's correct

Move-fast-and-iterate is the wrong default when you can't iterate. The discipline that feels excessive in a web2 sprint is the baseline on-chain. Invariant and property-based testing, where you assert things that must always be true (total supply never exceeds the cap, the sum of balances equals the reserve) and let a fuzzer try millions of inputs to break them, catches the edge cases unit tests miss. Static analyzers like Slither are cheap and belong in CI.

Then you spend real money on at least one independent audit before mainnet, and you treat the report as input, not absolution — auditors miss things, and a clean report is not a guarantee. A staged rollout with low value caps that you raise as the contract survives real usage turns mainnet into a graduated test instead of a coin flip. And a bug bounty with a serious payout gives a researcher a legal, profitable alternative to draining you. The economics only work if the bounty is large enough to compete with the exploit.

Keep the on-chain surface small

The best way to avoid unpatchable bugs is to put less unpatchable code on-chain. Every line that lives in an immutable contract is a line you're committing to forever. So push complexity off-chain wherever the trust model allows: do the heavy computation off-chain and verify the result on-chain, keep the on-chain logic to the minimal set of operations that genuinely need consensus and finality.

A small, auditable, boring contract that does one thing is worth far more than a feature-rich one you can't fully reason about. Cleverness is a liability when you can't roll it back. The contracts that survive are the ones whose authors were ruthless about what didn't need to be on-chain in the first place.

The bottom line

The bottom line: deployment is not a checkpoint, it's a commitment, and the people reading your committed code are paid in your users' funds to break it. Engineer for a world where every exploitable bug gets exploited — minimize what goes on-chain, build the pause switches and limits before you need them, and spend on testing and audits like the money is real, because it is. The teams that internalize 'I can't patch this' write very different code from the teams that learn it the expensive way.

BUILDING SOMETHING LIKE THIS?

This is the thinking we bring to every engagement. Tell us what you’re building.