
Testing Solidity Storage Layout Assumptions
Why storage layout deserves tests
Solidity stores state variables in 32-byte slots. The compiler assigns slots based on declaration order, type size, and inheritance structure. For ordinary contracts, this is mostly invisible. For upgradeable systems, however, storage layout becomes part of your public interface.
A small change such as inserting a new variable near the top of a contract can shift every subsequent slot. If the contract is behind a proxy, the implementation changes but the proxy storage remains. That mismatch can overwrite balances, ownership data, or configuration values.
Storage layout tests are useful when you:
- upgrade contracts through proxies
- inherit from multiple base contracts
- use packed variables such as
uint128,bool, oraddress - maintain long-lived contracts with strict backward compatibility
- want to validate assumptions made by off-chain tooling or auditors
How Solidity assigns storage
Solidity packs values smaller than 32 bytes into the same slot when possible. It also lays out inherited state variables starting from the most base-like contract in linearized order.
Consider this simplified example:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract LayoutV1 {
address public owner; // slot 0
uint128 public feeBps; // slot 1, lower 16 bytes
bool public paused; // slot 1, next byte
uint256 public total; // slot 2
}Here, owner occupies slot 0. feeBps and paused may share slot 1 because they fit together. total goes to slot 2.
If you later add a variable before total, the slot numbering changes. That is exactly the kind of regression storage tests should detect.
A practical testing strategy
The most reliable approach is to test storage layout in two layers:
- Behavioral tests
Confirm that values survive writes, reads, and upgrades.
- Structural tests
Confirm that variables still occupy the expected slots and offsets.
Behavioral tests catch user-visible breakage. Structural tests catch silent layout drift before it becomes visible.
A good rule: if the contract is upgradeable, test both.
Example: detecting a dangerous upgrade
Suppose version 1 of a contract stores an owner and a balance:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract VaultV1 {
address public owner;
uint256 public balance;
function deposit() external payable {
balance += msg.value;
}
}Now imagine version 2 adds a new variable at the top:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract VaultV2 {
uint256 public version;
address public owner;
uint256 public balance;
function deposit() external payable {
balance += msg.value;
}
}This looks harmless, but owner and balance have shifted. If VaultV2 is used as an implementation for an existing proxy, it will interpret old storage incorrectly.
A test should fail if the layout changes unexpectedly.
Testing with storage slot inspection
In Solidity-based testing environments, you can inspect raw storage using vm.load in Foundry or equivalent debug helpers in other frameworks. The idea is to write known values, then read the underlying slots directly and verify they match expectations.
Example Foundry test
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
contract LayoutV1 {
address public owner;
uint128 public feeBps;
bool public paused;
uint256 public total;
}
contract StorageLayoutTest is Test {
LayoutV1 internal vault;
function setUp() public {
vault = new LayoutV1();
}
function testStorageSlots() public {
address expectedOwner = address(0x1234);
vm.store(address(vault), bytes32(uint256(0)), bytes32(uint256(uint160(expectedOwner))));
vm.store(address(vault), bytes32(uint256(1)), bytes32(uint256(500)));
vm.store(address(vault), bytes32(uint256(2)), bytes32(uint256(999)));
bytes32 slot0 = vm.load(address(vault), bytes32(uint256(0)));
bytes32 slot1 = vm.load(address(vault), bytes32(uint256(1)));
bytes32 slot2 = vm.load(address(vault), bytes32(uint256(2)));
assertEq(address(uint160(uint256(slot0))), expectedOwner);
assertEq(uint256(slot1) & type(uint128).max, 500);
assertEq(uint256(slot2), 999);
}
}This example uses raw storage access to confirm slot-level assumptions. In real projects, you would usually write through the contract’s functions rather than vm.store, but the principle is the same: verify the exact storage shape.
What to test in upgradeable contracts
Storage layout tests are especially important for proxy-based systems. The most common failure modes are predictable.
| Risk | What happens | Test focus |
|---|---|---|
| Inserted variable before existing state | All later slots shift | Compare slot maps between versions |
| Changed variable type | Packing and offsets change | Verify slot and byte offset compatibility |
| Reordered inheritance | Base contract storage order changes | Check linearized layout |
| Removed variable without gap management | Future additions collide | Ensure reserved gaps remain intact |
| Packed variable assumptions | Values overlap in the same slot | Validate offsets for small types |
A strong upgrade test suite should include a “layout compatibility” check whenever a new implementation is proposed.
Using compiler output to validate layout
Solidity can emit storage layout metadata during compilation. This is one of the best ways to compare versions automatically.
The compiler output includes, for each variable:
- label
- slot
- offset
- type
- contract name
You can use this metadata in CI to detect accidental changes. The exact tooling differs by framework, but the workflow is consistent:
- compile both versions
- export storage layout metadata
- compare slots and offsets
- fail the build if incompatible changes are detected
This is more robust than eyeballing source diffs because the compiler, not the human, determines the actual layout.
Testing inheritance and packing edge cases
Inheritance makes storage layout less obvious. A base contract may declare variables that are not visible in the child contract source, yet they still occupy slots first.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract BaseConfig {
address public admin;
uint64 public delay;
}
contract ChildVault is BaseConfig {
bool public active;
uint256 public cap;
}The layout is not simply “child variables after base variables” in a casual sense. It follows Solidity’s linearization rules and packing rules. In this example, admin and delay may share a slot if packing permits, and active may continue packing in the same slot or move to the next one depending on offsets.
Best practice
When testing inheritance-heavy contracts:
- inspect the compiler’s storage layout output
- avoid assuming slot numbers from source order alone
- add tests for each base contract that contributes storage
- treat base contract changes as breaking changes unless proven otherwise
A regression test pattern for upgrades
A practical pattern is to snapshot the expected layout for a released version and compare it against future versions.
Step 1: define the baseline
Record the slot and offset for each variable in the released contract.
Step 2: compare against the new implementation
In tests or CI, assert that existing variables still occupy the same slot and offset.
Step 3: allow only safe additions
Appending new variables at the end is usually safe, provided you preserve any reserved storage gaps.
Example compatibility check
function assertCompatible(
string memory name,
uint256 oldSlot,
uint256 newSlot,
uint256 oldOffset,
uint256 newOffset
) internal pure {
require(oldSlot == newSlot, string.concat(name, ": slot changed"));
require(oldOffset == newOffset, string.concat(name, ": offset changed"));
}This kind of helper is useful when you parse compiler metadata in a script or custom test harness. The key idea is to fail fast on any unexpected movement.
Reserved gaps and how to test them
Upgradeable contracts often include storage gaps to preserve room for future variables:
uint256[50] private __gap;The gap is not decorative. It is a deliberate buffer that prevents later additions from colliding with inherited storage.
What to verify
- the gap exists in every upgradeable base contract
- the gap length remains unchanged unless intentionally consumed
- new variables are added only in reserved space or at the end of the layout
- no contract in the inheritance chain accidentally removes or reorders the gap
A good test can assert that the gap is still present by checking the compiler layout output or by verifying that the next declared variable lands after the reserved range.
When raw slot tests are not enough
Raw slot inspection is powerful, but it does not tell you whether the contract’s public behavior remains correct after an upgrade. For example, a variable may still be readable from the same slot, but its meaning may have changed.
Use structural tests to answer:
- Did the slot move?
- Did the offset move?
- Did packing change?
Use behavioral tests to answer:
- Does the contract still return the correct owner?
- Does a deposit still update the right balance?
- Does an upgrade preserve existing state?
Both are necessary. Storage layout tests prevent corruption; behavior tests prove correctness.
Common mistakes to avoid
1. Assuming source order equals storage order
Inheritance and packing can change the actual layout.
2. Adding variables to the top of an upgradeable contract
This is one of the fastest ways to break state compatibility.
3. Ignoring packed types
A bool or uint8 can share a slot with other values, so offset matters as much as slot number.
4. Forgetting inherited storage
A child contract may appear unchanged while a base contract update breaks everything.
5. Testing only on fresh deployments
Fresh deployments can hide upgrade bugs because the storage starts empty.
Recommended workflow for teams
A robust team workflow for storage layout testing looks like this:
- Design for append-only storage
- add new variables only at the end
- use storage gaps in upgradeable bases
- Export layout metadata in CI
- compare current and previous releases
- block incompatible changes
- Run upgrade simulation tests
- deploy V1
- write representative state
- upgrade to V2
- verify all values remain intact
- Include inheritance coverage
- test every contract that contributes state
- review base contract changes carefully
- Document storage assumptions
- note which variables are stable
- record reserved gaps and upgrade constraints
This workflow catches most layout regressions before they reach production.
Conclusion
Testing Solidity storage layout assumptions is one of the most effective ways to protect upgradeable contracts from silent state corruption. The core idea is simple: do not trust source order alone. Verify the actual slots, offsets, packing behavior, and inheritance effects that the compiler produces.
If your contract may ever be upgraded, storage layout is not an implementation detail—it is part of the contract’s long-term interface. Treat it that way, and test it accordingly.
