DEV Community

Ahmed Castro for Filosofía Código EN

Posted on

L1SLOAD: Reading Arrays, Structs and Nested Mappings

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Static Array Storage Demo

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:

valueSlot = arraySlot + valueIndex \text{valueSlot } = \text{ arraySlot } + \text{ valueIndex}

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Dynamic Array Storage Demo

L1DynamicArrayDemo storage layout

To query a specific element, we use the following formula:

valueSlot=KECCAK256(arraySLOT)+valueIndex \text{valueSlot} = \text{KECCAK256(arraySLOT)} + \text{valueIndex}

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode
Uint104 Array Storage Demo

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:

valueSlot=KECCAK256(arraySlot)+valueIndexslotSize/valueSize \text{valueSlot} = \text{KECCAK256(arraySlot)} + \frac{\text{valueIndex}}{\text{slotSize} / \text{valueSize}}

However, retrieving the value also requires shifting the bits appropriately, which can be done using the right-shift operator.

value=slotValue(valueIndex%slotSizevalueSizevalueSize) \text{value} = \text{slotValue} \gg \left( \text{valueIndex} \% \frac{\text{slotSize}}{\text{valueSize}} * \text{valueSize} \right)

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 L1Uint104ArrayDemo {
    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(245)/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(245)/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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Struct Storage Demo

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

KECCAK256(mappingKey, SLOT) \text{KECCAK256(mappingKey, SLOT)}

The dynamic array formula we introduced in this article is the following:

KECCAK256(SLOT)+arrayIndex \text{KECCAK256(SLOT)} + \text{arrayIndex}

We combine them and we get this one.

valueSlot=KECCAK256(KECCAK256(mappingKey, mappingSlot) + arrayIndex) \text{valueSlot} = \text{KECCAK256(KECCAK256(mappingKey, mappingSlot) + arrayIndex)}

Data is stored in a pattern as illustrated below:

Nesting Storage Demo

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (0)