Skip to content

feat(l1+evm+precompiles): add L1-backed zone db#698

Open
0xrusowsky wants to merge 45 commits into
mainfrom
rus/l1-aware-db
Open

feat(l1+evm+precompiles): add L1-backed zone db#698
0xrusowsky wants to merge 45 commits into
mainfrom
rus/l1-aware-db

Conversation

@0xrusowsky

@0xrusowsky 0xrusowsky commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

replaces #626

High-level goal

before this PR stack, Zones had two representations of Tempo L1 state:

  1. A semantic PolicyCache / PolicyProvider, populated from typed TIP-403 and TIP-20 events with RPC fallback. Custom ZoneTip20Token and ZoneTip403ProxyRegistry implementations used it to reproduce upstream policy behavior.
  2. A generic block-versioned raw storage cache keyed by (address, slot), used by TempoState and Zone system contracts for portal storage reads.

this duplicated policy semantics and left two L1-state paths that had to remain aligned in block anchoring, event decoding, invalidation, RPC fallback, and upstream behavior. The goal is to remove the semantic policy implementation from the EVM path and execute upstream Tempo code against one factory-installed L1-aware database. The database wraps the normal caller-provided EVM database and:

  • lazily reads the Tempo L1 anchor from local TempoState
  • serves TIP-403 registry storage from L1 at that anchor
  • overlays only the L1-owned transferPolicyId field in TIP-20’s packed slot, preserving Zone-local fields
  • delegates ordinary account, code, block-hash, and storage reads to local Zone state
  • rejects TIP-403 writes and strips mirrored TIP-20 fields from canonical state transitions

from upstream Tempo's perspective these remain ordinary EVM storage operations, but selected slots are virtualized to finalized L1 state. This makes upstream Tempo code the source of execution semantics rather than maintaining Zone-specific policy implementations or installing a separate storage provider around each precompile call.

EVM Database

database.rs adds L1OverlayDB, a database adapter installed by ZoneEvmFactory for every EVM created for payload building, canonical import, RPC calls, tracing, transaction-pool validation, and maintenance execution. The factory uses the alloy-evm database-context extension so the internal Tempo context can execute over AnchoredZoneDb<DB> while ZoneEvm continues to expose and return the original caller-provided DB.

this keeps Reth’s state ownership and commit path unchanged. Reads pass through the adapter during execution, while successful state transitions are validated and sanitized before Reth commits them through the original database. Inner database failures preserve their original error type; anchor, L1-read, and mirrored-write failures are propagated as typed custom EVM errors.

the adapter and native TempoState precompile share one execution-local anchor controller. It tracks whether execution is still at the parent Tempo anchor or has advanced to its direct child, rejects reads at an inconsistent anchor, rejects parent-anchor reads before advancement, and rejects duplicate or non-contiguous advancement. Noncommitting payload simulations restore the controller snapshot so they cannot leak anchor state into included transaction execution.

until TIP-20 policy storage moves fully into TIP-403, the adapter retains compatibility with the current packed layout. It reads the local packed word first, replaces only the L1-owned transferPolicyId field for execution, and restores the local field before canonical transitions are committed. TIP-403 account or storage mutations fail closed.

Precompile Execution

execution.rs now has one execution path for Zone-native and upstream Tempo precompiles. Every wrapper receives the same EVM configuration, storage-action recorder, and non-creditable-slot set. Whether a storage read is local or L1-backed is entirely a database concern rather than a choice between local and L1-aware precompile providers.

CallRules contains only Zone-specific admission behavior:

  • delegate calls are denied by default and explicitly enabled only for precompiles that support them
  • fixed-gas selectors are checked before execution
  • pure selector/caller admission returns either Continue or ABI-encoded revert data
  • the common wrapper applies calldata gas, reservoir handling, and fixed-price output consistently
  • admitted calls preserve the original calldata and caller when forwarding upstream

TIP-20, TIP-403, and TipFeeManager now execute the upstream Tempo implementations directly over ordinary EvmPrecompileStorageProvider storage. The remaining Zone-specific behavior is limited to privacy checks, bridge mint/burn authorization, read-only TIP-403 selectors, fixed gas, and delegate-call policy.

this removes ZonePrecompileStorageProvider, the separate local/L1-backed execution orchestration, and the custom L1-aware ZoneFeeManager. Tempo’s ordinary protocol fee manager now observes the same anchored policy state through the EVM database adapter.

Follow-up work

@socket-security

socket-security Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatedalloy-primitives@​1.6.0 ⏵ 1.6.110010093100100
Updatedalloy-sol-types@​1.6.0 ⏵ 1.6.110010093100100

View full report

@0xrusowsky

Copy link
Copy Markdown
Contributor Author

cyclops audit

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

cc @0xrusowsky

Cyclops audit event published. View workflow run

Config: config: default, iterations: default, hours: default

Comment on lines +63 to +87
fn execute_inner(
&mut self,
execute: impl FnOnce(
&mut TempoEvm<AnchoredZoneDb<DB, L1>, I>,
) -> Result<TempoResult, AdaptedEvmError<DB::Error>>,
) -> Result<TempoResult, ZoneEvmError<DB::Error>> {
let snapshot = self.anchor_controller().phase();
let mut result = match execute(&mut self.inner) {
Ok(result) => result,
Err(error) => {
self.anchor_controller().restore(snapshot);
return Err(map_adapter_error(error));
}
};
if !result.result.is_success() {
self.anchor_controller().restore(snapshot);
return Ok(result);
}
if let Err(error) = self.inner.db().sanitize_state(&mut result.state) {
self.anchor_controller().restore(snapshot);
return Err(error.into_evm_error());
}
Ok(result)
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

can we not avoid this logic? we always have advanceTempo as first tx in the block so i think entire block's execution would use the same L1 state always?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

simplified state machine logic b62c49f (this PR)

Comment on lines +160 to +169
if let Some(account) = state.get(&TIP403_REGISTRY_ADDRESS) {
if account.info != account.original_info() {
return l1_write(TIP403_REGISTRY_ADDRESS, U256::ZERO);
}
for (slot, value) in &account.storage {
if value.is_changed() {
return l1_write(TIP403_REGISTRY_ADDRESS, *slot);
}
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

is this actually reachable given that we ban all mutating selectors? it would be kinda unfortunate if txs can execute all the way to here and only then get rejected

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

should be unreachable yeah, i only added it cause it feels like an invariant worth enforcing?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yeah seems like a reasonable defense in depth.

Comment thread crates/evm/src/executor.rs Outdated
Comment on lines +118 to +131
/// Restores execution-local L1 anchor state when a simulated transaction is not committed.
fn execute_transaction_with_commit_condition(
&mut self,
tx: impl ExecutableTx<Self>,
f: impl FnOnce(&Self::Result) -> CommitChanges,
) -> Result<Option<GasOutput>, BlockExecutionError> {
let snapshot = self.evm().anchor_controller().phase();
let output = self.execute_transaction_without_commit(tx)?;
if !f(&output).should_commit() {
self.evm().anchor_controller().restore(snapshot);
return Ok(None);
}
Ok(Some(self.commit_transaction(output)))
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

execute_transaction_without_commit should not advance any state either. it is expected that execute_transaction_without_commit might be called multiple times in a row with different transactions

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

addressed in b62c49f (this PR)

Comment thread crates/node/src/node.rs Outdated
@0xrusowsky
0xrusowsky requested review from 0xKitsune and klkvr July 20, 2026 18:50

@klkvr klkvr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm, pending @0xKitsune

Comment on lines +70 to +72
/// Per-address mutation barriers. A value at V cannot serve a read at N when a barrier exists
/// in `(V, N]`.
invalidations: HashMap<Address, BTreeSet<u64>>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

can we just clear the invalidated addresses from slots mapping instead of tracking an extra map?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@0xrusowsky thoughts?

@tempo-voight-kampff

Copy link
Copy Markdown

Hi @klkvr — your review approval was detected by Voight-Kampff but no live Voight-Kampff agent connection received it, so Voight-Kampff sent a push fallback. The push was not approved before the approval window closed. Your review did not count toward this PR.

You can also try approving the PR directly via CLI with

vk approve pr https://github.com/tempoxyz/zones/pull/698

klkvr
klkvr previously approved these changes Jul 20, 2026
@0xKitsune 0xKitsune added cyclops and removed cyclops labels Jul 21, 2026
@tempoxyz-bot

tempoxyz-bot commented Jul 21, 2026

Copy link
Copy Markdown

👁️ Cyclops Security Review

133e2bf

🧭 Auditing · mode=normal · workers 0/3 done (3 left) · verify pending 3

Worker Engine Latest Status Status
pr-698-w1 claude-opus-4-8 🔍 Iteration 3 · Audit Running
pr-698-w2 gpt-5.5 🚨 Iteration 3 · Verify Running
pr-698-w3 gpt-5.6-sol 🔍 Iteration 3 · Audit Running

Findings

# Finding Severity Status
1 Portal L1 storage cache can inherit stale values across ZonePortal mutations High Iteration 1 · Verify
2 Transient L1 RPC failures during precompile policy reads abort block building instead of skipping the pool transaction High Iteration 1 · Verify
3 Reward view selectors bypass Zone TIP-20 account privacy High Iteration 1 · Verify
4 Precompile-originated L1 storage failures bypass pool-tx skip handling Medium Iteration 2 · Verify
5 L1-owned-slot write rejection is a block-fatal error (latent permissionless chain-halt) Low Iteration 2 · Audit
6 Cyclops Invariant Audit — PR #698 Unknown Iteration 3 · Verify
7 Historical eth_call can pin unlimited synchronous L1 RPC retries High Iteration 2 · Verify
⚙️ Controls
  • 🚀 Keep only 1 remaining iteration per worker after the current work finishes.
  • 👀 Keep only 2 remaining iterations per worker after the current work finishes.
  • ❤️ Let only worker 1 continue; other workers skip queued iterations.
  • 😄 Let only worker 2 continue; other workers skip queued iterations.
  • 🎉 End faster by skipping queued iterations and moving toward consolidation.
  • 😕 Stop active workers/verifiers now and start consolidation immediately.

📜 33 events

🔍 pr-698-w1 iter 1/3 [audit-general.md]
🔍 pr-698-w2 iter 1/3 [audit-ripple.md]
🔍 pr-698-w3 iter 1/3 [audit-invariants.md]
🚨 pr-698-w2 iter 1 — finding
🚨 Finding: Portal L1 storage cache can inherit stale values across ZonePortal mutations (High)
🔍 pr-698-w2 iter 2/3 [audit-ripple.md]
🔬 Verifying: Portal L1 storage cache can inherit stale values across ZonePortal mutations
🚨 pr-698-w1 iter 1 — finding
🚨 Finding: Transient L1 RPC failures during precompile policy reads abort block building instead of skipping the pool transaction (High)
🔍 pr-698-w1 iter 2/3 [audit-invariants.md]
🔬 Verifying: Transient L1 RPC failures during precompile policy reads abort block building instead of skipping the pool transaction
📋 Verify: Portal L1 storage cache can inherit stale values across ZonePortal mutations → ✅ Verified
🚨 pr-698-w3 iter 1 — finding
🚨 Finding: Reward view selectors bypass Zone TIP-20 account privacy (High)
🔍 pr-698-w3 iter 2/3 [audit-ripple.md]
🔬 Verifying: Reward view selectors bypass Zone TIP-20 account privacy
📋 Verify: Reward view selectors bypass Zone TIP-20 account privacy → ⏩ Dup
📋 Verify: Transient L1 RPC failures during precompile policy reads abort block building instead of skipping the pool transaction → ✅ Verified
🚨 pr-698-w2 iter 2 — finding
🚨 Finding: Precompile-originated L1 storage failures bypass pool-tx skip handling (Medium)
🔍 pr-698-w2 iter 3/3 [audit-invariants.md]
🔬 Verifying: Precompile-originated L1 storage failures bypass pool-tx skip handling
📋 Verify: Precompile-originated L1 storage failures bypass pool-tx skip handling → ⏩ Dup
💡 pr-698-w1 iter 2 — suggestion
🚨 Finding: L1-owned-slot write rejection is a block-fatal error (latent permissionless chain-halt) (Low)
🔍 pr-698-w1 iter 3/3 [audit-invariants.md]
🚨 pr-698-w2 iter 3 — finding
🚨 Finding: Cyclops Invariant Audit — PR #698 (Unknown)
🔬 Verifying: Cyclops Invariant Audit — PR #698
🚨 pr-698-w3 iter 2 — finding
🚨 Finding: Historical eth_call can pin unlimited synchronous L1 RPC retries (High)
🔍 pr-698-w3 iter 3/3 [audit-ripple.md]
🔬 Verifying: Historical eth_call can pin unlimited synchronous L1 RPC retries

.max_sync_attempts
.is_some_and(|max_attempts| attempt >= max_attempts)
{
return Err(eyre::eyre!(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

should add metric for grafana?

Comment thread crates/node/src/node.rs
Comment on lines +302 to +307
sync_attempts: u32,
) -> Self {
assert!(
sync_attempts > 0,
"at least one synchronous attempt is required"
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nonzero?

Comment on lines +67 to +69
fn execute_inner(
&mut self,
execute: impl FnOnce(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: can we add doc comments on this function?

}

/// Clears database-adapter bookkeeping left by the current transaction attempt.
pub(crate) fn reset_transaction_state(&mut self) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: consider renaming to clear_l1_anchor or similar for clarity as this is only clearing the db adapter state.

L1: L1StorageReader,
I: Inspector<TempoCtx<AnchoredZoneDb<DB, L1>>>,
{
fn execute_inner(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: consider renaming to execute_and_sanitize or similar for clarity. Right now this is a bit generic.

Comment on lines +70 to +72
/// Per-address mutation barriers. A value at V cannot serve a read at N when a barrier exists
/// in `(V, N]`.
invalidations: HashMap<Address, BTreeSet<u64>>,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@0xrusowsky thoughts?

Comment thread crates/node/src/node.rs
let policy_provider = PolicyProvider::new(self.policy_cache, policy_l1, runtime_handle);
evm_config = evm_config.with_policy_provider(policy_provider);
info!(target: "reth::cli", "Zone EVM initialized with TempoState + TIP-403 proxy precompiles");
let evm_config = ZoneEvmConfig::new(ctx.chain_spec(), tempo_chain_spec, l1_provider);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Block execution now uses the anchored L1 overlay, but pool construction still replaces this with a plain TempoEvmConfig at line 1078. When anchored L1 policy differs from Zone-local state, eth_sendRawTransaction can reject a transaction with PolicyForbids even though block execution would accept it.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants