This article is a continuation of the L1SLOAD guide where we introduced its basic usage. Now, we will explore how to use l1sload
to read more complex storage structures, such as mappings, structs, and both static and dynamic arrays.
The EVM handles the state by using 32-byte storage slots. In Solidity, this is abstracted to provide a better developer experience. However, to be read complex data structures with l1sload
, it's essential to understand how Solidity manages storage at a lower level.
So let's start by understanding how static arrays are managed by the EVM.
Static Arrays
Static arrays are those with a fixed length declared at compile time. For example:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract L1ArrayDemo {
uint[5] myArray;
constructor() {
myArray[0] = 10;
myArray[1] = 20;
myArray[2] = 30;
myArray[3] = 40;
myArray[4] = 50;
}
}
On-chain, the first element of the array is stored in the variable’s storage slot, the second in slot + 1
, the third in slot + 2
, and so on.
L1ArrayDemo storage layout
To retrieve an element, you can simply add the array's slot to the array index of the value you're looking for. The formula looks like this:
Keep in mind, the length of a static array is not stored on-chain. It's only visible at compile time.
Another important note is that this approach works for types larger than 16 bytes (e.g., uint256
, address
, uint200
). For smaller types like bool
or uint8
, Solidity applies data optimizations, which we’ll cover later in this guide.
The following contract demonstrates how to read an array from the L1ArrayDemo
contract.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract L2ArrayDemo {
address constant L1_BLOCKS_ADDRESS = 0x5300000000000000000000000000000000000001;
address constant L1_SLOAD_ADDRESS = 0x0000000000000000000000000000000000000101;
function getNum(address contractAddress, uint arraySlot, uint arrayIndex) public view returns(uint) {
bool success;
bytes memory returnValue;
(success, returnValue) = L1_SLOAD_ADDRESS.staticcall(abi.encodePacked(contractAddress, arraySlot + arrayIndex));
if(!success)
{
revert("L1SLOAD failed");
}
return abi.decode(returnValue, (uint));
}
}
Dynamic Arrays
Unlike static arrays, dynamic arrays can grow after deployment. Because of this, Solidity stores them differently on-chain.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract L1DynamicArrayDemo {
uint[] public myArray;
constructor() {
myArray.push(10);
myArray.push(20);
myArray.push(30);
}
}
The storage slot for a dynamic array holds its length. The actual data is stored starting at KECCAK256(ARRAY_SLOT)
, with elements stored sequentially after that position, just like static arrays.
L1DynamicArrayDemo storage layout
To query a specific element, we use the following formula:
Here’s an example contract that queries both the length and specific elements of a dynamic array:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract L2DynamicArrayDemo {
address constant L1_BLOCKS_ADDRESS = 0x5300000000000000000000000000000000000001;
address constant L1_SLOAD_ADDRESS = 0x0000000000000000000000000000000000000101;
function retrieveArrayElement(address contractAddress, uint arraySlot, uint arrayIndex) public view returns(uint) {
require(arrayIndex < getArrayLength(contractAddress, arraySlot), "Out of bounds index");
uint arrayElementSlot = uint(
keccak256(
abi.encodePacked(arraySlot)
)
) + arrayIndex;
bool success;
bytes memory returnValue;
(success, returnValue) = L1_SLOAD_ADDRESS.staticcall(abi.encodePacked(contractAddress, arrayElementSlot));
if(!success)
{
revert("L1SLOAD failed");
}
return abi.decode(returnValue, (uint));
}
function getArrayLength(address contractAddress, uint arraySlot) public view returns(uint) {
bool success;
bytes memory returnValue;
(success, returnValue) = L1_SLOAD_ADDRESS.staticcall(abi.encodePacked(contractAddress, arraySlot));
if(!success)
{
revert("L1SLOAD failed");
}
return abi.decode(returnValue, (uint));
}
}
Special Case: Storing Values 16 Bytes or Smaller
Consider a dynamic array of uint104
values. Since two uint104
values can fit into a single 32-byte storage slot (with 48 bits, or 6 bytes, remaining unused), Solidity packs them together.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract L1Uint104Array {
uint104[] public myArray;
constructor() {
myArray.push(10);
myArray.push(20);
myArray.push(30);
myArray.push(40);
myArray.push(50);
}
}
L1Uint104Array storage layout
To query such values using l1sload
, we first locate the appropriate slot, then use bitwise operations to extract the desired value. The following formula helps in calculating the slot:
However, retrieving the value also requires shifting the bits appropriately, which can be done using the right-shift operator.
This method can also work for larger types like uint256 or address, but for simplicity and efficiency, I recommend sticking to the methods mentioned earlier for those types.
Here’s an example contract that queries data stored in a uint104 array:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract L2Uint104ArrayDemo {
address constant L1_BLOCKS_ADDRESS = 0x5300000000000000000000000000000000000001;
address constant L1_SLOAD_ADDRESS = 0x0000000000000000000000000000000000000101;
function retrieveArrayElement(address contractAddress, uint arraySlot, uint arrayIndex) public view returns(uint104) {
require(arrayIndex < getArrayLength(contractAddress, arraySlot), "Out of bounds index");
uint arrayElementSlot = uint(
keccak256(
abi.encodePacked(arraySlot)
)
) + arrayIndex / (uint(256)/104);
bool success;
bytes memory returnValue;
(success, returnValue) = L1_SLOAD_ADDRESS.staticcall(abi.encodePacked(contractAddress, arrayElementSlot));
if(!success)
{
revert("L1SLOAD failed");
}
uint104 returnValueUint = uint104(abi.decode(returnValue, (uint)) >> ((arrayIndex % (uint(256)/104)) * 104));
return returnValueUint;
}
function getArrayLength(address contractAddress, uint arraySlot) public view returns(uint) {
bool success;
bytes memory returnValue;
(success, returnValue) = L1_SLOAD_ADDRESS.staticcall(abi.encodePacked(contractAddress, arraySlot));
if(!success)
{
revert("L1SLOAD failed");
}
return abi.decode(returnValue, (uint));
}
}
Structs
Structs store their data contiguously, similar to arrays. If multiple elements can fit into a single 32-byte slot, Solidity will pack them together to optimize storage.
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.20;
struct MyStruct {
uint a;// 32 bytes (256 bits)
address b; // 20 bytes (160 bits)
bool c; // 1 byte (8 bits)
uint d; // 32 bytes (256 bits)
}
contract L1StructDemo {
MyStruct myStruct;
constructor() {
myStruct = MyStruct(10,address(this),true,20);
}
}
In the following example, the struct fields a
, b
, and c
are packed across multiple slots. The variable d
doesn’t fit into the second slot, so it is stored in the next available slot.
L1StructDemo storage layout
Reading structs requires accounting for the byte offsets of each field, using bitwise operations as necessary.
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.20;
struct MyStruct {
uint a;// 32 bytes (256 bits)
address b; // 20 bytes (160 bits)
bool c; // 1 byte (8 bits)
uint d; // 32 bytes (256 bits)
}
// This contract reads the balance of any holder on L1
contract L2StructDemo {
address constant L1_BLOCKS_ADDRESS = 0x5300000000000000000000000000000000000001;
address constant L1_SLOAD_ADDRESS = 0x0000000000000000000000000000000000000101;
function getArrayLength(address contractAddress, uint structSlot) public view returns(MyStruct memory) {
bool success;
bytes memory returnValue;
(success, returnValue) = L1_SLOAD_ADDRESS.staticcall(abi.encodePacked(contractAddress, structSlot, structSlot+1, structSlot+2));
if(!success)
{
revert("L1SLOAD failed");
}
(uint256 slot0, uint256 slot1, uint slot2) = abi.decode(returnValue, (uint, uint, uint));
address b = address(uint160(slot1));
bool c = (slot1 >> 160) == 1;
return MyStruct(slot0, b, c, slot2);
}
}
Nesting Structures
Nested structures, such as mappings of arrays or structs containing arrays, follow the same rules as their underlying structures.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract L1NestingDemo {
mapping(uint => uint[] myArray) arrayMapping;
constructor() {
arrayMapping[0].push(10);
arrayMapping[0].push(20);
arrayMapping[0].push(30);
arrayMapping[1].push(100);
arrayMapping[1].push(200);
arrayMapping[1].push(300);
}
}
For example, reading from a mapping of arrays requires combining the formulas for mappings and dynamic arrays. The formula for mappings from the previous article, where introduced Mappings, works like this:
The dynamic array formula we introduced in this article is the following:
We combine them and we get this one.
Data is stored in a pattern as illustrated below:
L1NestingDemo storage layout
Take a look at this example that demonstrates reading a mapping of arrays:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract L2NestingDemo {
address constant L1_BLOCKS_ADDRESS = 0x5300000000000000000000000000000000000001;
address constant L1_SLOAD_ADDRESS = 0x0000000000000000000000000000000000000101;
function getArrayLength(address contractAddress, uint mappingSlot, uint mappingKey, uint arrayIndex) public view returns(uint) {
uint mappingArraySlot = uint(
keccak256(
abi.encodePacked(
keccak256(abi.encodePacked(mappingKey,
mappingSlot)
)
)
)) + arrayIndex;
bool success;
bytes memory returnValue;
(success, returnValue) = L1_SLOAD_ADDRESS.staticcall(abi.encodePacked(contractAddress, mappingArraySlot));
if(!success)
{
revert("L1SLOAD failed");
}
return abi.decode(returnValue, (uint));
}
}
You can extend these principles to more complex data structures, such as an array of mappings, structs with arrays, or even mappings of mappings. The same logic applies in all cases.
Next steps
To improve your understanding of Solidity storage layout, check out the official documentation on the Layout of State section. Additionally, share your thoughts on the Ethereum Magicians Forum regarding the L1SLOAD RIP. With l1sload
currently in development, your input is very important at this stage.
Thanks for reading!