rip | title | description | author | discussions-to | status | type | category | created |
---|---|---|---|---|---|---|---|---|
7728 |
Precompile for L1SLOAD |
Proposal to add precompiled contract that load L1 storage slots. |
Haichen Shen (@icemelon), Péter Garamvölgyi (@Thegaram) |
Draft |
Standards Track |
Core |
2024-06-24 |
This proposal introduces a new precompiled contract L1SLOAD
that loads several storage slots from L1 given a contract address and storage keys.
With the plethora of L2s on the Ethereum, building multi-chain smart contracts has become challenging. This proposal provides a convenient and trustless way for smart contracts deployed on an L2 chain to read storage values from L1. This improves the developer experience by removing the need for developers to generate and submit MPT proofs themselves.
An example use case is key management for smart accounts (multisigs and AA wallets). When a wallet already exists on L1, users no longer need to set up the configuration and signing keys on L2 but they can instead load them directly from L1. We believe there are many other use cases that could benefit from direct access to L1 state.
There have been similar proposals before from the community. Brecht Devos proposed L1CALL
that allows contracts on L2 to call contracts deployed on L1. Optimism had a similar RFP for remote static call. While the proposed static call mechanism is more powerful than simple state reads, it forces L1 EVM execution to be part of L2s which hinders its adoption by L2 chains. The L1SLOAD
provides more fundamental functionality and allows more flexibility to L2s because (a) it is easier for L2s to modify the EVM and (2) this precompile might be implemented even by totally non-EVM compatible L2s.
Name | Value |
---|---|
PRECOMPILED_ADDRESS | TBD |
FIXED_GAS_COST | 2000 (tentative) |
PER_LOAD_GAS_COST | 2000 |
MAX_NUM_STORAGE_SLOTS | 5 (tentative) |
The inputs to the L1SLOAD
precompile are an L1 contract address and MAX_NUM_STORAGE_SLOTS
.
Byte range | Name | Description |
---|---|---|
[0: 19] (20 bytes) | address |
The contract address |
[20: 51] (32 bytes) | key1 |
The storage key |
... | ... | ... |
[k *32-12: k *32+19] (32 bytes) |
keyk |
The storage key |
The output is the L1 storage value at the latest L1 block number known to the L2 sequencer.
Byte range | Name | Description |
---|---|---|
[0: 31] (32 bytes) | value1 |
The L1 storage value |
... | ... | ... |
[(k -1)*32: k *32] (32 bytes) |
valuek |
The L1 storage value |
Prerequisite 1: The L2 sequencer has access to an L1 node. Given that the sequencer needs to monitor deposit transactions from L1, it already embeds an L1 node inside (preferred) or has access to an L1 PRC endpoint.
The introduction of the L1SLOAD
precompile may increase the requirement for the L1 node or the L1 RPC endpoint to be an archive node when the L2 node syncs the L2 chains from older blocks.
Prerequisite 2: The L2 sequencer has a notion of the latest seen L1 block, which is deterministic over all L2 nodes, i.e. it is part of the L2 state machine. The exact mechanism is not in scope for this RIP.
Implementation: When the L2 node encounters a call to the L1SLOAD
precompiled contract, it first verifies that its input is well-formed. It then retrieves its latest seen L1 block number l1BlockNumber
and sends an RPC query eth_getStorageAt(address, storageKey, l1BlockNumber)
to the L1 node. Finally, it writes the received storage value to the designated output buffer.
There are a few error cases that the L1SLOAD
precompile needs to handle
- Invalid input: when the input is invalid, the gas provided is consumed and there is no return data. This can be due to an incorrect number of bytes provided to the precompile.
- Invalid output buffer: when the output buffer is not large enough to hold the return data.
- Insufficient gas: when not enough gas was provided, there is no return data.
- RPC error: when the L2 sequencer receives the RPC error from the L1 node, the L2 sequencer should retry the RPC request or reinsert the transaction into the txpool. This case should not lead to revert, because that could lead to inconsistency when other nodes replay the transaction. Therefore, this should be treated as an internal error instead of an execution error.
The gas costs for L1SLOAD
is FIXED_GAS_COST + k * PER_LOAD_GAS_COST
, where k
is the number of storage slots. The constants are subject to change after more benchmarks.
The FIXED_GAS_COST
accounts for the additional RPC call latency to the L1 client. All storage keys are treated as cold keys in the L1SLOAD
and thus uses 2000 gas for each storage slots to be loaded.
Here is an example Solidity code snippet to use the L1SLOAD
precompile.
function loadFromL1(address l1Address, uint256 key1, uint256 key2) public view returns (uint256, uint256) {
address L1_SLOAD_ADDRESS = 0x101;
(bool success, bytes memory ret) = L1_SLOAD_ADDRESS.staticcall(
abi.encodePacked(l1Address, key1, key2)
);
if (!success) {
revert("L1SLOAD failed");
}
return abi.decode(ret, (uint256, uint256));
}
According to the specification defined above, L1SLOAD
returns the storage value at the latest L1 block known to the L2 sequencer. There are two related issues:
- How to guarantee the consistent return value of
L1SLOAD
- The risk of loading the storage value from a very stale L1 state.
First, to ensure the return value is consistent during transaction replay, L2 chains should provide a system contract that stores the information of the latest L1 block known to L2 sequencer. Optimism already provides a predeployed contract L1Block
. Scroll has a new system contract design that trustlessly imports the L1 block information and also stores other header fields such as state root, timestamp, RANDAO, etc.
Second, L2 protocols determine the L1 block import delay at their own discretion. To make L1SLOAD
more useful and reduce the risk of reading stale L1 storage states, we argue that the import delay should not be too long, e.g., waiting for finalized state that usually takes about 18-19 minutes. We suggest to wait for around 10 L1 block confirmations that has low risk of Ethereum chain re-organization while the import delay is fairly short (around 2 min). To adopt this, the L2 sequencers need to be capable of handling the situation when there is a long L1 chain re-organization. Furthermore, if the application is sensitive to stale storage reads, developers can limit the difference between the L1 block number retrieved from the system contract and the latest L1 block number per application requirement.
The L1SLOAD
precompile introduces risks of additional RPC latency and intermittent RPC errors. Both risks can be mitigated by running a L1 node in the same cluster as the L2 sequencer. It is preferrable for a L2 operator to run their own L1 node instead of using third party to get better security and reliability. We will perform more benchmarks to quantify the latency overhead in such setting.
Since the L1SLOAD
precompile directly returns storage values without verifying Merkle inclusion proofs, the responsibility of proving the correctness comes to L2 protocols. Here we briefly describe the method to prove the L1SLOAD
:
- First, we need to validate the state root of the last known L1 block. We assume the L1 block system contract stores some information of L1 block header, such as blockhash or state root. If the state root is stored in the system contract, the L1 state root can be directly read from the system contract and the validation of the state root is left to the design of the L1 block system contract. If only blockhash is stored in the contract, the state root can be decoded from the blockhash by providing the block header.
- Second, we need to prove the correctness of storage values. Zk rollups can use the storage Merkle inclusion proofs as witness data and validate the Merkle path to the L1 state root in the circuit. Similarly, Optimistic rollups can prove the Merkle inclusion proofs as part of the fraud proof.
No backward compatibility issues found as the precompiled contract will be added to PRECOMPILED_ADDRESS
at the next available address in the precompiled address set.