L1SLOAD el nuevo opcode para Keystores seguras y escalables

L1SLOAD el nuevo opcode para Keystores seguras y escalables

Las funciones de abstracción de cuentas cross-chain serán posibles gracias a los Keystores. Los usuarios podrán controlar varias smart contracts wallets, en múltiples chains, con una sola llave. Esto puede traer la tan esperada buena experiencia de usuario para los usuarios finales en los rollups de Ethereum.

Para que esto suceda, necesitamos poder leer los datos de L1 desde los rollups en L2, lo cual actualmente es un proceso muy costoso. Es por eso que Scroll introdujo recientemente el precompile L1SLOAD que es capaz de leer el estado de L1 de manera rápida y económica. Safe wallet ha creado un demo presentado en Safecon Berlín 2024. Pienso que esto es solo el comienzo, esto podrá mejorar aplicaciones cross-chain en DeFi, juegos, redes sociales y muchos más.

Vamos ahora a aprender, con ejemplos prácticos, los conceptos básicos de esta nueva primitiva que está abre la puerta a una nueva forma de interactuar con Ethereum.

1. Conecta tu wallet al Scroll Devnet

Actualmente, L1SLOAD está disponible únicamente en la Scroll Devnet. Toma nota y no la confundas con la Scroll Sepolia Testnet. Aunque ambos están desplegados sobre el Sepolia Testnet, son cadenas separadas.

Comenzamos conectando nuestra wallet a la Scroll Devnet:

Name: Scroll Devnet

RPC: https://l1sload-rpc.scroll.io

Chain id: 222222

Symbol: Sepolia ETH

Explorer: https://l1sload-blockscout.scroll.io

2. Obtener fondos en Scroll Devnet

Existen dos métodos de obtener fondos en la Scroll Devnet. Elige el que prefieras.

Bot de Faucet en Telegram (recomendado)

Únete a este grupo de telegram y escribe /drop TUADDRESS (e.g. /drop 0xd8da6bf26964af9d7eed9e03e53415d37aa96045) para recibir fondos directo a tu cuenta.

Bridge de Sepolia

Puedes enviar fondos de Sepolia a la Scroll Devnet a través del bridge. Existen dos maneras de lograr esto pero en este caso usaremos Remix.

Conectemos ahora tu wallet con Sepolia ETH a Sepolia Testnet. Recuerda que puedes obtener Sepolia ETH grátis en un faucet,

Ahora compila la siguiente interfaz.

// SPDX-License-Identifier: MIT
pragma solidity >=0.7.0 <0.9.0;

interface ScrollMessanger {
function sendMessage(address to, uint value, bytes memory message, uint gasLimit) external payable;
}

A continuación, en la tab de “Deploy & Run” conecta el contrato siguiente: 0x561894a813b7632B9880BB127199750e93aD2cd5.

Ahora puedes enviar ETH llamando la función sendMessage como se detalla a continación:

to: La dirección de tu cuenta EOA. El address que recibirá fondos en L2.
value: La cantidad de ether que deseas recibir en L2, en formato wei. Por ejemplo, si envías 0.01 ETH debes pasar como parámetro 10000000000000000

message: Déjalo en blanco, simplemente envía 0x00

gasLimit: 1000000 debería ser suficiente

Also remember to pass some value to your transaction. And add some extra ETH to pay for fees on L2, 0.001 should be more than enough. So if for example you sent 0.01 ETH on the bridge, send a transaction with 0.011 ETH to cover the fees.

También recuerda pasar un extra value en tu transacción. Es decir, agrega un poco de ETH extra para pagar las comisiones en L2, 0.001 debería ser más que suficiente. Así que, por ejemplo, si enviaste 0.01 ETH en el bridge, envía una transacción con 0.011 ETH para cubrir las comisiones.

Haz click en el botón transact y tus fondos deberían llegar en 15 mins aproximadamente.

2. Lanza tu contrato en L2

Tal y como mencionamos anteriormente, L1SLOAD lee el estado de contratos en L1 desde L2. Lancemos ahora un contrato simple en L1 que luego leeremos el valor de la variable number desde L2.

// SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.20;

/**
* @title Storage
* @dev Store & retrieve value in a variable
*/

contract L1Storage {

uint256 public number;

/**
* @dev Store value in variable
* @param num value to store
*/

function store(uint256 num) public {
number = num;
}

/**
* @dev Return value
* @return value of ‘number’
*/

function retrieve() public view returns (uint256){
return number;
}
}

Now call the store(uint256 num) function and pass a new value. For example let’s pass 42.

3. Obtener una slot desde L2

Lanzamos el siguiente contrato en L2 pasando el address del contrato que recién lanzamos en L1 como parámetro en el constructor.

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.20;

interface IL1Blocks {
function latestBlockNumber() external view returns (uint256);
}

contract L2Storage {
address constant L1_BLOCKS_ADDRESS = 0x5300000000000000000000000000000000000001;
address constant L1_SLOAD_ADDRESS = 0x0000000000000000000000000000000000000101;
uint256 constant NUMBER_SLOT = 0;
address immutable l1StorageAddr;

uint public l1Number;

constructor(address _l1Storage) {
l1StorageAddr = _l1Storage;
}

function latestL1BlockNumber() public view returns (uint256) {
uint256 l1BlockNum = IL1Blocks(L1_BLOCKS_ADDRESS).latestBlockNumber();
return l1BlockNum;
}

function retrieveFromL1() public {
uint256 l1BlockNum = IL1Blocks(L1_BLOCKS_ADDRESS).latestBlockNumber();
bytes memory input = abi.encodePacked(l1BlockNum, l1StorageAddr, NUMBER_SLOT);
bool success;
bytes memory ret;
(success, ret) = L1_SLOAD_ADDRESS.call(input);
if (success) {
(l1Number) = abi.decode(ret, (uint256));
} else {
revert(L1SLOAD failed);
}
}
}

Observa que este contrato primero llama latestL1BlockNumber() para obtener el más reciente bloque en L1 que está disponible para lectura en L2. Luego, llamamos L1SLOAD (opcode 0x101) pasando el address del contrato en L1 como parámetro y la slot 9 que es donde la variable number está ubicada dentro de ese contrato.

Ahora podemos llamar retrieveFromL1() para obtener el valor almacenado previamente.

Ejemplo #2: Leer otros tipos de variables

Solidity guarda las slots de las variables en el mismo orden en la que fueron declaradas. Esto es bastante conveniente para nosotros. Por ejemplo, en el siguiente contrato, account se almacena en la slot #0, number en la #1 y text en la #2.

// SPDX-License-Identifier: MIT
pragma solidity >=0.7.0 <0.9.0;

contract AdvancedL1Storage {
address public account;
uint public number;
string public text;
}

Podemos observar que cómo podemos obtener los valores de diferentes tipos: uint256, address, etc… Las strings son un poco diferente por la naturaleza variable de su tamaño.

// SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.20;

interface IL1Blocks {
function latestBlockNumber() external view returns (uint256);
}

contract L2Storage {
address constant L1_BLOCKS_ADDRESS = 0x5300000000000000000000000000000000000001;
address constant L1_SLOAD_ADDRESS = 0x0000000000000000000000000000000000000101;
address immutable l1ContractAddress;

address public account;
uint public number;
string public test;

constructor(address _l1ContractAddress) { //0x5555158Ea3aB5537Aa0012AdB93B055584355aF3
l1ContractAddress = _l1ContractAddress;
}

// Internal functions

function latestL1BlockNumber() internal view returns (uint256) {
uint256 l1BlockNum = IL1Blocks(L1_BLOCKS_ADDRESS).latestBlockNumber();
return l1BlockNum;
}

function retrieveSlotFromL1(uint blockNumber, address l1StorageAddress, uint slot) internal returns (bytes memory) {
bool success;
bytes memory returnValue;
(success, returnValue) = L1_SLOAD_ADDRESS.call(abi.encodePacked(blockNumber, l1StorageAddress, slot));
if(!success)
{
revert(L1SLOAD failed);
}
return returnValue;
}

function decodeStringSlot(bytes memory encodedString) internal pure returns (string memory) {
uint length = 0;
while (length < encodedString.length && encodedString[length] != 0x00) {
length++;
}
bytes memory data = new bytes(length);
for (uint i = 0; i < length; i++) {
data[i] = encodedString[i];
}
return string(data);
}

// Public functions

function retrieveAddress() public {
uint256 l1BlockNum = IL1Blocks(L1_BLOCKS_ADDRESS).latestBlockNumber();
account = abi.decode(retrieveSlotFromL1(l1BlockNum, l1ContractAddress, 0), (address));
}

function retrieveNumber() public {
uint256 l1BlockNum = IL1Blocks(L1_BLOCKS_ADDRESS).latestBlockNumber();
number = abi.decode(retrieveSlotFromL1(l1BlockNum, l1ContractAddress, 1), (uint));
}

function retrieveString() public {
uint256 l1BlockNum = IL1Blocks(L1_BLOCKS_ADDRESS).latestBlockNumber();
test = decodeStringSlot(retrieveSlotFromL1(l1BlockNum, l1ContractAddress, 2));
}

function retrieveAll() public {
uint256 l1BlockNum = IL1Blocks(L1_BLOCKS_ADDRESS).latestBlockNumber();
account = abi.decode(retrieveSlotFromL1(l1BlockNum, l1ContractAddress, 0), (address));
number = abi.decode(retrieveSlotFromL1(l1BlockNum, l1ContractAddress, 1), (uint));
test = decodeStringSlot(retrieveSlotFromL1(l1BlockNum, l1ContractAddress, 2));
}
}

Ejemplo #3: Leer el balance de un token ERC20 en L1

Comenzamos lanzando un token ERC20 bastante sencillo.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

import @openzeppelin/contracts/token/ERC20/ERC20.sol;

contract SimpleToken is ERC20 {
constructor(
string memory name,
string memory symbol,
uint256 initialSupply
) ERC20(name, symbol) {
_mint(msg.sender, initialSupply * 1 ether);
}
}

A continuación, lanzamos el siguiente contrato en L2 pasando como parámetros el address del token que recién lanzamos en L1.

// SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.20;

interface IL1Blocks {
function latestBlockNumber() external view returns (uint256);
}

contract L2Storage {
address constant L1_BLOCKS_ADDRESS = 0x5300000000000000000000000000000000000001;
address constant L1_SLOAD_ADDRESS = 0x0000000000000000000000000000000000000101;
address immutable l1ContractAddress;

uint public l1Balance;

constructor(address _l1ContractAddress) {
l1ContractAddress = _l1ContractAddress;
}

// Internal functions
function latestL1BlockNumber() public view returns (uint256) {
uint256 l1BlockNum = IL1Blocks(L1_BLOCKS_ADDRESS).latestBlockNumber();
return l1BlockNum;
}

function retrieveSlotFromL1(uint blockNumber, address l1StorageAddress, uint slot) internal returns (bytes memory) {
bool success;
bytes memory returnValue;
(success, returnValue) = L1_SLOAD_ADDRESS.call(abi.encodePacked(blockNumber, l1StorageAddress, slot));
if(!success)
{
revert(L1SLOAD failed);
}
return returnValue;
}

// Public functions
function retrieveL1Balance(address account) public {
uint slotNumber = 0;
uint256 l1BlockNum = IL1Blocks(L1_BLOCKS_ADDRESS).latestBlockNumber();
l1Balance = abi.decode(retrieveSlotFromL1(
l1BlockNum,
l1ContractAddress,
uint(keccak256(
abi.encodePacked(uint160(account),slotNumber)
)
)
), (uint));
}
}

Los contratos de OpenZeppelin colocan convenientemente el mapping de los balances del token en el Slot 0. Así que puedes llamar a retrieveL1Balance() pasando el address del holder como parámetro y el balance del token se almacenará en la variable l1Balance. Como puedes ver en el código, el proceso es primer convertir el address a uint160 y luego lo hasheamos con el slot del mapping, que es 0. Esto se debe a que es así como Solidity implementa los mappings.

¡Gracias por leer esta guía!

Sígueme en dev.to y en Youtube para todo lo relacionado al desarrollo en Blockchain en Español.