A vending machine that can be robbed mid-purchase
Imagine a vending machine that drops your snack before it deducts the coins — and a clever thief who can stick his hand in the chute and grab a second snack while the machine is still mid-transaction. That is not a fantasy; it is exactly the bug that drained The DAO of about 3.6 million ETH in 2016, and it is possible because of one deep fact about a smart contract: the instant your code makes an external call, it hands the steering wheel to code you do not control.
You have already written real Solidity in this rung: state and visibility, mappings and structs, modifiers and events, a full ERC-20. Now we add the discipline that separates a toy from a contract that holds millions. These four patterns are not academic — each one is the distilled scar tissue of a famous, expensive hack. Learn them as habits, not as afterthoughts.
Checks-Effects-Interactions: the order that defeats reentrancy
The single most important ordering rule in Solidity is Checks → Effects → Interactions. First check your preconditions (inputs, balances, permissions). Then apply effects — update your contract's own state. Only then perform interactions — calls to other addresses. The danger is doing them in the wrong order: if you send money before zeroing the balance, the recipient can call back in and be paid again. Here is the vulnerable shape that doomed The DAO.
// VULNERABLE: interaction happens BEFORE the effect
pragma solidity ^0.8.24;
contract Bank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender]; // Check
require(amount > 0, "nothing to withdraw");
// INTERACTION first -- hands control to msg.sender's code:
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
balances[msg.sender] = 0; // Effect -- too late!
}
}The attacker deploys a contract whose `receive()` function calls `withdraw()` again. Because the balance is only zeroed after the transfer, each re-entry sees the full balance and drains the bank in a single transaction. The fix is almost embarrassingly small: move the state update above the external call so the door is locked before you open it.
// SAFE: effects committed BEFORE the interaction
function withdraw() external {
uint256 amount = balances[msg.sender]; // Checks
require(amount > 0, "nothing to withdraw");
balances[msg.sender] = 0; // Effects (state updated FIRST)
(bool ok, ) = msg.sender.call{value: amount}(""); // Interactions (call LAST)
require(ok, "transfer failed");
}Checks-effects-interactions handles the common case, but for defense in depth add a reentrancy guard: a mutex that reverts on any nested call. OpenZeppelin's guard stores `1` (not entered) and `2` (entered) rather than a boolean, because flipping a storage slot between two non-zero values is far cheaper in gas than touching it from zero.
uint256 private _status = 1; // 1 = NOT_ENTERED, 2 = ENTERED
modifier nonReentrant() {
require(_status == 1, "reentrant call");
_status = 2;
_; // function body runs here
_status = 1;
}
function withdraw() external nonReentrant {
/* ... checks-effects-interactions still applies ... */
}Pull over push: don't let one bad recipient brick the contract
The second pattern is about who initiates the transfer. The naive ("push") approach has the contract send funds to users inside its own logic. But an external send can fail or revert — and if your contract's progress depends on that send succeeding, a single malicious recipient can freeze everyone. This is a denial-of-service bug. Classic example: an auction that refunds the previous high bidder inline.
// VULNERABLE (push): refund runs inside bid()
contract Auction {
address public highestBidder;
uint256 public highestBid;
function bid() external payable {
require(msg.value > highestBid, "bid too low");
if (highestBidder != address(0)) {
// If THIS transfer reverts, no one can ever outbid again.
payable(highestBidder).transfer(highestBid);
}
highestBidder = msg.sender;
highestBid = msg.value;
}
}The fix is the pull-payment pattern: instead of pushing the refund, record what each address is owed and let them withdraw it themselves in a separate call. One person's failed withdrawal now affects only that person. Notice the withdrawal function still follows checks-effects-interactions, and uses the low-level `call` while checking its return value — never ignore the success boolean of an external call.
// SAFE (pull): bidders withdraw their own refunds
mapping(address => uint256) public pendingReturns;
function bid() external payable {
require(msg.value > highestBid, "bid too low");
if (highestBidder != address(0)) {
pendingReturns[highestBidder] += highestBid; // record, don't send
}
highestBidder = msg.sender;
highestBid = msg.value;
}
function withdrawRefund() external {
uint256 amount = pendingReturns[msg.sender];
require(amount > 0, "nothing to withdraw");
pendingReturns[msg.sender] = 0; // effect before interaction
(bool ok, ) = payable(msg.sender).call{value: amount}("");
require(ok, "refund failed");
}Access control: who is allowed to pull the lever
Many catastrophes were not clever cryptographic breaks — just a privileged function anyone could call. An access-control failure is a missing or wrong guard on a powerful action: mint, withdraw, upgrade, pause, selfdestruct. The minimal defense is an owner check, expressed as a reusable modifier with a cheap custom error.
error NotOwner();
address public owner;
constructor() { owner = msg.sender; }
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner(); // msg.sender, NEVER tx.origin
_;
}
function setFee(uint256 newFee) external onlyOwner { /* ... */ }Real systems outgrow a single owner. Role-based access control grants distinct permissions (MINTER, PAUSER, UPGRADER) to different addresses, so a compromised minter key can't also upgrade the contract. The pattern is a nested mapping of role to address to boolean.
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
mapping(bytes32 => mapping(address => bool)) public hasRole;
modifier onlyRole(bytes32 role) {
require(hasRole[role][msg.sender], "missing role");
_;
}
function mint(address to, uint256 amt) external onlyRole(MINTER_ROLE) { /* ... */ }Upgradeable proxies: separating logic from storage via delegatecall
Deployed bytecode is immutable — a feature for trust, a curse when you ship a bug. The proxy upgrade pattern resolves the tension by splitting a contract into two: a thin proxy that holds all the storage and the funds, and a swappable implementation that holds the logic. The proxy forwards every call to the implementation using delegatecall — which runs the implementation's code in the proxy's storage context.
The mechanism is exactly the CALL-vs-DELEGATECALL distinction from the EVM rung: a normal call runs in the callee's context, but `delegatecall` borrows the callee's code while keeping the caller's `storage`, `msg.sender`, and `msg.value`. To upgrade, you just point the proxy at a new implementation address. Here is the heart of a minimal proxy's fallback.
// Proxy: stores state, delegatecalls ALL logic to `implementation`
contract Proxy {
// EIP-1967 slot = keccak256("eip1967.proxy.implementation") - 1
bytes32 private constant IMPL_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
function _impl() internal view returns (address a) {
assembly { a := sload(IMPL_SLOT) }
}
fallback() external payable {
address impl = _impl();
assembly {
calldatacopy(0, 0, calldatasize())
let ok := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch ok
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}Proxies are powerful and dangerous in equal measure. Three rules keep them from becoming the bug:
- Storage layout is append-only. The proxy and every implementation must agree on the storage slot order. Re-ordering or inserting a variable in V2 makes new code read old data at the wrong storage location — silent, catastrophic corruption. Only ever append new variables at the end (or use storage gaps).
- Use an initializer, not a constructor. A constructor runs in the implementation's context at its deployment, so it never touches the proxy's storage. Instead expose an `initialize()` function guarded so it can run only once — and call it atomically at deploy time.
- Pick a proxy style deliberately. A transparent proxy keeps the upgrade logic in the proxy and routes admin vs user calls to avoid function-selector clashes; a UUPS proxy puts the `upgradeTo` logic in the implementation (cheaper to deploy) — but if a new implementation forgets to include it, you permanently lose the ability to upgrade. The diamond pattern shards logic across many facets for very large contracts.
Putting it together: defense in depth
No single pattern makes a contract safe; they are layers. Ordering stops reentrancy, pull payments stop griefing DoS, access control stops unauthorized power, and proxies let you fix what slips through. Together they embody one mindset: assume every external interaction is hostile, and never let your contract's safety depend on a stranger behaving.
- Order every state-changing function as Checks → Effects → Interactions; add a reentrancy guard on functions that make external calls.
- Prefer pull over push for payouts; check the return value of every low-level call and never loop-send over an unbounded list.
- Guard every privileged function with least-privilege roles via a modifier; authorize with msg.sender; keep the most powerful keys behind a multisig and timelock.
- If upgradeable, lock down storage layout, initialize once, and treat the upgrade key as the crown jewels.