Why storage packing deserves tests

Solidity packs multiple state variables into a single 32-byte storage slot when their combined size fits. This is efficient, but it also means that a change to one variable can affect neighboring values if your assumptions are wrong.

This matters in several common situations:

  • You refactor a contract and reorder state variables.
  • You add a new variable to a contract with existing deployed state.
  • You inherit from multiple base contracts and expect a specific layout.
  • You use low-level assembly to read or write storage.
  • You store tightly packed flags, counters, or small integers.

A bug in storage layout is often not obvious from function-level tests. A contract may still compile and even pass basic happy-path tests while silently writing to the wrong slot.

How Solidity packs state variables

Solidity stores state variables sequentially, but smaller values can share a slot. The compiler packs values according to type size and alignment rules.

A useful mental model is:

  • uint256, bytes32, and address-sized values typically occupy their own space unless they can be packed with smaller values.
  • Smaller types such as uint8, bool, and uint16 can share a slot.
  • Packing happens within a 32-byte slot, and the compiler places values in declaration order.
  • Structs and arrays have their own layout rules, which are less flexible than simple value types.

Example packing pattern

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

contract PackedConfig {
    uint128 public maxDeposit;
    uint64 public feeBps;
    bool public paused;
    uint8 public version;

    function setAll(uint128 _maxDeposit, uint64 _feeBps, bool _paused, uint8 _version) external {
        maxDeposit = _maxDeposit;
        feeBps = _feeBps;
        paused = _paused;
        version = _version;
    }
}

In this contract, all four variables can fit into a single storage slot because their combined size is less than 32 bytes. That is efficient, but it also means a test should verify that each field is written and read correctly.

What to test

Storage packing tests should focus on three categories:

  1. Correctness of individual reads and writes
  • Setting one field should not alter another field.
  1. Slot boundary behavior
  • Values that cross a slot boundary should not overwrite adjacent data.
  1. Layout stability
  • Changes to declaration order or inheritance should be detected early.

The goal is not to reimplement the compiler. Instead, you want tests that confirm your assumptions about layout remain true.

Reading raw storage in tests

Most Solidity test frameworks let you inspect raw storage directly. This is the most reliable way to validate packing.

If you use Foundry, vm.load(address, bytes32) returns the raw contents of a storage slot. In Hardhat, you can use ethers.provider.getStorageAt(address, slot).

Foundry example: inspect a packed slot

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

import "forge-std/Test.sol";

contract PackedConfigTest is Test {
    PackedConfig config;

    function setUp() public {
        config = new PackedConfig();
    }

    function testPackedSlotEncoding() public {
        config.setAll(1000, 25, true, 7);

        bytes32 slot0 = vm.load(address(config), bytes32(uint256(0)));

        // The exact byte layout depends on packing order and endianness.
        // This test checks that the slot is non-zero and that the high-level getters still work.
        assertEq(config.maxDeposit(), 1000);
        assertEq(config.feeBps(), 25);
        assertTrue(config.paused());
        assertEq(config.version(), 7);

        emit log_bytes32(slot0);
    }
}

This example demonstrates the basic pattern: set values, read the raw slot, and verify the public getters. In real tests, you would decode the slot more precisely to ensure the expected bytes are in the expected positions.

Decoding packed values precisely

A stronger test checks the exact byte positions. This is useful when you want to prove that a layout has not changed.

Suppose you expect the following declaration order:

uint128 maxDeposit;
uint64 feeBps;
bool paused;
uint8 version;

You can encode the expected slot manually and compare it to the raw storage value.

Example: exact slot assertion

function testExactPackedEncoding() public {
    config.setAll(1000, 25, true, 7);

    bytes32 raw = vm.load(address(config), bytes32(uint256(0)));

    bytes32 expected = bytes32(
        (uint256(7) << 224) |
        (uint256(1) << 216) |
        (uint256(25) << 152) |
        uint256(1000)
    );

    assertEq(raw, expected);
}

This style of assertion is especially valuable for contracts that depend on stable layout across versions or for contracts that use inline assembly.

Why this works

The compiler stores packed values in a deterministic order. If you know the exact widths of each field, you can reconstruct the slot value and compare it against the raw storage.

That said, exact bit math can be brittle if the contract changes. Use it when layout stability is important, not as a substitute for functional tests.

Testing slot boundaries with larger values

A common source of bugs is the transition from packed to unpacked storage. When a variable does not fit into the remaining space in a slot, Solidity starts a new slot.

Consider this contract:

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

contract BoundaryExample {
    uint128 public a;
    uint128 public b;
    uint256 public c;

    function set(uint128 _a, uint128 _b, uint256 _c) external {
        a = _a;
        b = _b;
        c = _c;
    }
}

Here, a and b share slot 0, while c starts at slot 1. A test should confirm that writing c does not affect a or b.

Boundary test pattern

function testSlotBoundaryIsolation() public {
    BoundaryExample ex = new BoundaryExample();

    ex.set(type(uint128).max, 123, 999);

    assertEq(ex.a(), type(uint128).max);
    assertEq(ex.b(), 123);
    assertEq(ex.c(), 999);

    bytes32 slot0 = vm.load(address(ex), bytes32(uint256(0)));
    bytes32 slot1 = vm.load(address(ex), bytes32(uint256(1)));

    assertTrue(slot0 != bytes32(0));
    assertTrue(slot1 != bytes32(0));
}

This test verifies both the public interface and the raw slot separation. If a future refactor accidentally changes the order of declarations, this test will fail.

Comparing common testing strategies

StrategyWhat it checksBest use caseLimitation
Public getter assertionsHigh-level correctnessEveryday regression testsDoes not prove exact layout
Raw slot inspectionActual storage bytesPacking and boundary validationRequires knowledge of encoding
Exact bitmask comparisonPrecise layout stabilityUpgrade-sensitive contractsBrittle if layout changes intentionally
Differential testsBehavior before and after refactorDetecting accidental layout driftNeeds a reference implementation

A good test suite usually combines getter checks with at least one raw storage assertion.

Detecting layout drift after refactors

Storage bugs often appear after harmless-looking edits. For example, inserting a new variable at the top of a contract changes the slot numbers of all later variables.

Bad refactor example

contract ConfigV1 {
    uint128 public maxDeposit;
    uint64 public feeBps;
    bool public paused;
}

contract ConfigV2 {
    bool public emergencyMode; // inserted before existing variables
    uint128 public maxDeposit;
    uint64 public feeBps;
    bool public paused;
}

If ConfigV2 is meant to preserve layout for an upgrade, this change is dangerous. A test should compare the slot positions of each variable against a known baseline.

Practical safeguard

Create a test that documents the expected slot index for each variable and fails if the layout changes unexpectedly. For upgradeable systems, this is often more important than testing individual setters.

Using storage checks with inheritance

Inheritance can make storage layout harder to reason about because base contract variables are placed before derived contract variables. If you add a new state variable to a base contract, the derived contract layout can shift.

A simple test strategy is to deploy the contract, write sentinel values to all fields, and inspect the raw slots in order.

Example sentinel test

function testInheritedLayoutSentinels() public {
    DerivedContract d = new DerivedContract();
    d.initialize(11, 22, 33);

    assertEq(d.baseA(), 11);
    assertEq(d.baseB(), 22);
    assertEq(d.childC(), 33);

    bytes32 slot0 = vm.load(address(d), bytes32(uint256(0)));
    bytes32 slot1 = vm.load(address(d), bytes32(uint256(1)));

    assertTrue(slot0 != bytes32(0));
    assertTrue(slot1 != bytes32(0));
}

This does not fully prove inheritance layout, but it gives you a regression test that catches many accidental changes.

Best practices for reliable storage layout tests

1. Test the public API and the raw slot

Do not rely only on raw storage. A slot can be correctly encoded while the contract logic still misbehaves. Pair slot inspection with getter assertions.

2. Use sentinel values

Choose values that are easy to recognize in hex and unlikely to collide with defaults. For example:

  • 0x01, 0x7f, 0xff for small integers
  • type(uint128).max for boundary checks
  • Distinct values for each field to detect overlap

3. Keep layout tests close to the contract

If a contract’s storage layout is critical, place the test next to the contract source and document the intended slot order. Future maintainers should not have to infer the layout from bit math alone.

4. Avoid unnecessary state reordering

If a contract is already deployed or upgradeable, treat variable order as part of the public interface. Add new variables only at the end unless you are deliberately redesigning storage.

5. Use exact assertions only when needed

Exact slot comparisons are powerful, but they can be too strict for evolving code. Prefer them for upgrade safety and low-level libraries; use behavior-based tests elsewhere.

When storage packing tests are most valuable

These tests are especially useful in:

  • Upgradeable contracts with preserved storage
  • Protocol configuration contracts with many small flags
  • Contracts using assembly for gas optimization
  • Libraries that expose packed structs
  • Systems where a single corrupted slot could affect funds or permissions

If your contract stores critical financial or governance state, storage layout tests are a low-cost way to reduce upgrade risk.

A practical testing checklist

Before merging a storage-sensitive change, verify:

  • Each packed field still returns the expected value.
  • Writing one field does not alter adjacent fields.
  • Slot boundaries remain unchanged.
  • Inheritance has not shifted base storage unexpectedly.
  • Any upgrade preserves the old layout exactly.

A small amount of deterministic storage testing can prevent expensive state corruption later.

Learn more with useful resources