JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Patterns that keep contracts safe: checks-effects-interactions, pull payments, and proxies

Four battle-tested patterns stand between your contract and an attacker: ordering state changes before calls, letting users pull funds instead of pushing, guarding who can pull the levers, and separating logic from storage so you can fix bugs. This is the bridge into the security rung.

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 call on line 14 invokes the recipient's fallback, which can re-enter withdraw() before line 16 runs — so balances[attacker] is still non-zero and pays out again, and again.

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");
}
Now a re-entrant call reads balances[attacker] == 0 and the require reverts. Same code, reordered — and the exploit vanishes.

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 ... */
}
A reentrancy guard as a Solidity modifier. Use it as a belt to checks-effects-interactions' braces — not a replacement for correct ordering.

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;
    }
}
An attacker bids from a contract whose receive() always reverts. Now every future bid() reverts at the refund line, and the auction is permanently stuck with the attacker winning.

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");
}
Pull payments also dodge the 2300-gas stipend trap of transfer()/send(), which breaks when the recipient is a smart-contract wallet whose receive() needs more gas.

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 { /* ... */ }
Use msg.sender (the immediate caller), not tx.origin (the original EOA) — see the warning below.

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) { /* ... */ }
Least privilege: each key holds only the powers it needs. This is the shape OpenZeppelin's AccessControl formalizes, with role admins and on-chain grant/revoke events.

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()) }
        }
    }
}
The implementation address lives in a pseudo-random EIP-1967 slot, chosen so it can never collide with the implementation's own sequential storage variables (slot 0, 1, 2 ...).

Proxies are powerful and dangerous in equal measure. Three rules keep them from becoming the bug:

  1. 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).
  2. 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.
  3. 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.

  1. Order every state-changing function as Checks → Effects → Interactions; add a reentrancy guard on functions that make external calls.
  2. Prefer pull over push for payouts; check the return value of every low-level call and never loop-send over an unbounded list.
  3. 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.
  4. If upgradeable, lock down storage layout, initialize once, and treat the upgrade key as the crown jewels.