3.7.2 State and Storage Patterns

Three patterns govern how a contract organizes its state safely: Explicit Storage Buckets for layout isolation in upgradeable contracts, Bitmap Nonces for efficient tracking of large sets of single-use operations, and State Machines for contracts whose behavior changes phase by phase. They share a common philosophy — make state transitions explicit, validated, and resistant to silent corruption.

Storage layout is one of the few places in Solidity where the language's defaults are actively dangerous in upgradeable contexts. A single mis-ordered variable in a child contract can silently corrupt the parent's state forever. The patterns in this section trade a small amount of code complexity for layout guarantees that eliminate whole vulnerability classes.

Explicit Storage Buckets

In a non-upgradeable contract, storage layout is determined by declaration order. Slot 0 holds the first declared state variable, slot 1 the next, and so on. The compiler manages this automatically, packing smaller variables together where possible. For a single deployment, this is fine — the layout is fixed at compile time and never changes.

For upgradeable contracts, this default becomes a liability. When a new implementation contract is deployed behind an existing proxy, the new implementation's storage layout must be a strict extension of the old one. Add a state variable in the middle of the declaration list and every variable below it shifts down one slot — corrupting the data that was stored under the old layout.

The Explicit Storage Bucket pattern solves this by placing state at deterministic, hashed slot locations rather than at sequential slots. Each "bucket" is a struct stored at a slot computed from a unique string identifier, making collisions effectively impossible.

The Sequential Layout Problem

// Original implementation
contract VaultV1 {
    address public owner;        // slot 0
    uint256 public totalDeposits; // slot 1
    mapping(address => uint256) public balances; // slot 2
}

// Naive upgrade — DANGEROUS
contract VaultV2 {
    address public owner;         // slot 0
    bool public paused;           // slot 1 — SHIFTED! Was totalDeposits
    uint256 public totalDeposits; // slot 2 — SHIFTED! Was balances
    mapping(address => uint256) public balances; // slot 3
}

After upgrading to V2, what was totalDeposits is now read as paused, and what was balances is now read as a uint256 stored at slot 2. The data has not moved — only the interpretation has shifted. The contract is silently broken.

OpenZeppelin's upgradeable contracts work around this by using __gap arrays in inheritance chains — a 50-slot empty array that absorbs future state additions:

contract OwnableUpgradeable {
    address private _owner;
    uint256[49] private __gap;  // reserves 49 slots for future fields
}

This works but is fragile. The developer must remember to subtract from __gap every time a new variable is added to the parent, and must do so before deploying the upgrade. A miss is silent and unrecoverable.

The Storage Bucket Pattern

EIP-1967 standardizes the approach for proxies — implementation address, admin address, and beacon address all live at slots computed as keccak256("eip1967.proxy.implementation") - 1. The same principle generalizes to application state.

OpenZeppelin v5 introduced Namespaced Storage (sometimes called the "ERC-7201 pattern") to apply this generally:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract Vault {
    /// @custom:storage-location erc7201:myapp.vault
    struct VaultStorage {
        address owner;
        uint256 totalDeposits;
        mapping(address => uint256) balances;
        bool paused;
    }

    // keccak256(abi.encode(uint256(keccak256("myapp.vault")) - 1)) & ~bytes32(uint256(0xff))
    bytes32 private constant VAULT_STORAGE_LOCATION =
        0x7f5e9c2f8a3e4b6d1c5e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b900;

    function _vaultStorage() private pure returns (VaultStorage storage $) {
        assembly {
            $.slot := VAULT_STORAGE_LOCATION
        }
    }

    function deposit() external payable {
        VaultStorage storage $ = _vaultStorage();
        $.balances[msg.sender] += msg.value;
        $.totalDeposits += msg.value;
    }

    function withdraw(uint256 amount) external {
        VaultStorage storage $ = _vaultStorage();
        require(!$.paused, "paused");
        require($.balances[msg.sender] >= amount, "insufficient");
        $.balances[msg.sender] -= amount;
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok);
    }
}

The entire contract state lives inside one struct at a deterministic, hashed slot. Adding a field to VaultStorage extends the struct's footprint forward from that base slot — the next contract or library in any inheritance chain remains untouched because its own state lives at a different hashed slot.

The slot constant calculation uses a specific form mandated by ERC-7201:

keccak256(abi.encode(uint256(keccak256("myapp.vault")) - 1)) & ~bytes32(uint256(0xff))

The double-hash with - 1 ensures the slot is not directly reachable by any string preimage. The mask of the last byte (& ~bytes32(uint256(0xff))) reserves the low byte to zero, allowing the struct to safely occupy 256 consecutive slots without colliding with another namespace.

A practical helper: never compute the slot by hand. Use cast from Foundry:

$ cast index-erc7201 myapp.vault
0x7f5e9c2f8a3e4b6d1c5e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b900

Or use OpenZeppelin's StorageSlot library helpers for ad-hoc access to single slots without a full namespace.

When to Use Storage Buckets

The pattern's value scales with contract complexity. For a single non-upgradeable contract, declaration order is fine — buckets add complexity for no benefit. The pattern earns its keep when:

  • The contract is behind a proxy (transparent, UUPS, beacon, or diamond)
  • The contract uses libraries that read or write contract storage via delegatecall
  • The contract is part of a deep inheritance chain where storage layout drift between versions is a maintenance hazard
  • Multiple teams contribute to the codebase and storage layout must be reviewed independently per module

The Diamond Pattern (EIP-2535) uses storage buckets pervasively — each facet defines its own struct at its own hashed slot, allowing facets to be added, removed, or upgraded without storage conflicts.

Foundry Test for Storage Isolation

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/Vault.sol";

contract StorageBucketTest is Test {
    Vault vault;

    function setUp() public {
        vault = new Vault();
        vm.deal(address(this), 10 ether);
    }

    function test_stateLivesAtBucketSlot() public {
        vault.deposit{value: 1 ether}();

        // Storage at slot 0 should be empty — the bucket pattern moved state elsewhere
        bytes32 slot0Value = vm.load(address(vault), bytes32(uint256(0)));
        assertEq(slot0Value, bytes32(0), "slot 0 unexpectedly populated");

        // The struct base slot is the namespace hash. The mapping for balances
        // lives at a slot derived from that base + the field offset.
        bytes32 baseSlot = 0x7f5e9c2f8a3e4b6d1c5e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b900;
        // balances mapping is field index 2 in VaultStorage
        bytes32 balancesSlot = bytes32(uint256(baseSlot) + 2);
        bytes32 entrySlot = keccak256(abi.encode(address(this), balancesSlot));
        uint256 storedBalance = uint256(vm.load(address(vault), entrySlot));

        assertEq(storedBalance, 1 ether, "balance not at expected bucket slot");
    }
}

This test asserts not just that the contract works but that it works the way we believe it works — proving storage actually lives at the hashed slot and not at a default-layout slot. For upgradeable contracts, this kind of layout assertion is the most valuable test in the suite.

Bitmap Nonces

Many protocols need to track which of a large set of unique operations have been used: nonces in EIP-2612 permits, claimed airdrop entries, redeemed signatures. The naive implementation uses a mapping from nonce to bool:

mapping(uint256 => bool) public usedNonces;

This costs 20,000 gas per write (zero-to-nonzero SSTORE). At scale this adds up — a Merkle airdrop with one million eligible recipients pays 20 billion gas in claims alone, before any token transfer logic.

A bitmap packs 256 boolean flags into a single storage slot. A uint256 stores 256 bits; flag n is bit n % 256 of the storage word at slot n / 256. Writes to a slot whose previous value was non-zero cost ~5,000 gas instead of 20,000 — a 4× saving once the slot is "warm."

Idiomatic Form

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/utils/structs/BitMaps.sol";

contract Airdrop {
    using BitMaps for BitMaps.BitMap;

    BitMaps.BitMap private claimed;
    bytes32 public immutable merkleRoot;
    IERC20 public immutable token;
    uint256 public immutable amountPerClaim;

    constructor(bytes32 _root, IERC20 _token, uint256 _amount) {
        merkleRoot = _root;
        token = _token;
        amountPerClaim = _amount;
    }

    function claim(uint256 index, bytes32[] calldata proof) external {
        require(!claimed.get(index), "already claimed");

        bytes32 leaf = keccak256(abi.encodePacked(index, msg.sender));
        require(MerkleProof.verify(proof, merkleRoot, leaf), "bad proof");

        claimed.set(index);

        token.transfer(msg.sender, amountPerClaim);
    }
}

OpenZeppelin's BitMaps library handles the bit math internally. claimed.set(index) computes bucket = index / 256 and bit = index % 256, reads the bucket word, sets the bit, and writes the word back.

For the typical airdrop case, the savings are substantial. Claims 0–255 all write to bucket 0; only the first one pays a zero-to-nonzero cost. Claims 256–511 share bucket 1, and so on. Average gas per claim falls from ~20,000 to ~5,200 once distribution is underway.

Trade-offs

Bitmaps are not a universal win. The pattern assumes:

  • Each entry occupies a unique, predictable integer index (true for airdrops indexed by Merkle leaf position; true for sequential nonces; not true for arbitrary user-chosen identifiers).
  • The "used / not used" state is binary (true for nonces; not true for "redeemed amount" which is a counter).
  • Indices cluster in some way, so that the warm-slot savings actually materialize. Sparse, random 256-bit indices behave the same as a regular mapping.

For ordered nonces (e.g., EIP-712 permit signatures where each signer has their own nonce counter), the question is whether sequential nonces are required at all. EIP-2612 uses sequential nonces per signer; OpenZeppelin's Nonces contract provides this. But for one-shot signatures that need not be sequential — like Permit2's signature transfers — bitmap nonces allow signers to invalidate any subset of their outstanding signatures in any order, which sequential nonces cannot do.

Bitmap Nonce for Signature Invalidation

contract SignedActions {
    using BitMaps for BitMaps.BitMap;

    mapping(address => BitMaps.BitMap) private signerNonces;

    function executeWithNonce(
        uint256 nonce,
        bytes calldata signature,
        bytes calldata action
    ) external {
        require(!signerNonces[recoverSigner(signature, action, nonce)].get(nonce), "used");

        address signer = recoverSigner(signature, action, nonce);
        signerNonces[signer].set(nonce);

        _execute(action);
    }

    function invalidate(uint256[] calldata nonces) external {
        for (uint256 i = 0; i < nonces.length; ++i) {
            signerNonces[msg.sender].set(nonces[i]);
        }
    }
}

The signer can pre-invalidate nonces in batch — useful if a signed message leaks or is no longer needed. Each signer has their own bitmap, so signers don't compete for slot warmth across each other.

State Machines

Contracts whose behavior changes phase by phase — an ICO that moves through whitelist, public sale, and finalized states; an auction that opens, accepts bids, then settles; an escrow that moves from funded to disputed to released — are state machines whether the developer thinks of them that way or not. Making the state machine explicit prevents whole classes of bugs.

The risk in not using a state machine pattern is invariant drift. A bool public saleOpen and a bool public finalized and a bool public refundsEnabled create eight possible state combinations, of which perhaps three are valid. The other five are bugs waiting to be discovered by an attacker.

Vulnerable Multi-Flag State

contract LooseAuction {
    bool public open = true;
    bool public settled;
    bool public refundsOpen;

    address public highBidder;
    uint256 public highBid;

    function bid() external payable {
        require(open, "closed");
        // ... bid logic
    }

    function settle() external {
        require(!settled, "already settled");
        settled = true;
        // BUG: didn't set open=false. Bids can continue after settlement.
        // BUG: didn't enable refunds for losing bidders.
        // forgotten cleanup ...
    }

    function refund() external {
        require(refundsOpen, "no refunds");
        // ... refund logic, but refundsOpen never gets set
    }
}

Three flags, three functions, and at least two off-the-happy-path bugs visible in twenty lines of code. The bug-to-flag ratio grows quadratically: each new flag doubles the number of state combinations that must be validated.

Explicit State Machine

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract Auction {
    enum Phase { Created, Open, Settled, Refunding, Closed }

    Phase public phase;
    address public highBidder;
    uint256 public highBid;
    mapping(address => uint256) public bids;

    error WrongPhase(Phase expected, Phase actual);

    modifier inPhase(Phase expected) {
        if (phase != expected) revert WrongPhase(expected, phase);
        _;
    }

    function open() external inPhase(Phase.Created) {
        phase = Phase.Open;
    }

    function bid() external payable inPhase(Phase.Open) {
        require(msg.value > highBid, "bid too low");

        if (highBidder != address(0)) {
            bids[highBidder] += highBid;
        }
        highBidder = msg.sender;
        highBid = msg.value;
    }

    function settle() external inPhase(Phase.Open) {
        phase = Phase.Settled;
        // settlement logic — pay seller, etc.
        phase = Phase.Refunding;
    }

    function refund() external inPhase(Phase.Refunding) {
        uint256 amount = bids[msg.sender];
        require(amount > 0, "nothing to refund");
        bids[msg.sender] = 0;
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok);
    }

    function close() external inPhase(Phase.Refunding) {
        phase = Phase.Closed;
    }
}

Every function declares which phase it operates in. The inPhase modifier enforces the transition contract. There is only one variable holding the state — no synchronization problem, no "I forgot to set the other flag" bug.

The enum is more than syntactic sugar. It documents the valid phases for any reader, and the compiler will reject attempts to assign invalid values. Adding a new phase is a deliberate code change that surfaces every place that needs updating.

Trade-offs and Refinements

State machines add boilerplate for contracts with genuinely simple lifecycles. A token contract that just transfers and approves does not need a state machine — there are no phases. The pattern earns its keep when:

  • Multiple phases exist, each with distinct allowed operations
  • Some operations are valid in some phases but not others
  • Phase transitions must happen in a specific order
  • Invariants differ phase to phase (e.g., "during Open, the contract must hold at least totalBids ETH"; "during Refunding, the contract must hold exactly totalBids - paidOut ETH")

For more complex state graphs (not just linear progressions), consider explicit transition tables:

mapping(Phase => mapping(Phase => bool)) private validTransitions;

function _transitionTo(Phase next) internal {
    require(validTransitions[phase][next], "invalid transition");
    phase = next;
}

This documents the entire transition graph as data rather than as scattered modifier checks, which helps when the legal transitions depend on conditions (e.g., "Open → Refunding only if no bids were placed; otherwise Open → Settled → Refunding").

Foundry Test for State Transitions

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/Auction.sol";

contract AuctionStateTest is Test {
    Auction auction;
    address alice = makeAddr("alice");
    address bob = makeAddr("bob");

    function setUp() public {
        auction = new Auction();
        vm.deal(alice, 5 ether);
        vm.deal(bob, 5 ether);
    }

    function test_cannotBidBeforeOpen() public {
        vm.prank(alice);
        vm.expectRevert(
            abi.encodeWithSelector(
                Auction.WrongPhase.selector,
                Auction.Phase.Open,
                Auction.Phase.Created
            )
        );
        auction.bid{value: 1 ether}();
    }

    function test_cannotRefundDuringOpen() public {
        auction.open();
        vm.prank(alice);
        vm.expectRevert();
        auction.refund();
    }

    function test_happyPathProgression() public {
        auction.open();
        assertEq(uint256(auction.phase()), uint256(Auction.Phase.Open));

        vm.prank(alice);
        auction.bid{value: 1 ether}();

        vm.prank(bob);
        auction.bid{value: 2 ether}();

        auction.settle();
        assertEq(uint256(auction.phase()), uint256(Auction.Phase.Refunding));

        vm.prank(alice);
        auction.refund();
        assertEq(alice.balance, 5 ether, "alice refunded");

        auction.close();
        assertEq(uint256(auction.phase()), uint256(Auction.Phase.Closed));
    }
}

Each test pins down one transition rule. The pattern of writing one test per illegal transition (cannot bid before open, cannot refund during open, cannot close before settling) builds a comprehensive guarantee that the state machine cannot be entered into an invalid configuration. Foundry's invariant testing extends this further — see Section 3.4.6 for fuzz-driven state machine validation.

Quick Reference

PatternBest whenCost vs defaultDefeats
Explicit Storage BucketsUpgradeable contracts, deep inheritance, libraries via delegatecall~50 gas per access (one extra MLOAD), small bytecode increaseStorage collisions across upgrades and inheritance
Bitmap NoncesTracking large numbers of single-use binary flags~5,200 gas warm (vs 20,000 for mapping)Storage cost overhead at scale
State MachinesContracts with multi-phase lifecycles, distinct per-phase invariantsNegligible (one SSTORE per transition, one SLOAD per check)Multi-flag desynchronization bugs

Cross-References

  • Upgradeable patterns — Section 3.5 (Smart Contract Upgradeability) covers proxies and the inheritance chains where storage buckets become essential
  • Delegatecall storage hazards — Section 4.11.9 (Delegatecall) covers what goes wrong when storage layouts diverge between caller and callee
  • Invariant identification — Section 4.8.4 (Identifying Invariants) covers how to derive per-phase invariants for state machines
  • EIP-712 signatures — Section 3.8.8 (Signature & Replay Issues) covers permit signatures and where bitmap nonces apply
  • Diamond pattern — Section 3.5 (Smart Contract Upgradeability) covers EIP-2535, which depends on per-facet storage buckets