Skip to content

Add L1SLOAD precompile#11

Open
jmadibekov wants to merge 12 commits intomainfrom
feat/l1sload-precompile-nmc
Open

Add L1SLOAD precompile#11
jmadibekov wants to merge 12 commits intomainfrom
feat/l1sload-precompile-nmc

Conversation

@jmadibekov
Copy link
Copy Markdown

@jmadibekov jmadibekov commented Feb 25, 2026

What is L1SLOAD?

L1SLOAD (https://github.com/ethereum/RIPs/blob/master/RIPS/rip-7728.md) is a precompile at address 0x10001 that lets L2 smart contracts read L1 Ethereum storage. For example, an L2 DeFi protocol can check an L1 oracle price or verify L1 token balances without a bridge or messaging system.

An L2 transaction calls the precompile with 84 bytes - an L1 contract address (20 bytes), a storage key (32 bytes), and an L1 block number (32 bytes) - and gets back the 32-byte storage value from that slot at that block.

What this PR does

Adds the L1SLOAD precompile to the EVM at address 0x10001. The precompile is cache-based: it looks up pre-populated values from an in-memory cache. This makes it deterministic and suitable for ZK proving.

It has an option of providing an L1 RPC endpoint, more on that below.

Precompile behavior

Input: 84 bytes — [0..20] address, [20..52] storage key, [52..84] block number (uint256 BE).
Output: 32-byte storage value.
Gas: 4000 (2000 fixed + 2000 per-load).

Block range validation

The precompile enforces that the requested L1 block falls within a valid range relative to the L1 origin (the L1 block that triggered this L2 batch):

[l1origin - 256] ◄──── max lookback (256 blocks) ────► [l1origin]

Rules:

  • requested_block <= l1origin (can't read future L1 state)
  • l1origin - requested_block <= 256 (lookback limit measured from L1 origin)

Execution flow

Tx/CALL to 0x10001
    │
    ├── Parse 84-byte input
    ├── Validate gas (need 4000)
    ├── Validate block range (within [l1origin-256, l1origin])
    │
    └── Cache lookup
        ├── HIT → return 32-byte value
        └── MISS
            ├── RPC callback set? → fetch from L1, cache it, track the call → return value
            └── No callback → error "value not found in cache"

RPC fallback for indirect calls

During preflight (when network access is available), the companion prover (Raiko) sets an RPC callback via set_l1_rpc_fetcher(). This handles cache misses from indirect L1SLOAD calls — cases where a smart contract internally calls the precompile and the call wasn't detected by static transaction scanning.

After execution, Raiko reads take_l1_rpc_served_calls() to get all calls that were served via RPC, then fetches their Merkle proofs for verification.

During proving (no network), the callback is not set — cache misses are hard errors, which is correct since all values should have been pre-verified and cached.

Cache API

// Context setup (called by Raiko before block execution)
set_anchor_block_id(u64)
set_l1_origin_block_id(u64)
set_l1_storage_value(address, key, block_number, value)

// RPC fallback (preflight only)
set_l1_rpc_fetcher(callback)
take_l1_rpc_served_calls() -> HashSet<(address, key, block_number)>

// Cleanup (between blocks)
clear_l1_storage()

Unit tests

25 unit tests in crates/evm/src/precompiles/l1sload.rs

How to run

# L1SLOAD tests only
cargo test -p alethia-reth-evm --lib precompiles::l1sload::tests

# Full alethia-reth-evm suite (29 pass)
cargo test -p alethia-reth-evm

Test output

running 25 tests
test precompiles::l1sload::tests::test_anchor_block_id_context ... ok
test precompiles::l1sload::tests::test_clear_l1_storage_clears_rpc_fallback_state ... ok
test precompiles::l1sload::tests::test_l1sload_accepts_block_between_anchor_and_l1origin ... ok
test precompiles::l1sload::tests::test_cache_key_uniqueness ... ok
test precompiles::l1sload::tests::test_l1sload_exact_lookback_boundary ... ok
test precompiles::l1sload::tests::test_l1sload_exact_gas_boundary ... ok
test precompiles::l1sload::tests::test_l1sload_exact_l1_origin ... ok
test precompiles::l1sload::tests::test_l1sload_fails_with_insufficient_gas ... ok
test precompiles::l1sload::tests::test_l1sload_anchor_equals_l1origin ... ok
test precompiles::l1sload::tests::test_l1sload_fails_without_cached_storage ... ok
test precompiles::l1sload::tests::test_l1sload_l1origin_less_than_anchor_rejected ... ok
test precompiles::l1sload::tests::test_l1sload_fails_without_anchor_block_id ... ok
test precompiles::l1sload::tests::test_l1sload_rejects_block_after_l1_origin ... ok
test precompiles::l1sload::tests::test_l1sload_multiple_rpc_calls_tracked ... ok
test precompiles::l1sload::tests::test_l1sload_rejects_block_beyond_lookback ... ok
test precompiles::l1sload::tests::test_l1sload_rejects_block_near_anchor_but_far_from_l1origin ... ok
test precompiles::l1sload::tests::test_l1sload_rejects_invalid_input_lengths ... ok
test precompiles::l1sload::tests::test_l1sload_rpc_fallback_error_propagates ... ok
test precompiles::l1sload::tests::test_l1sload_rpc_fallback_records_served_calls ... ok
test precompiles::l1sload::tests::test_l1sload_same_key_different_blocks ... ok
test precompiles::l1sload::tests::test_l1sload_succeeds_with_cached_storage ... ok
test precompiles::l1sload::tests::test_l1sload_succeeds_with_older_block ... ok
test precompiles::l1sload::tests::test_l1sload_zero_block_number ... ok
test precompiles::l1sload::tests::test_l1sload_zero_storage_value ... ok
test precompiles::l1sload::tests::test_storage_cache_operations ... ok

test result: ok. 25 passed; 0 failed; 0 ignored; 0 measured; 4 filtered out; finished in 0.00s

Cross references

Adds the L1SLOAD precompile at address 0x10001 for reading L1 storage
from L2. The precompile takes 84-byte input (address + storage key +
block number) and returns the cached storage value.

The precompile is a dummy cache: Raiko populates it with verified
values before block re-execution during proving. The cache keys
include block number to support lookback up to 256 blocks from
the anchor block.

Changes:
- crates/evm/src/precompiles/l1sload.rs: Cache, API, precompile logic
- crates/evm/src/precompiles/mod.rs: taiko_precompiles_map() helper
- crates/evm/src/factory.rs: Use taiko_precompiles_map in EVM factory
- 11 unit tests covering all paths
- PR #1: Convert eprintln! to trace! in L1SLOAD precompile hot path to
  avoid flooding stderr in production. Keep [jmadibekov] tags for debugging.
- PR #3: Update comment byte ranges from inclusive [0:19] to Rust slice
  syntax [0..20], [20..52], [52..84] matching the actual code.
@jmadibekov jmadibekov changed the title Add L1SLOAD precompile Add L1SLOAD precompile (RIP-7728) for reading L1 storage from L2 Mar 3, 2026
Promote trace! to info! and add [jmadibekov] tags for:
- Precompile entry, input parsing, range checks
- Cache hit/miss, RPC fallback, anchor/l1origin context
- Consolidate ~12 per-call info logs in l1sload_run() into 2-3 targeted logs
- Cache hits downgraded to trace level; RPC fallback remains at info
- Trim verbose doc comments that restate obvious code
- Keep all functional logic and tests intact

E2E verified: all L1SLOAD scenarios pass on devnet.
- Remove unused L1SLOAD const bundle (address inlined in mod.rs)
- Inline clear_anchor_block_id/clear_l1_origin_block_id into clear_l1_storage
- Make 4 constants private (not used outside crate)
- Unify duplicated output construction in cache-hit/RPC-fallback branches
- Downgrade RPC fallback log from info to debug
- Remove redundant clear calls in tests
The lookback range is now measured from L1 origin instead of anchor,
matching surge-raiko's simplified trust model. L1 origin is the single
reference point for both upper and lower bounds.
@jmadibekov jmadibekov changed the title Add L1SLOAD precompile (RIP-7728) for reading L1 storage from L2 Add L1SLOAD precompile Mar 6, 2026
@jmadibekov jmadibekov marked this pull request as ready for review March 6, 2026 12:20
@smartprogrammer93
Copy link
Copy Markdown

@claude please review

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a Taiko-specific L1SLOAD EVM precompile (RIP-7728-inspired) at address 0x10001, enabling L2 contracts to read L1 storage values from a deterministic in-memory cache with an optional RPC fallback for preflight.

Changes:

  • Added a new l1sload precompile implementation with cache/context APIs and unit tests.
  • Wired the Taiko EVM factory to use a custom precompiles map that includes 0x10001.
  • Added serial_test (dev dependency) to ensure tests are isolated due to global state.

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
crates/evm/src/precompiles/mod.rs Adds taiko_precompiles_map() to register the L1SLOAD precompile at 0x10001.
crates/evm/src/precompiles/l1sload.rs Implements the L1SLOAD precompile, global cache/context, RPC fallback tracking, and tests.
crates/evm/src/lib.rs Exposes a new precompiles module from the crate root.
crates/evm/src/factory.rs Switches EVM construction to use the Taiko precompiles map (now including L1SLOAD).
crates/evm/Cargo.toml Adds serial_test as a dev-dependency for serializing tests.
Cargo.lock Locks new dev-dependency transitive packages (e.g., serial_test, extra dashmap version).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/evm/src/precompiles/l1sload.rs Outdated
Comment thread crates/evm/src/precompiles/l1sload.rs Outdated
Comment thread crates/evm/src/precompiles/l1sload.rs Outdated
Comment thread crates/evm/src/precompiles/l1sload.rs
Comment thread crates/evm/src/precompiles/l1sload.rs
Comment thread crates/evm/src/precompiles/l1sload.rs
Comment thread crates/evm/Cargo.toml
@smartprogrammer93
Copy link
Copy Markdown

@claude please review

@claude
Copy link
Copy Markdown

claude bot commented Mar 12, 2026

Claude finished @smartprogrammer93's task in 2m 0s —— View job


Code Review: Add L1SLOAD Precompile

Tasks:

  • Gather context
  • Read changed files
  • Analyze implementation
  • Post review

Good overall structure — the implementation is clean and well-tested. Below are my findings, roughly ordered by severity.


Critical / Correctness

1. Mutex held across RPC call (deadlock risk)
l1sload.rs:191–199L1_RPC_FETCHER guard is still alive when fetcher(...) is invoked on line 197. If the callback (or any code it calls internally) attempts to acquire L1_RPC_FETCHER again (e.g. via set_l1_rpc_fetcher / clear_l1_rpc_fetcher), the thread will deadlock. The drop(fetcher_guard) on line 199 only releases after the call returns.

The standard fix is to clone the fetcher under the lock, drop the guard, then invoke:

let maybe_fetcher = {
    let guard = L1_RPC_FETCHER.lock().expect("...");
    guard.as_ref().map(|f| Arc::clone(f))  // fetcher should be Arc<dyn Fn...>
};
if let Some(fetcher) = maybe_fetcher {
    let fetched = fetcher(contract_address, storage_key, requested_block)?;
    ...
}

This also shortens the critical section to near-zero during the RPC call. Fix this →


2. Silent fallback: l1_origin_block_id defaults to anchor_block_id
l1sload.rs:151 — When CURRENT_L1_ORIGIN_BLOCK_ID is not set, the code silently falls back to anchor_block_id. The PR description makes it clear that block range validation is relative to L1 origin, not the anchor. A missing L1 origin is almost certainly a caller bug, and the silent fallback will accept a different window than intended, potentially allowing stale or future L1 data to be read.

Recommend requiring it explicitly:

let l1_origin_block_id = get_l1_origin_block_id().ok_or_else(|| {
    warn!("L1SLOAD: L1 origin block ID not set");
    PrecompileError::Other("L1 origin block ID not set".into())
})?;

Fix this →


3. Mutex poisoning panics the node
Throughout the file, .expect("... mutex poisoned") is used in the precompile's hot execution path (l1sload_run). If a fetcher panics while holding a lock, the mutex is poisoned and subsequent calls will panic the EVM rather than returning a controlled PrecompileError. Consider using unwrap_or_else(|e| e.into_inner()) for the runtime path, or at minimum mapping poisoning to a PrecompileError. Fix this →


Design Concerns

4. Global mutable state — threading and parallelism hazards
All state (L1_STORAGE_CACHE, CURRENT_ANCHOR_BLOCK_ID, L1_RPC_FETCHER, etc.) is process-global. This is fine for single-threaded sequential block processing, but means:

  • Any future parallel EVM execution (e.g. optimistic parallelism) will silently share state across transactions/blocks.
  • Tests require serial_test to avoid races — a design smell.

A cleaner long-term approach is to pass this context through the EVM's ContextPrecompile mechanism so each EVM instance carries its own L1SLOAD state. This would also eliminate the need for clear_l1_storage() between blocks.

5. clear_l1_storage has surprising scope
l1sload.rs:83–90clear_l1_storage also clears the RPC fetcher. If a caller wants to reset the cache between blocks but preserve the fetcher (e.g. during multi-block preflight), they must re-register the callback each time. Consider whether this should be two separate operations or have the fetcher survive clear_l1_storage.

6. factory.rs inspector double-construction
factory.rs:88–91create_evm_with_inspector calls build_taiko_mainnet_with_inspector(NoOpInspector {}) and then immediately .with_inspector(inspector). The initial NoOpInspector is created and discarded, which is wasteful and slightly confusing. If the builder supports it, pass the real inspector directly. Fix this →


Code Quality / Lint

7. Missing doc comments on private items — clippy will fail
l1sload.rs:57, 68, 116 — The crate enables clippy::missing_docs_in_private_items (see lib.rs:1). The private functions get_anchor_block_id, get_l1_origin_block_id, and get_l1_storage_value lack doc comments and will fail just clippy. Fix this →

8. serial_test not in workspace dependencies
crates/evm/Cargo.toml:28 — All other dependencies use workspace = true. Adding serial_test to the workspace [dev-dependencies] and referencing it as serial_test = { workspace = true } keeps version management centralized. Fix this →


Minor

9. Unnecessary buffer copy on output
l1sload.rs:219–221value.0 is already a [u8; 32]. The intermediate output buffer and copy_from_slice are superfluous:

// simplify to:
Ok(PrecompileOutput::new(gas_used, Bytes::from(value.0.to_vec())))

10. Missing test coverage

  • No test for the l1_origin < anchor guard (l1sload.rs:153).
  • No test that validates the silent fallback when l1_origin is not set (especially relevant if issue feat(repo): modify L1SLOAD precompile input #2 above is fixed to return an error).
  • No test for the test_l1sload_rejects_block_beyond_lookback with requested_block exactly at the boundary (l1_origin - 256), only one-past-boundary.

Summary

Severity Count
Critical / Correctness 3
Design 3
Lint / Quality 2
Minor 2

The implementation is functionally solid for the single-threaded sequential case and the test suite is thorough. The most important things to address before merging are the mutex-held-across-RPC-call deadlock risk (#1), the silent l1_origin fallback (#2), and the clippy failures from missing doc comments (#7).
|

@smartprogrammer93
Copy link
Copy Markdown

@claude review

@claude
Copy link
Copy Markdown

claude bot commented Mar 17, 2026

Claude finished @smartprogrammer93's task in 1m 50s —— View job


Tasks:

  • Gather context
  • Read changed files
  • Compare against previous review feedback
  • Post updated review

Updated Code Review: Add L1SLOAD Precompile

Good progress since the last review — several issues have been addressed. Here's the current state.


Fixed Since Last Review

  • Silent l1_origin fallbackl1sload.rs:154–160 now correctly returns a PrecompileError when CURRENT_L1_ORIGIN_BLOCK_ID is not set.
  • Missing doc commentsget_anchor_block_id (line 57), get_l1_origin_block_id (line 69), and get_l1_storage_value (line 118) now have doc comments.
  • New teststest_l1sload_l1origin_less_than_anchor_rejected and test_l1sload_fails_without_l1_origin_block_id cover the previously missing cases.

Remaining: Mutex Held Across RPC Call

l1sload.rs:199–217 — The fetcher_guard (lock on L1_RPC_FETCHER) is still held when fetcher(...) is called on line 205. The drop(fetcher_guard) on line 207 is on the success path only — in the error case the ? exits early and the guard unwinds naturally. This is functionally correct but means the mutex is held for the entire duration of the RPC call.

The author's position ("the callback is a simple RPC call that never re-acquires this lock") is pragmatically reasonable given the current architecture, but there's a secondary consequence worth noting: since the ? on line 205–206 can return early before drop(fetcher_guard), the placement of the explicit drop implies it only matters on the success path. A scoped block would make the intent clearer and is the idiomatic Rust pattern here:

let maybe_value = {
    let guard = L1_RPC_FETCHER.lock().expect("...");
    guard.as_ref().map(|f| f(contract_address, storage_key, requested_block))
};
if let Some(fetched) = maybe_value {
    let fetched = fetched.map_err(|e| PrecompileError::Other(...))?;
    // guard is already dropped here
    ...
}

However if this won't be fixed before merge, it's acceptable given the stated constraints. Fix this →


Remaining: factory.rs Double-Construction

factory.rs:89–91create_evm_with_inspector still builds with NoOpInspector {} then immediately calls .with_inspector(inspector), discarding the initial inspector. This was raised in the previous review but not addressed. Fix this →


Minor: Unnecessary Output Buffer Copy

l1sload.rs:227–229value.0 is [u8; 32] and can be used directly:

// current
let mut output = [0u8; 32];
output.copy_from_slice(value.as_slice());
Ok(PrecompileOutput::new(gas_used, Bytes::from(output)))

// simpler
Ok(PrecompileOutput::new(gas_used, Bytes::from(value.0.to_vec())))
// or
Ok(PrecompileOutput::new(gas_used, value.0.into()))

Won't Fix (Acknowledged)

These were flagged in the previous review and marked as won't-fix by the author — recording for visibility:

  • Mutex poisoning panics.expect() in hot path; low risk if fetcher doesn't panic, but worth revisiting if the callback ever becomes more complex.
  • serial_test not in workspace deps — Minor consistency issue. Worth a follow-up cleanup.
  • Global mutable state — Acceptable for the current single-threaded execution model.

Summary

Status Issue
✅ Fixed Silent l1_origin fallback
✅ Fixed Missing doc comments (3 functions)
✅ Fixed Missing tests for l1origin < anchor and missing l1_origin
⚠️ Open Mutex held across RPC call (acceptable in practice)
⚠️ Open factory.rs double-construction
💡 Minor Unnecessary output buffer copy
ℹ️ Won't Fix Mutex poisoning, serial_test workspace dep, global state

The implementation is in good shape. The two remaining open issues (factory.rs double-construction and mutex scope) are both low-risk and the logic is correct. The code is mergeable with those acknowledged.

Copy link
Copy Markdown

@Gohnnyman Gohnnyman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants