Solidity Storage Layout Risks
Storage layout risk is the danger that an upgraded logic contract reads old values in the wrong slots. Slots are fixed positions in storage, so a new variable inserted in the middle shifts every value after it.
Appending a variable keeps the old value safe
The new version adds its variable at the end. The old value reads back exactly as before.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract V1 {
uint256 public amount;
function setAmount(uint256 v) public {
amount = v;
}
}
contract V2Safe {
uint256 public amount;
uint256 public bonus;
function setAmount(uint256 v) public {
amount = v;
}
function setBonus(uint256 v) public {
bonus = v;
}
}
contract Proxy {
bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
bytes32 constant ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
constructor(address impl) {
_setImplementation(impl);
_setOwner(msg.sender);
}
function implementation() public view returns (address impl) {
bytes32 slot = IMPL_SLOT;
assembly { impl := sload(slot) }
}
function owner() public view returns (address admin) {
bytes32 slot = ADMIN_SLOT;
assembly { admin := sload(slot) }
}
function _setImplementation(address impl) internal {
bytes32 slot = IMPL_SLOT;
assembly { sstore(slot, impl) }
}
function _setOwner(address admin) internal {
bytes32 slot = ADMIN_SLOT;
assembly { sstore(slot, admin) }
}
function upgrade(address impl) public {
require(msg.sender == owner(), "only owner");
_setImplementation(impl);
}
fallback() external payable {
address impl = implementation();
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}Inserting a variable corrupts the old read
The new version puts its variable before the old one. The old value now appears under the new variable name.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract V1 {
uint256 public amount;
function setAmount(uint256 v) public {
amount = v;
}
}
contract V2Bad {
uint256 public bonus;
uint256 public amount;
}
contract Proxy {
bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
bytes32 constant ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
constructor(address impl) {
_setImplementation(impl);
_setOwner(msg.sender);
}
function implementation() public view returns (address impl) {
bytes32 slot = IMPL_SLOT;
assembly { impl := sload(slot) }
}
function owner() public view returns (address admin) {
bytes32 slot = ADMIN_SLOT;
assembly { admin := sload(slot) }
}
function _setImplementation(address impl) internal {
bytes32 slot = IMPL_SLOT;
assembly { sstore(slot, impl) }
}
function _setOwner(address admin) internal {
bytes32 slot = ADMIN_SLOT;
assembly { sstore(slot, admin) }
}
function upgrade(address impl) public {
require(msg.sender == owner(), "only owner");
_setImplementation(impl);
}
fallback() external payable {
address impl = implementation();
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}A middle insert shifts every later value
The new version inserts a variable between two old ones. The second old value moves and reads as the new variable.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract V1Two {
uint256 public first;
uint256 public second;
function setBoth(uint256 a, uint256 b) public {
first = a;
second = b;
}
}
contract V2Mid {
uint256 public first;
uint256 public inserted;
uint256 public second;
}
contract Proxy {
bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
bytes32 constant ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
constructor(address impl) {
_setImplementation(impl);
_setOwner(msg.sender);
}
function implementation() public view returns (address impl) {
bytes32 slot = IMPL_SLOT;
assembly { impl := sload(slot) }
}
function owner() public view returns (address admin) {
bytes32 slot = ADMIN_SLOT;
assembly { admin := sload(slot) }
}
function _setImplementation(address impl) internal {
bytes32 slot = IMPL_SLOT;
assembly { sstore(slot, impl) }
}
function _setOwner(address admin) internal {
bytes32 slot = ADMIN_SLOT;
assembly { sstore(slot, admin) }
}
function upgrade(address impl) public {
require(msg.sender == owner(), "only owner");
_setImplementation(impl);
}
fallback() external payable {
address impl = implementation();
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}Example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract V1Vault {
uint256 public saved;
function addSaved(uint256 v) public {
saved += v;
}
}
contract V2Vault {
uint256 public saved;
uint256 public bonus;
function addSaved(uint256 v) public {
saved += v;
}
function setBonus(uint256 v) public {
bonus = v;
}
function total() public view returns (uint256) {
return saved + bonus;
}
}
contract Proxy {
bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
bytes32 constant ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
constructor(address impl) {
_setImplementation(impl);
_setOwner(msg.sender);
}
function implementation() public view returns (address impl) {
bytes32 slot = IMPL_SLOT;
assembly { impl := sload(slot) }
}
function owner() public view returns (address admin) {
bytes32 slot = ADMIN_SLOT;
assembly { admin := sload(slot) }
}
function _setImplementation(address impl) internal {
bytes32 slot = IMPL_SLOT;
assembly { sstore(slot, impl) }
}
function _setOwner(address admin) internal {
bytes32 slot = ADMIN_SLOT;
assembly { sstore(slot, admin) }
}
function upgrade(address impl) public {
require(msg.sender == owner(), "only owner");
_setImplementation(impl);
}
fallback() external payable {
address impl = implementation();
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}Key points
- Storage slot: a fixed position where one state value lives.
- Append safely: a new variable added at the end leaves old values in place.
- Insert danger: a new variable added in the middle shifts later values to new slots.
- Corrupted read: after a bad insert, an old value can appear under a new variable name.
- Proxy rule: the proxy and every logic version must agree on slot order.
In this example, a vault starts with a saved value and gains a bonus value at the end of its layout. The saved value survives the upgrade, and the total adds the two values together.
Note
slot layout is a compiler detail, so prove layout only by values read back and never by slot numbers.
Try it Yourself
Edit the code, then press Run.
Running is simulated · Remix IDE
Exercise
A new variable is inserted before an old value in an upgrade. What happens to the old value?