Storage Slots in Solidity: Storage Allocation and Low-level assembly storage operations
This article examines the storage architecture of the Ethereum Smart Contracts. It explains how variables are kept in the EVM storage and how to read and write to storage slots using low-level assembly (Yul).
This information is a prerequisite to understanding how proxies in Solidity work and how to gas optimize smart contracts.
Authorship
This article was co-authored by Aymeric Taylor (LinkedIn, Twitter), a research intern at RareSkills.
Smart Contract Storage Architecture
Variables in a smart contract store their value in two primary locations: storage and bytecode.
Bytecode
The bytecode stores immutable information. These include the values of immutable
and constant
variable types,
contract ImmutableVariables{
uint256 constant myConstant = 100;
uint256 immutable myImmutable;
}
as well as the compiled source code (The source code is the entire text below).
contract ImmutableVariables {
uint256 constant myConstant = 100;
uint256 immutable myImmutable;
constructor(uint256 _myImmutable) {
myImmutable = _myImmutable;
}
function doubleX() public pure returns (uint256) {
uint256 x = 20;
return x * 2;
}
}
In the doubleX()
function above, the value of hardcoded local variable such as uint256 x = 20
will also be stored in the bytecode.
As this article focuses on covering the storage aspect, we will not discuss the bytecode in detail.
Storage
The storage holds mutable information. Variables that store their value in the storage are called state variables or storage variables.
Their value persists in the storage indefinitely, until further transactions alter them or the contract self-destructs.
Storage variables are variables of all types that are declared within the global scope of a contract (except for immutable and constant variables).
contract StorageVariables{
uint256 x;
address owner;
mapping(address => uint256) balance;
// and more...
}
When we interact with a storage variable, under the hood, we are actually reading and writing from the storage, specifically at the storage slot where the variable keeps its value.
Storage slots
A smart contract’s storage is organized into storage slots. Each slot has a fixed storage capacity of 256 bits or 32 bytes ($256 \div 8 = 32$).
Storage slots are indexed from $0$ to $2^{256} – 1$. These numbers act as a unique identifier for locating individual slots.
The solidity compiler allocates storage space to storage variables in a sequential and deterministic manner, based on their declaration order within the contract.
Consider the contract below, it contains two storage variables: uint256 x
and uint256 y
.
contract StorageVariables {
uint256 public x; // first declared storage variable
uint256 public y; // second declared storage variable
}
Since x
is declared first and y
is declared second, x
is allocated to the first storage slot, slot 0, and y
allocated to the second storage slot, slot 1. Thus, x
will retain its value at slot 0, and y
at slot 1.
When queried, x
and y
will consistently read from the values stored in their respective storage slots. A variable cannot change its storage slot once the contract is deployed to the blockchain.
If the value of x
and y
is not initialized, it defaults to zero. All storage variables default to zero until they are explicitly set.
contract StorageVariables {
uint256 public x; // Uninitialized storage variable
function return_uninitialized_X() public view returns (uint256) {
return x; // returns zero
}
}
To set the value of x
to 20
, we can call the function set_x(20)
.
function set_x(uint256 value) external {
x = value;
}
This transaction triggers a state change in slot 0, updating its state from 0 to 20.
Essentially, all state changes made to a smart contract correspond to changes within these storage slots.
Inside storage slots: 256-bit data
Individual storage slots store data in 256-bit format; It stores the bit representation of a storage variable’s value.
In our previous example, uint256 x
stores its value at slot 0. A uint256
variable is 256 bit/32 bytes in size, therefore it will use up the 256 bit of storage space within slot 0 to store its value.
- Before calling
set_x(20)
, slot 0 was at its default state (all zeros)
All the green zeros seen in the image above correspond to the bits that are used to store x
‘s value.
- After calling
set_x(20)
, slot 0’s state was changed to the bit representation of uint256 20.
Reading the contents of a storage slot in raw 256 bit format is less human readable, therefore, solidity devs usually read it in hexadecimal format.
Raw 256 bit: 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
Hexadecimal format:
0x0000000000000000000000000000000000000000000000000000000000000014
The 256 bit of ones and zeros can be reduced to just 64 hexadecimal numbers. 1 hexadecimal character represents 4 bits. 2 hexadecimal characters represent 1 byte. The hexadecimal 0x14
equally translates to decimal number 20
. 0x14 (hex) = 10100 (binary) = 20 (decimal). Binary-to-hex converter.
We’ll demonstrate how to output the value of a storage slot in hexadecimal format or in bytes32 type using assembly in an upcoming section.
Primitive and Complex datatype
Throughout this article, our examples will only revolve around primitive datatypes such as unsigned integers (uint
), integers (int
), addresses (address
), and booleans (bool
).
contract PrimitiveTypes {
uint256 a;
int256 b;
address owner;
bool isTrue;
}
These variables occupy at most one storage slot.
Complex datatypes such as structs (struct{}
), arrays (array[]
), mappings (mapping(address => uint256)
), strings (string
), and bytes (bytes32
) have a more complicated storage slot allocation. They require a separate article to discuss thoroughly.
Storage Packing
So far, we’ve conveniently dealt with uint256
variables, which span the entire 32 bytes of a storage slot. Other primitive data types, such as uint8
, uint32
, uint128
, address
, and bool
, are smaller in size and uses less storage space. They can be packed together within the same storage slot.
On a side note, any multiple of 8 up to 256 is a valid uint
, and bytes1
, bytes2
, all the fixed byte sizes bytes1
, bytes2
, … way to bytes32
are all valid datatypes.
The table below illustrates the storage size of some primitive data types.
Type | Size |
---|
bool | 1 byte |
uint8 | 1 byte |
uint32 | 4 bytes |
uint128 | 16 bytes |
address | 20 bytes |
uint256 | 32 bytes |
For example, a storage variable of type address
will require 20 bytes of storage space to store its value, as illustrated in the table above.
contract AddressVariable{
address owner = 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4;
}
In the contract above, owner
will use up 20 bytes of the 32 bytes available in slot 0 to store its value.
Solidity packs variables in storage slots starting from the least significant byte (right most byte) and progresses to the left.
We can verify this by reading the bytes32 representation of the slot:
As shown in the diagram above, the value of owner, 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4, is stored starting from the right most byte or the least significant byte. The remaining 12 bytes in slot 0 will be unused storage space that another variable can occupy.
When declared in sequence, smaller sized variables live in the same storage slot if their total size is less than 256 bits or 32 bytes.
Say we declared a second and a third storage variable of type bool
(1 byte) and uint32
(4 bytes) , their values will be stored within the same storage slot as owner
, slot 0, at the unused storage space.
contract AddressVariable {
address owner = 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4;
// new
bool Boolean = true;
uint32 thirdvar = 5_000_000;
}
Boolean, the second declared storage variable, will store its value at the first byte to the left of owner‘s byte sequence, or, at the least significant byte of the unused storage space. Remember, solidity packs variables from the right to left.
uint32 thirdVar, the third storage variable, will store its value to the left of Boolean‘s byte sequence.
If we were to introduce a fourth storage variable, address admin
, its value will be stored in the next storage slot, slot 1.
contract AddressVariable {
address owner = 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4;
bool Boolean = true;
uint32 thirdVar = 5_000_000;
// new
address admin = 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2;
}
This is because admin‘s value in its entirety cannot fit into slot 0’s unused storage space. There are 7 bytes of storage space left but 20 bytes of consecutive storage space is needed. Therefore, instead of splitting admin‘s data between slot 0 and slot 1 (7 bytes in slot 0 and 13 bytes in slot 1), admin‘s value will be stored in a new storage slot, slot 1.
If a variable’s value cannot fit entirely into the remaining space of the current storage slot, it will be stored in the next available slot.
Declare smaller variables together
uint16 public a;
uint256 public x; // uint256 in the middle
uint32 public b;
In this arrangement, uint16 a
and uint32 b
will not be packed together.
Instead, a
will be stored at slot 0, x
at slot 1, and b
at slot 2, using up three storage slots. The storage slot allocation would look like the diagram below:
A better practice is to reorder the declarations to allow the smaller datatypes to be packed together.
uint256 public x;
// packed together
uint16 public a;
uint32 public b;
This configuration allows a and b to share a storage slot, thereby optimizing storage space.
Now that we’ve understood the theory behind how primitive variables are kept in storage, we are finally ready to learn how to manipulate them in assembly, using YUL.
Storage Slot Manipulation in Assembly (YUL)
Low level assembly (Yul) gives a higher degree of freedom in performing storage related operations. It allows us to directly read and write from individual storage slots and access a storage variable’s properties.
There are two opcodes related to storage in Yul: sload()
& sstore()
.
sload()
reads the value stored by a specific storage slot.sstore()
updates the value of a specific storage slot with a new value.
Two other important Yul keywords are .slot
and .offset
.
.slot
returns the location within the storage slots..offset
returns the byte offset of the variable. (This will be discussed in Part 2)
The .slot
keyword
The contract below contains three uint256 storage variables.
contract StorageManipulation {
uint256 x;
uint256 y;
uint256 z;
}
You should be able to deduce that x
, y
and z
store their values in slot 0, slot 1 and slot 2, respectively. We can prove this by accessing the storage variable’s property using the .slot
keyword.
.slot
tells us at which storage slot a variable keeps its value.
For example, to query x
‘s storage slot, append .slot
to the variable name: x.slot
in assembly.
function getSlotX() external pure returns (uint256 slot) {
assembly {// yul
slot := x.slot // returns slot location of x
}
}
x.slot
returns a value of 0 , which corresponds to the storage slot where x
stores its state—slot 0.
y.slot
will return 1 , which corresponds to y
‘s storage slot— slot 1.
z.slot
will return 2 , which corresponds to z
‘s storage slot— slot 1.
Reading the value of variables directly from their storage slot: sload()
Yul allows us to read the value stored by individual storage slots. The sload(slot)
opcode is used for this purpose. It requires one input, slot
, the storage slot identifier and returns the entire 256 bit of data stored at the specified slot location.
The slot identifier can be either the .slot
keyword (sload(x.slot)
), a local variable (sload(localvar)
) or a hardcoded number (sload(1)
).
Here are a few examples on how to use the sload()
opcode:
contract ReadStorage {
uint256 public x = 11;
uint256 public y = 22;
uint256 public z = 33;
function readSlotX() external view returns (uint256 value) {
assembly {
value := sload(x.slot)
}
}
function sloadOpcode(uint256 slotNumber)
external
view
returns (uint256 value)
{
assembly {
value := sload(slotNumber)
}
}
}
The function readSlotX()
retrieves the 256 bit data stored in x.slot
(slot 0) and returns it in uint256
format, which equals 11.
function readSlotX() external view returns (uint256 value) {
assembly {
value := sload(x.slot)
}
}
sload(0)
reads from slot 0, which stores the value of 11.sload(1)
reads from slot 1, which stores the value of 22.sload(2)
reads from slot 2, which stores the value of 33.sload(3)
reads from slot 3, which stores nothing, it is still in its default state.
The animation below visualizes how the sload
opcode functions.
The function sloadOpcode(slotNumber)
allows us to read the value of any arbitrary storage slot. It then returns the value in uint256 format.
function sloadOpcode(uint256 slotNumber)
external
view
returns (uint256 value)
{
assembly {
value := sload(slotNumber)
}
}
Notably, sload()
does not perform a type check.
In Solidity, we cannot return a uint256 variable in bool format as it will incur a type error.
function returnX() public view returns (bool ret) {
// type error
ret = x;
}
But if the same set of operation is performed in Yul, the code will still compile.
function readSlotX_bool() external view returns(bool value) {
// return in bool
assembly{
value:= sload(x.slot) // will compile
}
}
We’ll discuss why this is possible in detail in Part 2. To give you a rough idea, in assembly, every variable is essentially treated as a bytes32
type. Outside of the assembly scope, the variable will resume its original type and format the data accordingly.
Consequently, we can use this property to examine the value of a storage slot in bytes32 format.
contract ReadSlotsRaw {
uint256 public x = 20;
function readSlotX_bool() external view returns (bytes32 value) {
assembly {
value := sload(x.slot) // will compile
}
}
}
Writing to a storage slot using the sstore()
opcode
Yul gives us direct access to modify the value of a storage slot using the sstore()
opcode.
sstore(slot, value)
stores a 32-byte long value directly to a storage slot . The opcode takes two parameters, slot and value:
slot
: This is the targeted storage slot which we are writing to.value
: The 32-byte value to be stored at the specified storage slot. If the value is less than 32 bytes, it will be left padded with zeroes
sstore(slot, value)
overwrites the entire storage slot with a new value.
The contract below demonstrates how to use sstore()
; we use it to change the values of x
and y
:
contract WriteStorage {
uint256 public x = 11;
uint256 public y = 22;
address public owner;
constructor(address _owner) {
owner = _owner;
}
// sstore() function
function sstore_x(uint256 newval) public {
assembly {
sstore(x.slot, newval)
}
}
// normal function
function set_x(uint256 newval) public {
x = newval;
}
}
sstore_x(newVal)
directly updates the value stored in the storage slot that x
references, effectively changing the value of x
. The animation below visualizes what happens when we call the opcode sstore_x(88)
.
Both sstore_x(newVal)
and set_x()
perform the same function: They update the value of x
with a new value.
The function below, sstoreArbitrarySlot(slot, newVal)
, is capable of changing the value of any storage slot, therefore, it is advised to never put this in production.
function sstoreArbitrarySlot(uint256 slot, uint256 newVal) public {
assembly {
sstore(slot, newVal)
}
}
Calling sstoreArbitratySlot(
1 , 48
)
, will change the value of y
from 22
to 48
. Since y
keeps its value at storage slot 1, it overrides the value of 22 in slot 1 and changes it to 48.
sstore()
also does not type check.
Normally, when we try to assign an address
type to a uint256
type, it would return a type error and the contract would not compile:
address public owner;
function TypeError(uint256 value) external {
owner = value; // ERROR: Type uint256 is not implicitly convertible to expected type address.
}
ERROR: Type uint256 is not implicitly convertible to expected type address.
This error will not trigger with sstore()
as it does not perform a type check.
contract WriteStorage {
address public owner;
function sstoreOpcode(uint256 value) public {
assembly {
sstore(owner.slot, value)
}
}
}
Manipulating storage packed variables in Yul Part 2
sstore
and sload
operate on lengths of 32 bytes. This is convenient when dealing with uint256
type as the entire 32 bytes read or written correspond directly to the uint256
variable. However, the situation becomes more complex when dealing with variables that are packed within the same storage slot. Their byte sequence occupies only a portion of the 32 bytes and in assembly, we do not have an opcode to directly modify or read from their byte sequence in storage.
In Part 2, we will cover manipulating storage-packed variables in Yul using bit-manipulation and bit-masking techniques.
Originally Published July 15, 2024