diff --git a/Cargo.lock b/Cargo.lock index 84ba40655..5bd2c5726 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -327,8 +327,7 @@ dependencies = [ [[package]] name = "alloy-evm" version = "0.37.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acde665074b478c97047dc2a461b4916cac77922d690e5b7415d1941ae7ff7bf" +source = "git+https://github.com/alloy-rs/evm?rev=ce6b773ab958548e37fe195b21412a2a1638706e#ce6b773ab958548e37fe195b21412a2a1638706e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -375,9 +374,9 @@ dependencies = [ [[package]] name = "alloy-json-abi" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c36c9d7f9021601b04bfef14a4b64849f6d73116a4e91e071d7fbfe10247901" +checksum = "6cee30dd4c2f4b23f434fdf675e7bf9681b86768141277266c6f548ef25cba0a" dependencies = [ "alloy-primitives", "alloy-sol-type-parser", @@ -441,9 +440,9 @@ dependencies = [ [[package]] name = "alloy-primitives" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4885c1409b6936c4898e646ef58baf6ec54edaf6d8179f79df805a7b85b7cf3e" +checksum = "f007e257069855bdf21d27762fd3f3705a613f805c9a08309bf353503f081d71" dependencies = [ "alloy-rlp", "arbitrary", @@ -451,6 +450,7 @@ dependencies = [ "cfg-if", "const-hex", "derive_more", + "fixed-cache", "foldhash", "getrandom 0.4.3", "hashbrown 0.17.1", @@ -806,13 +806,13 @@ dependencies = [ [[package]] name = "alloy-sol-macro" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "840128ed2b2971d6d4668a553fe403a82683d3acc646c73e75887e7157408033" +checksum = "b5655c38d5f84955bf727b2eeb62fddd91ebb98fd1d7ae6eb77f73ea88f9b9cf" dependencies = [ "alloy-sol-macro-expander", "alloy-sol-macro-input", - "proc-macro-error2", + "proc-macro-error3", "proc-macro2", "quote", "syn 2.0.119", @@ -820,16 +820,16 @@ dependencies = [ [[package]] name = "alloy-sol-macro-expander" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63ec265e5d65d725175f6ca7711c970824c90ef9c0d1f1973711d4150ee612dd" +checksum = "6277c780e07b76951e09a59788dde230d1582612324177d11a43a61e21a6bb83" dependencies = [ "alloy-json-abi", "alloy-sol-macro-input", "const-hex", "heck", "indexmap 2.14.0", - "proc-macro-error2", + "proc-macro-error3", "proc-macro2", "quote", "sha3 0.11.0", @@ -839,9 +839,9 @@ dependencies = [ [[package]] name = "alloy-sol-macro-input" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89bf01077f18650876cfa682eb1f949967b5cde03f1a51c955c469d2c9b4aa67" +checksum = "9762b2ad3e5a0c09886de54fe549ab0056681df843cb082e2df7e1c0eb270d30" dependencies = [ "alloy-json-abi", "const-hex", @@ -857,9 +857,9 @@ dependencies = [ [[package]] name = "alloy-sol-type-parser" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "857b470ecdd2ed38beaf82ad1a38c516a8ff75266750f38b9eeed001d575241b" +checksum = "da4c7130f0f01f4719678bda3db3bc7267fc2f7f9d0565e3bd964cd2bb45050d" dependencies = [ "serde", "winnow 1.0.4", @@ -867,9 +867,9 @@ dependencies = [ [[package]] name = "alloy-sol-types" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384cf252de0db2dec52821eac037a7f57e2aa33fe5b900ce6fe39973402341f1" +checksum = "d96e74d6213180f78dbdccddce8af02a639c160c94b0a543fa35c77c58b8a7fc" dependencies = [ "alloy-json-abi", "alloy-primitives", @@ -6551,6 +6551,16 @@ dependencies = [ "quote", ] +[[package]] +name = "proc-macro-error-attr3" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82366fd7d8b7a440d66d13418820c69df9b3908bcb1a0476d7f5ce5d12f5a04d" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "proc-macro-error2" version = "2.0.1" @@ -6560,6 +6570,17 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b511283ea8a74b4b39447b128c5d00f03a356b7424554b13e298a5550100d9ac" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", "syn 2.0.119", ] @@ -11018,9 +11039,9 @@ dependencies = [ [[package]] name = "syn-solidity" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec005042c7d952febc1a3ef5b0f6674e9054aa836877a31c90b20e25b3d31744" +checksum = "083be3061e64d362cbe6ef12cfe1307ba3884326d8856448fe8a120fa2c44ebf" dependencies = [ "paste", "proc-macro2", @@ -11136,7 +11157,7 @@ dependencies = [ [[package]] name = "tempo-alloy" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy-consensus", "alloy-contract", @@ -11175,7 +11196,7 @@ dependencies = [ [[package]] name = "tempo-chainspec" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy-eips", "alloy-evm", @@ -11199,7 +11220,7 @@ dependencies = [ [[package]] name = "tempo-contracts" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy-contract", "alloy-primitives", @@ -11211,7 +11232,7 @@ dependencies = [ [[package]] name = "tempo-dkg-onchain-artifacts" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "bytes", "commonware-codec", @@ -11223,7 +11244,7 @@ dependencies = [ [[package]] name = "tempo-evm" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy-consensus", "alloy-evm", @@ -11258,7 +11279,7 @@ dependencies = [ [[package]] name = "tempo-hardfork" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy-eips", "alloy-evm", @@ -11270,7 +11291,7 @@ dependencies = [ [[package]] name = "tempo-node" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy", "alloy-eips", @@ -11286,6 +11307,7 @@ dependencies = [ "futures", "jiff", "jsonrpsee", + "metrics", "reqwest 0.13.4", "reth-chainspec", "reth-errors", @@ -11309,6 +11331,7 @@ dependencies = [ "reth-transaction-pool", "serde", "serde_json", + "sysinfo 0.38.4", "tempo-alloy", "tempo-chainspec", "tempo-evm", @@ -11328,7 +11351,7 @@ dependencies = [ [[package]] name = "tempo-payload-builder" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy-consensus", "alloy-eip7928", @@ -11366,7 +11389,7 @@ dependencies = [ [[package]] name = "tempo-payload-types" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy-eips", "alloy-primitives", @@ -11386,7 +11409,7 @@ dependencies = [ [[package]] name = "tempo-precompiles" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy", "alloy-evm", @@ -11409,7 +11432,7 @@ dependencies = [ [[package]] name = "tempo-precompiles-macros" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy", "proc-macro2", @@ -11420,7 +11443,7 @@ dependencies = [ [[package]] name = "tempo-primitives" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy-consensus", "alloy-eips", @@ -11452,7 +11475,7 @@ dependencies = [ [[package]] name = "tempo-revm" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy-consensus", "alloy-evm", @@ -11475,7 +11498,7 @@ dependencies = [ [[package]] name = "tempo-transaction-pool" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy-consensus", "alloy-eips", @@ -13411,6 +13434,7 @@ dependencies = [ "tempo-primitives", "tempo-revm", "tempo-zone-contracts", + "thiserror 2.0.18", "tokio", "tracing", "zone-chainspec", @@ -13618,6 +13642,7 @@ dependencies = [ "alloy-sol-types", "const-hex", "derive_more", + "eyre", "k256", "paste", "rand 0.8.7", diff --git a/Cargo.toml b/Cargo.toml index d7ecad336..747da348e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,25 +94,25 @@ incremental = false [workspace.dependencies] # tempo (from upstream) -tempo-alloy = { git = "https://github.com/tempoxyz/tempo", rev = "436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" } -tempo-chainspec = { git = "https://github.com/tempoxyz/tempo", rev = "436f6cf069a0c8aafa9a3a197ffa17488b5f1e81", default-features = false } -tempo-consensus = { git = "https://github.com/tempoxyz/tempo", rev = "436f6cf069a0c8aafa9a3a197ffa17488b5f1e81", default-features = false } -tempo-contracts = { git = "https://github.com/tempoxyz/tempo", rev = "436f6cf069a0c8aafa9a3a197ffa17488b5f1e81", default-features = false, features = [ +tempo-alloy = { git = "https://github.com/tempoxyz/tempo", rev = "d1507111c1437fe352f6ad74b6d2035f878a5714" } +tempo-chainspec = { git = "https://github.com/tempoxyz/tempo", rev = "d1507111c1437fe352f6ad74b6d2035f878a5714", default-features = false } +tempo-consensus = { git = "https://github.com/tempoxyz/tempo", rev = "d1507111c1437fe352f6ad74b6d2035f878a5714", default-features = false } +tempo-contracts = { git = "https://github.com/tempoxyz/tempo", rev = "d1507111c1437fe352f6ad74b6d2035f878a5714", default-features = false, features = [ "serde", ] } -tempo-evm = { git = "https://github.com/tempoxyz/tempo", rev = "436f6cf069a0c8aafa9a3a197ffa17488b5f1e81", default-features = false } -tempo-node = { git = "https://github.com/tempoxyz/tempo", rev = "436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" } -tempo-payload-types = { git = "https://github.com/tempoxyz/tempo", rev = "436f6cf069a0c8aafa9a3a197ffa17488b5f1e81", default-features = false } -tempo-precompiles = { git = "https://github.com/tempoxyz/tempo", rev = "436f6cf069a0c8aafa9a3a197ffa17488b5f1e81", default-features = false } -tempo-precompiles-macros = { git = "https://github.com/tempoxyz/tempo", rev = "436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" } -tempo-primitives = { git = "https://github.com/tempoxyz/tempo", rev = "436f6cf069a0c8aafa9a3a197ffa17488b5f1e81", default-features = false, features = [ +tempo-evm = { git = "https://github.com/tempoxyz/tempo", rev = "d1507111c1437fe352f6ad74b6d2035f878a5714", default-features = false } +tempo-node = { git = "https://github.com/tempoxyz/tempo", rev = "d1507111c1437fe352f6ad74b6d2035f878a5714" } +tempo-payload-types = { git = "https://github.com/tempoxyz/tempo", rev = "d1507111c1437fe352f6ad74b6d2035f878a5714", default-features = false } +tempo-precompiles = { git = "https://github.com/tempoxyz/tempo", rev = "d1507111c1437fe352f6ad74b6d2035f878a5714", default-features = false } +tempo-precompiles-macros = { git = "https://github.com/tempoxyz/tempo", rev = "d1507111c1437fe352f6ad74b6d2035f878a5714" } +tempo-primitives = { git = "https://github.com/tempoxyz/tempo", rev = "d1507111c1437fe352f6ad74b6d2035f878a5714", default-features = false, features = [ "reth", "serde", "serde-bincode-compat", "reth-codec", ] } -tempo-revm = { git = "https://github.com/tempoxyz/tempo", rev = "436f6cf069a0c8aafa9a3a197ffa17488b5f1e81", default-features = false } -tempo-transaction-pool = { git = "https://github.com/tempoxyz/tempo", rev = "436f6cf069a0c8aafa9a3a197ffa17488b5f1e81", default-features = false } +tempo-revm = { git = "https://github.com/tempoxyz/tempo", rev = "d1507111c1437fe352f6ad74b6d2035f878a5714", default-features = false } +tempo-transaction-pool = { git = "https://github.com/tempoxyz/tempo", rev = "d1507111c1437fe352f6ad74b6d2035f878a5714", default-features = false } # zones tempo-zone-contracts = { path = "crates/contracts", default-features = false } @@ -231,6 +231,7 @@ tracing = "0.1.41" # Keep Commonware aligned with the revision used by upstream Tempo. Cargo does # not inherit a git dependency's `[patch]` section into this workspace. [patch.crates-io] +alloy-evm = { git = "https://github.com/alloy-rs/evm", rev = "ce6b773ab958548e37fe195b21412a2a1638706e" } commonware-broadcast = { git = "https://github.com/commonwarexyz/monorepo", rev = "2a7dd423f0a241276a5a38db8cc3d05f11de0c03" } commonware-codec = { git = "https://github.com/commonwarexyz/monorepo", rev = "2a7dd423f0a241276a5a38db8cc3d05f11de0c03" } commonware-consensus = { git = "https://github.com/commonwarexyz/monorepo", rev = "2a7dd423f0a241276a5a38db8cc3d05f11de0c03" } diff --git a/crates/evm/Cargo.toml b/crates/evm/Cargo.toml index c9b524a77..5b63b8e23 100644 --- a/crates/evm/Cargo.toml +++ b/crates/evm/Cargo.toml @@ -44,9 +44,11 @@ alloy-sol-types.workspace = true revm.workspace = true # misc +thiserror.workspace = true tokio.workspace = true tracing.workspace = true [dev-dependencies] eyre.workspace = true +zone-precompiles = { workspace = true, features = ["std", "test-utils"] } tempo-precompiles = { workspace = true, features = ["test-utils"] } diff --git a/crates/evm/src/database.rs b/crates/evm/src/database.rs new file mode 100644 index 000000000..3033c930e --- /dev/null +++ b/crates/evm/src/database.rs @@ -0,0 +1,366 @@ +//! Execution-local database adapter that overlays finalized Tempo L1 reads while preserving +//! the caller-provided Zone database as the sole canonical state backend. + +use std::{collections::HashMap, fmt}; + +use alloy_evm::Database; +use alloy_primitives::{Address, B256, U256}; +use revm::{ + context::{ + DBErrorMarker, + result::{AnyError, EVMError}, + }, + database_interface::Database as RevmDatabase, + precompile::PrecompileError, + primitives::{AddressMap, StorageKey, StorageValue}, + state::{Account, AccountInfo, Bytecode}, +}; +use thiserror::Error; +use zone_precompiles::{ + TIP403_REGISTRY_ADDRESS, + storage::{self, L1AnchorController, L1AnchorError, L1StorageReader}, + tempo_state::TEMPO_BLOCK_NUMBER_SLOT, +}; +use zone_primitives::constants::TEMPO_STATE_ADDRESS; + +/// Database error produced by [`AnchoredZoneDb`]. +#[derive(Debug, Error)] +pub enum AnchoredZoneDbError { + /// Error from the caller-provided database. + #[error("inner database error: {0}")] + Inner(#[source] E), + /// The selected Zone state contains an invalid Tempo anchor. + #[error("invalid Tempo anchor (does not fit in u64): {0}")] + AnchorOverflow(U256), + /// The execution-local anchor transition is inconsistent. + #[error(transparent)] + AnchorTransition(#[from] L1AnchorError), + /// Exact anchored L1 storage was unavailable. + #[error("Tempo L1 storage unavailable address={address} slot={slot} block={anchor}: {source}")] + L1Read { + address: Address, + slot: B256, + anchor: u64, + #[source] + source: PrecompileError, + }, + /// A transaction attempted to persist mirrored Tempo-owned state. + #[error("write to mirrored Tempo storage address={address} slot={slot}")] + L1Write { address: Address, slot: U256 }, +} + +impl DBErrorMarker for AnchoredZoneDbError {} + +impl AnchoredZoneDbError { + pub(crate) fn into_evm_error(self) -> EVMError { + match self { + Self::Inner(error) => EVMError::Database(error), + error => EVMError::CustomAny(AnyError::new(error)), + } + } +} + +// TODO(rusowsky): remove once TIP-1092 is implemented +#[derive(Debug, Clone, Copy)] +struct PackedPolicySlot { + local: U256, + l1: U256, +} + +/// Resolves mirrored L1 reads at the active Tempo anchor and forwards all other database +/// operations to the caller-provided Zone database. +pub struct AnchoredZoneDb { + inner: DB, + l1: L1, + controller: L1AnchorController, + // TODO(rusowsky): remove once TIP-1092 is implemented + packed_policy_slots: HashMap<(Address, U256), PackedPolicySlot>, +} + +impl fmt::Debug for AnchoredZoneDb { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AnchoredZoneDb") + .field("inner", &self.inner) + .field("controller", &self.controller) + .finish_non_exhaustive() + } +} + +impl AnchoredZoneDb { + /// Creates an adapter around the caller-provided database. + pub fn new(inner: DB, l1: L1) -> Self { + Self { + inner, + l1, + controller: L1AnchorController::default(), + packed_policy_slots: HashMap::new(), + } + } + + /// Returns the original caller-provided database. + pub const fn inner(&self) -> &DB { + &self.inner + } + + /// Returns the original caller-provided database mutably. + pub const fn inner_mut(&mut self) -> &mut DB { + &mut self.inner + } + + /// Recovers the original caller-provided database. + pub fn into_inner(self) -> DB { + self.inner + } + + /// Returns the execution-local anchor controller. + pub const fn controller(&self) -> &L1AnchorController { + &self.controller + } + + /// Clears bookkeeping that is valid only for the current transaction attempt. + pub(crate) fn reset_transaction_state(&mut self) { + self.controller.reset(); + self.packed_policy_slots.clear(); + } +} + +impl AnchoredZoneDb { + fn anchor(&mut self) -> Result> { + if let Some(anchor) = self.controller.current() { + return Ok(anchor); + } + + let value = self + .inner + .storage(TEMPO_STATE_ADDRESS, TEMPO_BLOCK_NUMBER_SLOT) + .map_err(AnchoredZoneDbError::Inner)?; + let anchor = + u64::try_from(value).map_err(|_| AnchoredZoneDbError::AnchorOverflow(value))?; + Ok(anchor) + } + + fn l1_storage( + &self, + address: Address, + slot: U256, + anchor: u64, + ) -> Result> { + self.l1 + .read_l1_storage(address, B256::from(slot), anchor) + .map(|value| value.into()) + .map_err(|source| AnchoredZoneDbError::L1Read { + address, + slot: slot.into(), + anchor, + source, + }) + } + + /// Rejects writes to the L1-mirrored slots and removes the mirrored field from packed TIP-20 transitions. + pub fn sanitize_state( + &self, + state: &mut AddressMap, + ) -> Result<(), AnchoredZoneDbError> { + let l1_write = |address, slot| Err(AnchoredZoneDbError::L1Write { address, slot }); + + 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); + } + } + } + + // TODO(rusowsky): remove once TIP-1092 is implemented + for ((address, slot), observed) in &self.packed_policy_slots { + let Some(value) = state + .get_mut(address) + .and_then(|account| account.storage.get_mut(slot)) + else { + continue; + }; + if storage::merge_transfer_policy_id(U256::ZERO, value.present_value) + != storage::merge_transfer_policy_id(U256::ZERO, observed.l1) + { + return l1_write(*address, *slot); + } + + value.original_value = + storage::merge_transfer_policy_id(value.original_value, observed.local); + value.present_value = + storage::merge_transfer_policy_id(value.present_value, observed.local); + } + Ok(()) + } +} + +impl RevmDatabase for AnchoredZoneDb { + type Error = AnchoredZoneDbError; + + fn basic(&mut self, address: Address) -> Result, Self::Error> { + self.inner + .basic(address) + .map_err(AnchoredZoneDbError::Inner) + } + + fn code_by_hash(&mut self, code_hash: B256) -> Result { + self.inner + .code_by_hash(code_hash) + .map_err(AnchoredZoneDbError::Inner) + } + + fn storage(&mut self, address: Address, slot: StorageKey) -> Result { + let local = self + .inner + .storage(address, slot) + .map_err(AnchoredZoneDbError::Inner)?; + if address != TIP403_REGISTRY_ADDRESS && !storage::is_tip20_policy_id_slot(address, slot) { + return Ok(local); + } + + let anchor = self.anchor()?; + let l1 = self.l1_storage(address, slot, anchor)?; + self.controller + .observe_read(anchor) + .map_err(AnchoredZoneDbError::AnchorTransition)?; + + if storage::is_tip20_policy_id_slot(address, slot) { + self.packed_policy_slots + .insert((address, slot), PackedPolicySlot { local, l1 }); + Ok(storage::merge_transfer_policy_id(local, l1)) + } else { + Ok(l1) + } + } + + fn block_hash(&mut self, number: u64) -> Result { + self.inner + .block_hash(number) + .map_err(AnchoredZoneDbError::Inner) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::TestL1; + use revm::{ + database::{CacheDB, EmptyDB}, + state::EvmStorageSlot, + }; + use tempo_precompiles::{PATH_USD_ADDRESS, tip20::tip20_slots}; + + fn test_db(anchor: u64) -> CacheDB { + let mut db = CacheDB::new(EmptyDB::default()); + db.insert_account_storage( + TEMPO_STATE_ADDRESS, + TEMPO_BLOCK_NUMBER_SLOT, + U256::from(anchor), + ) + .unwrap(); + db + } + + #[test] + fn overlays_registry_at_selected_state_anchor() { + let anchor = 42; + let slot = U256::from(7); + let expected = U256::from(99); + let l1 = TestL1::default(); + l1.insert(TIP403_REGISTRY_ADDRESS, slot, anchor, expected); + let mut db = AnchoredZoneDb::new(test_db(anchor), l1); + + assert_eq!(db.storage(TIP403_REGISTRY_ADDRESS, slot).unwrap(), expected); + assert_eq!(db.controller().current(), Some(anchor)); + } + + #[test] + fn l1_failures_and_read_before_advance_fail_closed() { + let anchor = 42; + let slot = U256::from(7); + let mut failing = AnchoredZoneDb::new(test_db(anchor), TestL1::failing_storage()); + assert!(matches!( + failing.storage(TIP403_REGISTRY_ADDRESS, slot), + Err(AnchoredZoneDbError::L1Read { anchor: 42, .. }) + )); + + let reader = TestL1::default(); + reader.insert(TIP403_REGISTRY_ADDRESS, slot, anchor, U256::ONE); + let mut db = AnchoredZoneDb::new(test_db(anchor), reader.clone()); + let controller = db.controller().clone(); + assert_eq!( + db.storage(TIP403_REGISTRY_ADDRESS, slot).unwrap(), + U256::ONE + ); + assert!(controller.begin_advance(anchor, anchor + 1).is_err()); + assert_eq!(reader.storage_requests().len(), 1); + } + + #[test] + fn packed_policy_field_is_removed_from_canonical_transition() { + let (anchor, token, slot) = (42, PATH_USD_ADDRESS, tip20_slots::TRANSFER_POLICY_ID); + let offset = tempo_precompiles::tip20::tip20_slots::TRANSFER_POLICY_ID_OFFSET * 8; + let local = storage::merge_transfer_policy_id(U256::from(10), U256::from(2) << offset); + let l1_value = U256::from(99) << offset; + let l1 = TestL1::default(); + l1.insert(token, slot, anchor, l1_value); + let mut inner = test_db(anchor); + inner.insert_account_storage(token, slot, local).unwrap(); + let mut db = AnchoredZoneDb::new(inner, l1); + let observed = db.storage(token, slot).unwrap(); + + let mut state = AddressMap::default(); + let mut account = Account::default(); + account.storage.insert( + slot, + EvmStorageSlot { + original_value: observed, + present_value: observed + U256::ONE, + ..Default::default() + }, + ); + state.insert(token, account); + db.sanitize_state(&mut state).unwrap(); + + let sanitized = state[&token].storage[&slot].present_value; + assert_eq!( + storage::merge_transfer_policy_id(U256::ZERO, sanitized), + storage::merge_transfer_policy_id(U256::ZERO, local) + ); + assert_eq!(sanitized & U256::from(u64::MAX), U256::from(11)); + } + + #[test] + fn transaction_reset_clears_anchor_and_packed_slot_observations() { + let (anchor, token, slot) = (42, PATH_USD_ADDRESS, tip20_slots::TRANSFER_POLICY_ID); + let l1 = TestL1::default(); + l1.insert(token, slot, anchor, U256::from(7)); + let mut db = AnchoredZoneDb::new(test_db(anchor), l1); + + db.storage(token, slot).unwrap(); + assert_eq!(db.controller().current(), Some(anchor)); + assert_eq!(db.packed_policy_slots.len(), 1); + + db.reset_transaction_state(); + + assert_eq!(db.controller().current(), None); + assert!(db.packed_policy_slots.is_empty()); + } + + #[test] + fn ordinary_storage_is_local_and_inner_database_is_recoverable() { + let address = Address::repeat_byte(0x11); + let slot = U256::from(3); + let value = U256::from(5); + let mut inner = test_db(1); + inner.insert_account_storage(address, slot, value).unwrap(); + let mut db = AnchoredZoneDb::new(inner, TestL1::default()); + + assert_eq!(db.storage(address, slot).unwrap(), value); + let mut inner: CacheDB = db.into_inner(); + assert_eq!(inner.storage(address, slot).unwrap(), value); + } +} diff --git a/crates/evm/src/executor.rs b/crates/evm/src/executor.rs index c5304540a..4a852d075 100644 --- a/crates/evm/src/executor.rs +++ b/crates/evm/src/executor.rs @@ -1,7 +1,7 @@ //! Zone block executor. //! //! A simplified block executor for zone nodes that wraps [`EthBlockExecutor`] directly. -//! Unlike the Tempo L1 [`TempoBlockExecutor`], this executor does **not** enforce subblock +//! Unlike the Tempo L1 `TempoBlockExecutor`, this executor does **not** enforce subblock //! ordering, shared-gas accounting, or the end-of-block subblock metadata system transaction. use alloy_consensus::transaction::TxHashRef; @@ -20,8 +20,9 @@ use tempo_precompiles::{ use tempo_primitives::{TempoReceipt, TempoTxEnvelope, TempoTxType}; use tempo_revm::{TempoStateAccess, evm::TempoContext}; use zone_chainspec::ZoneChainSpec; +use zone_l1::state::L1StateProvider; -use crate::{ZoneEvm, tx_context}; +use crate::{AnchoredZoneDb, ZoneEvm, tx_context}; /// Simplified block executor for zone nodes. /// @@ -34,7 +35,7 @@ pub struct ZoneBlockExecutor<'a, DB: Database, I> { impl<'a, DB, I> ZoneBlockExecutor<'a, DB, I> where DB: StateDB, - I: Inspector>, + I: Inspector>>, { /// Create a zone block executor for `evm` and the current block context. pub fn new( @@ -82,7 +83,7 @@ where impl<'a, DB, I> BlockExecutor for ZoneBlockExecutor<'a, DB, I> where DB: StateDB, - I: Inspector>, + I: Inspector>>, { type Transaction = TempoTxEnvelope; type Receipt = TempoReceipt; @@ -99,13 +100,20 @@ where ) -> Result { let (tx_env, recovered) = tx.into_parts(); - // Override the validator's fee token preference to match this - // transaction's resolved fee token, so the handler skips FeeAMM. - self.override_validator_token(); + // System txs do not pay protocol fees. Resolving their validator token here could read L1 + // policy state at N before `advanceTempo` moves the controller to N+1, causing the anchor + // advancement to fail. Thus, FeeAMM override is restricted to regular fee-paying txs. + if !tx_env.is_system_tx { + self.override_validator_token(); + } let _tx_hash_guard = tx_context::set_current_tx_hash(*recovered.tx().tx_hash()); - self.inner - .execute_transaction_without_commit((tx_env, recovered)) + let result = self + .inner + .execute_transaction_without_commit((tx_env, recovered)); + + self.evm_mut().reset_transaction_state(); + result } fn commit_transaction(&mut self, output: Self::Result) -> GasOutput { diff --git a/crates/evm/src/lib.rs b/crates/evm/src/lib.rs index 7f4e1fe7c..659a4bdf7 100644 --- a/crates/evm/src/lib.rs +++ b/crates/evm/src/lib.rs @@ -1,26 +1,26 @@ //! Zone-specific EVM configuration. //! -//! Wraps [`TempoEvmConfig`] with a custom [`ZoneEvmFactory`] that registers -//! zone-specific native precompiles. +//! Wraps [`TempoEvmConfig`] with a [`ZoneEvmFactory`] that installs the L1-anchored database, +//! registers Zone-native precompiles, and preserves the original database at the [`Evm`] boundary. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![cfg_attr(docsrs, feature(doc_cfg))] #![allow(unnameable_types)] +mod database; mod executor; pub mod precompiles; +#[cfg(test)] +mod test_utils; mod tx_context; mod zone_evm; +pub use database::{AnchoredZoneDb, AnchoredZoneDbError}; pub use executor::ZoneBlockExecutor; pub use zone_evm::{ZoneEvm, contract_creation::validate_transaction}; use crate::{ - precompiles::{ - AES_GCM_DECRYPT_ADDRESS, AesGcmDecrypt, CHAUM_PEDERSEN_VERIFY_ADDRESS, ChaumPedersenVerify, - L1StorageReader, SequencerExt, TempoState, ZONE_TIP20_FACTORY_ADDRESS, - ZONE_TIP403_PROXY_ADDRESS, ZoneTip20Token, ZoneTip403ProxyRegistry, ZoneTokenFactory, - }, + precompiles::{L1AnchorController, L1StorageReader, SequencerExt, extend_zone_precompiles}, tx_context::ZoneTxContext, }; use alloy_evm::{ @@ -47,27 +47,20 @@ use tempo_evm::{ evm::{TempoEvm, TempoEvmFactory}, }; use tempo_payload_types::TempoExecutionData; -use tempo_precompiles::{ - ACCOUNT_KEYCHAIN_ADDRESS, NONCE_PRECOMPILE_ADDRESS, PrecompileEnv, STABLECOIN_DEX_ADDRESS, - TIP_FEE_MANAGER_ADDRESS, account_keychain::AccountKeychain, nonce::NonceManager, - storage::actions::StorageActions, storage_credits::NonCreditableSlots, - tip_fee_manager::TipFeeManager, tip20::is_tip20_prefix, -}; +use tempo_precompiles::{storage::actions::StorageActions, storage_credits::NonCreditableSlots}; use tempo_primitives::{ Block, TempoHeader, TempoPrimitives, TempoReceipt, TempoTxEnvelope, TempoTxType, }; -use tempo_zone_contracts::{TEMPO_STATE_ADDRESS, ZONE_TX_CONTEXT_ADDRESS}; +use tempo_zone_contracts::ZONE_TX_CONTEXT_ADDRESS; use zone_chainspec::ZoneChainSpec; -use zone_l1::state::{L1StateCache, L1StateProvider, L1StateProviderConfig, PolicyProvider}; +use zone_l1::state::{L1StateCache, L1StateProvider, L1StateProviderConfig}; type TempoCtx = ::Context; -/// Zone EVM factory — wraps [`TempoEvmFactory`] and registers the -/// zone-native precompiles. +/// Zone EVM factory that adapts caller databases and registers the zone-native precompiles. #[derive(Debug, Clone)] pub struct ZoneEvmFactory { l1_reader: L1, - policy_provider: Option, } impl ZoneEvmFactory @@ -76,85 +69,27 @@ where { /// Create a new factory with the given L1 state reader. pub fn new(l1_reader: L1) -> Self { - Self { - l1_reader, - policy_provider: None, - } + Self { l1_reader } } - /// Set the policy provider for the TIP-403 proxy precompile. - pub fn with_policy_provider(mut self, policy_provider: PolicyProvider) -> Self { - self.policy_provider = Some(policy_provider); - self - } - - fn register_precompiles>>( + fn register_precompiles>>>( &self, - mut evm: TempoEvm, - ) -> TempoEvm { + mut evm: TempoEvm, I>, + controller: L1AnchorController, + ) -> TempoEvm, I> { let cfg = evm.ctx().cfg.clone(); let (_, _, precompiles) = evm.components_mut(); - precompiles.apply_precompile(&TEMPO_STATE_ADDRESS, |_| { - Some(TempoState::create(self.l1_reader.clone(), &cfg)) - }); - precompiles.apply_precompile(&ZONE_TX_CONTEXT_ADDRESS, |_| Some(ZoneTxContext::create())); - precompiles.apply_precompile(&CHAUM_PEDERSEN_VERIFY_ADDRESS, |_| { - Some(ChaumPedersenVerify::create(&cfg)) - }); - precompiles.apply_precompile(&AES_GCM_DECRYPT_ADDRESS, |_| { - Some(AesGcmDecrypt::create(&cfg)) - }); - precompiles.apply_precompile(&ZONE_TIP20_FACTORY_ADDRESS, |_| { - Some(ZoneTokenFactory::create(&cfg)) - }); - let registry = self - .policy_provider - .clone() - .map(ZoneTip403ProxyRegistry::new); let sequencer: Arc = Arc::new(self.l1_reader.clone()); - - if let Some(provider) = self.policy_provider.clone() { - precompiles.apply_precompile(&ZONE_TIP403_PROXY_ADDRESS, |_| { - Some(ZoneTip403ProxyRegistry::create(provider.clone(), &cfg)) - }); - } - - // Override the TIP-20 precompile lookup so that all TIP-20 token - // calls go through ZoneTip20Token. When a live policy provider is - // available, the wrapper also enforces TIP-403 policy checks; without - // one, it still applies privacy, fixed-gas, and bridge-auth rules. - // - // This replaces the upstream `extend_tempo_precompiles` lookup, so we - // must also handle the non-TIP-20 Tempo precompiles that are zone-relevant - // (FeeManager, NonceManager, AccountKeychain). - // Zone-specific overrides (TIP20Factory, TIP403Proxy) are in the - // static map via `apply_precompile` and take priority over this. - let zone_cfg = cfg.clone(); - let zone_env = PrecompileEnv::new( + extend_zone_precompiles( + precompiles, &cfg, + self.l1_reader.clone(), + sequencer, StorageActions::disabled(), Rc::new(RefCell::new(NonCreditableSlots::empty())), + controller, ); - precompiles.set_precompile_lookup(move |address: &alloy_primitives::Address| { - if is_tip20_prefix(*address) { - Some(ZoneTip20Token::create( - *address, - &zone_cfg, - registry.clone(), - sequencer.clone(), - )) - } else if *address == TIP_FEE_MANAGER_ADDRESS { - Some(TipFeeManager::create_precompile(&zone_env)) - } else if *address == STABLECOIN_DEX_ADDRESS { - None - } else if *address == NONCE_PRECOMPILE_ADDRESS { - Some(NonceManager::create_precompile(&zone_env)) - } else if *address == ACCOUNT_KEYCHAIN_ADDRESS { - Some(AccountKeychain::create_precompile(&zone_env)) - } else { - None - } - }); + precompiles.apply_precompile(&ZONE_TX_CONTEXT_ADDRESS, |_| Some(ZoneTxContext::create())); evm } } @@ -163,8 +98,8 @@ impl EvmFactory for ZoneEvmFactory where L1: L1StorageReader + SequencerExt, { - type Evm>> = ZoneEvm; - type Context = TempoCtx; + type Evm>> = ZoneEvm; + type Context = TempoCtx>; type Tx = ::Tx; type Error = ::Error; type HaltReason = TempoHaltReason; @@ -177,8 +112,10 @@ where db: DB, input: EvmEnv, ) -> Self::Evm { + let db = AnchoredZoneDb::new(db, self.l1_reader.clone()); + let controller = db.controller().clone(); let evm = TempoEvm::new(db, input); - ZoneEvm::new(self.register_precompiles(evm)) + ZoneEvm::new(self.register_precompiles(evm, controller)) } fn create_evm_with_inspector>>( @@ -187,12 +124,14 @@ where input: EvmEnv, inspector: I, ) -> Self::Evm { + let db = AnchoredZoneDb::new(db, self.l1_reader.clone()); + let controller = db.controller().clone(); let evm = TempoEvm::new(db, input).with_inspector(inspector); - ZoneEvm::new(self.register_precompiles(evm)) + ZoneEvm::new(self.register_precompiles(evm, controller)) } } -/// Assembler for Zone blocks — delegates to [`TempoBlockAssembler`] after converting input types. +/// Assembler for Zone blocks - delegates to [`TempoBlockAssembler`] after converting input types. #[derive(Debug, Clone)] pub struct ZoneBlockAssembler { inner: TempoBlockAssembler, @@ -296,17 +235,14 @@ impl ZoneEvmConfig { .connect_http("http://127.0.0.1:1".parse().expect("valid fallback URL")) .erased(); let runtime_handle = tokio::runtime::Handle::current(); - let config = L1StateProviderConfig::default(); + let config = L1StateProviderConfig { + max_sync_attempts: Some(1), + ..Default::default() + }; let l1_provider = L1StateProvider::new_raw(config, cache, provider, runtime_handle); Self::from_chain_spec(chain_spec, l1_provider) } - /// Set the policy provider for the TIP-403 proxy precompile. - pub fn with_policy_provider(mut self, policy_provider: PolicyProvider) -> Self { - self.zone_factory = self.zone_factory.with_policy_provider(policy_provider); - self - } - /// Returns the Zone chain specification. pub fn chain_spec(&self) -> &Arc { &self.chain_spec @@ -324,7 +260,8 @@ impl BlockExecutorFactory for ZoneEvmConfig { type Transaction = TempoTxEnvelope; type Receipt = TempoReceipt; type TxExecutionResult = EthTxResult; - type Executor<'a, DB: StateDB, I: Inspector>> = ZoneBlockExecutor<'a, DB, I>; + type Executor<'a, DB: StateDB, I: Inspector>>> = + ZoneBlockExecutor<'a, DB, I>; fn evm_factory(&self) -> &Self::EvmFactory { &self.zone_factory @@ -337,7 +274,7 @@ impl BlockExecutorFactory for ZoneEvmConfig { ) -> Self::Executor<'a, DB, I> where DB: StateDB, - I: Inspector>, + I: Inspector>>, { ZoneBlockExecutor::new(evm, ctx, self.chain_spec()) } diff --git a/crates/evm/src/test_utils.rs b/crates/evm/src/test_utils.rs new file mode 100644 index 000000000..571c322ae --- /dev/null +++ b/crates/evm/src/test_utils.rs @@ -0,0 +1,3 @@ +//! Shared test utilities for Zone EVM tests. + +pub(crate) use zone_precompiles::test_utils::MockL1Reader as TestL1; diff --git a/crates/evm/src/zone_evm/contract_creation.rs b/crates/evm/src/zone_evm/contract_creation.rs index 67414eae6..df318dc85 100644 --- a/crates/evm/src/zone_evm/contract_creation.rs +++ b/crates/evm/src/zone_evm/contract_creation.rs @@ -73,7 +73,7 @@ fn contract_creation_deployer(tx: &TempoTxEnv) -> Option
{ #[cfg(test)] mod tests { use super::*; - use crate::ZoneEvm; + use crate::{AnchoredZoneDb, ZoneEvm, test_utils::TestL1}; use alloy_evm::{Evm, EvmEnv}; use alloy_primitives::{Address, Bytes, TxKind, U256, bytes}; use revm::{ @@ -91,16 +91,17 @@ mod tests { use tempo_revm::{TempoBatchCallEnv, TempoTxEnv}; type TestDb = CacheDB; + type TestAdaptedDb = AnchoredZoneDb; const TEST_DEPLOYER: Address = Address::new([0x42; 20]); fn test_create( - context: ZoneInstructionCtx<'_, TestDb>, + context: ZoneInstructionCtx<'_, TestAdaptedDb>, ) -> Result<(), InstructionResult> { - create::(context, &[TEST_DEPLOYER]) + create::(context, &[TEST_DEPLOYER]) } - fn enable_test_deployer(evm: &mut ZoneEvm) { + fn enable_test_deployer(evm: &mut ZoneEvm) { let instructions = &mut evm.inner.inner_mut().inner.instruction; instructions.insert_instruction(CREATE, Instruction::new(test_create::), 0); instructions.insert_instruction(CREATE2, Instruction::new(test_create::), 0); @@ -122,13 +123,17 @@ mod tests { db } - fn evm_with_contract(addr: Address, code: &[u8]) -> ZoneEvm { - let input: EvmEnv = - EvmEnv::default(); - ZoneEvm::new(TempoEvm::new( - test_db([(addr, Bytes::copy_from_slice(code))]), - input, - )) + fn test_evm( + db: TestDb, + input: EvmEnv, + ) -> ZoneEvm { + let db = AnchoredZoneDb::new(db, TestL1::default()); + ZoneEvm::new(TempoEvm::new(db, input)) + } + + fn evm_with_contract(addr: Address, code: &[u8]) -> ZoneEvm { + let input = EvmEnv::default(); + test_evm(test_db([(addr, Bytes::copy_from_slice(code))]), input) } fn call_tx(caller: Address, contract: Address) -> TempoTxEnv { @@ -247,13 +252,13 @@ mod tests { let input: EvmEnv = EvmEnv::default(); - let mut evm = ZoneEvm::new(TempoEvm::new( + let mut evm = test_evm( test_db([ (proxy, Bytes::from(proxy_code)), (TEST_DEPLOYER, bytes!("0x5f5f5ff000")), ]), input, - )); + ); enable_test_deployer(&mut evm); let result = evm diff --git a/crates/evm/src/zone_evm/mod.rs b/crates/evm/src/zone_evm/mod.rs index 1a5c0641a..5d280f0e8 100644 --- a/crates/evm/src/zone_evm/mod.rs +++ b/crates/evm/src/zone_evm/mod.rs @@ -2,47 +2,101 @@ pub(crate) mod contract_creation; -use crate::TempoCtx; +use crate::{ + TempoCtx, + database::{AnchoredZoneDb, AnchoredZoneDbError}, +}; use alloy_evm::{Database, Evm, EvmEnv, precompiles::PrecompilesMap, revm::Inspector}; use alloy_primitives::{Address, Bytes}; -use revm::context::result::{EVMError, ResultAndState}; +use revm::context::{ + DBErrorMarker, + result::{EVMError, ResultAndState}, +}; use tempo_evm::{TempoBlockEnv, TempoHaltReason, evm::TempoEvm}; use tempo_revm::{TempoInvalidTransaction, TempoTxEnv}; +use zone_l1::state::L1StateProvider; +use zone_precompiles::L1StorageReader; use zone_primitives::constants::CONTRACT_DEPLOYER_ALLOWLIST; +type TempoResult = ResultAndState; +type AdaptedEvmError = EVMError, TempoInvalidTransaction>; +type ZoneEvmError = EVMError; + /// Zone runtime EVM. /// -/// Wraps Tempo (L1) EVM to enforce Zone-specific execution rules. -pub struct ZoneEvm { - inner: TempoEvm, +/// Execution uses an anchored database adapter internally while the public [`Evm::DB`] remains the +/// exact database supplied by the caller. Successful results are validated and sanitized before +/// their state transitions can be committed through that public database. +pub struct ZoneEvm { + inner: TempoEvm, I>, } -impl ZoneEvm { +impl ZoneEvm { /// Creates a new `ZoneEvm` with guarded `CREATE` and `CREATE2` opcodes. - pub(super) fn new(mut evm: TempoEvm) -> Self { + pub(super) fn new(mut evm: TempoEvm, I>) -> Self { contract_creation::configure_runtime(&mut evm); Self { inner: evm } } /// Provides a reference to the EVM context. - pub fn ctx(&self) -> &TempoCtx { + pub fn ctx(&self) -> &TempoCtx> { self.inner.ctx() } /// Provides a mutable reference to the EVM context. - pub fn ctx_mut(&mut self) -> &mut TempoCtx { + pub fn ctx_mut(&mut self) -> &mut TempoCtx> { self.inner.ctx_mut() } + + /// Clears database-adapter bookkeeping left by the current transaction attempt. + pub(crate) fn reset_transaction_state(&mut self) { + self.inner + .ctx_mut() + .journaled_state + .database + .reset_transaction_state(); + } } -impl Evm for ZoneEvm +impl ZoneEvm where DB: Database, - I: Inspector>, + L1: L1StorageReader, + I: Inspector>>, +{ + fn execute_inner( + &mut self, + execute: impl FnOnce( + &mut TempoEvm, I>, + ) -> Result>, + ) -> Result> { + let result = match execute(&mut self.inner) { + Ok(mut result) => { + if result.result.is_success() + && let Err(error) = self.inner.db().sanitize_state(&mut result.state) + { + Err(error.into_evm_error()) + } else { + Ok(result) + } + } + Err(error) => Err(map_adapter_error(error)), + }; + + self.reset_transaction_state(); + result + } +} + +impl Evm for ZoneEvm +where + DB: Database, + L1: L1StorageReader, + I: Inspector>>, { type DB = DB; type Tx = TempoTxEnv; - type Error = EVMError; + type Error = ZoneEvmError; type HaltReason = TempoHaltReason; type Spec = tempo_chainspec::hardfork::TempoHardfork; type BlockEnv = TempoBlockEnv; @@ -66,7 +120,7 @@ where tx: Self::Tx, ) -> Result, Self::Error> { contract_creation::validate_transaction(&tx, CONTRACT_DEPLOYER_ALLOWLIST)?; - self.inner.transact_raw(tx) + self.execute_inner(|evm| evm.transact_raw(tx)) } fn transact_system_call( @@ -75,11 +129,12 @@ where contract: Address, data: Bytes, ) -> Result, Self::Error> { - self.inner.transact_system_call(caller, contract, data) + self.execute_inner(|evm| evm.transact_system_call(caller, contract, data)) } fn finish(self) -> (Self::DB, EvmEnv) { - self.inner.finish() + let (db, env) = self.inner.finish(); + (db.into_inner(), env) } fn set_inspector_enabled(&mut self, enabled: bool) { @@ -87,10 +142,24 @@ where } fn components(&self) -> (&Self::DB, &Self::Inspector, &Self::Precompiles) { - self.inner.components() + let (db, inspector, precompiles) = self.inner.components(); + (db.inner(), inspector, precompiles) } fn components_mut(&mut self) -> (&mut Self::DB, &mut Self::Inspector, &mut Self::Precompiles) { - self.inner.components_mut() + let (db, inspector, precompiles) = self.inner.components_mut(); + (db.inner_mut(), inspector, precompiles) + } +} + +fn map_adapter_error( + error: AdaptedEvmError, +) -> ZoneEvmError { + match error { + EVMError::Transaction(error) => EVMError::Transaction(error), + EVMError::Header(error) => EVMError::Header(error), + EVMError::Database(error) => error.into_evm_error(), + EVMError::Custom(error) => EVMError::Custom(error), + EVMError::CustomAny(error) => EVMError::CustomAny(error), } } diff --git a/crates/l1/src/lib.rs b/crates/l1/src/lib.rs index ad8033386..c9f07b8b6 100644 --- a/crates/l1/src/lib.rs +++ b/crates/l1/src/lib.rs @@ -102,6 +102,5 @@ pub(crate) use event::EnqueueOutcome; pub(crate) use queue::PendingDeposits; #[cfg(test)] pub(crate) use subscriber::{ - LocalTempoCheckpointReader, address_to_storage_value, apply_sequencer_events_to_cache, - verify_receipts, + LocalTempoCheckpointReader, apply_sequencer_events_to_cache, verify_receipts, }; diff --git a/crates/l1/src/state/provider.rs b/crates/l1/src/state/provider.rs index cb5a3fd4d..13ff03409 100644 --- a/crates/l1/src/state/provider.rs +++ b/crates/l1/src/state/provider.rs @@ -41,6 +41,8 @@ pub struct L1StateProviderConfig { /// Interval between WebSocket reconnection attempts. /// Defaults to 100ms. pub retry_connection_interval: std::time::Duration, + /// Maximum number of synchronous RPC attempts per cache miss. `None` retries indefinitely. + pub max_sync_attempts: Option, } impl Default for L1StateProviderConfig { @@ -52,6 +54,7 @@ impl Default for L1StateProviderConfig { max_retries: 10, initial_backoff_ms: 20, retry_connection_interval: std::time::Duration::from_millis(100), + max_sync_attempts: None, } } } @@ -86,6 +89,8 @@ pub struct L1StateProvider { /// Handle to the tokio runtime, used by [`get_storage`](Self::get_storage) to /// dispatch async RPC calls from a blocking (non-async) context. runtime_handle: tokio::runtime::Handle, + /// Optional finite attempt limit for synchronous cache-miss fallback. + max_sync_attempts: Option, } impl L1StateProvider { @@ -134,6 +139,7 @@ impl L1StateProvider { portal_address: config.portal_address, provider, runtime_handle, + max_sync_attempts: config.max_sync_attempts, }) } @@ -153,6 +159,7 @@ impl L1StateProvider { portal_address: config.portal_address, provider, runtime_handle, + max_sync_attempts: config.max_sync_attempts, } } @@ -200,6 +207,14 @@ impl L1StateProvider { return Ok(value); } Err(rpc_err) => { + if self + .max_sync_attempts + .is_some_and(|max_attempts| attempt >= max_attempts) + { + return Err(eyre::eyre!( + "L1 storage RPC fetch failed after {attempt} attempts for address={address} slot={slot} block={block_number}: {rpc_err}" + )); + } warn!(%address, %slot, block_number, %rpc_err, ?elapsed, attempt, "L1 storage RPC fetch failed, retrying"); } } @@ -298,3 +313,36 @@ impl SequencerExt for L1StateProvider { self.get_latest_sequencer().ok() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test(flavor = "multi_thread")] + async fn finite_sync_attempt_limit_returns_diagnostic_error() { + let config = L1StateProviderConfig { + max_sync_attempts: Some(1), + ..Default::default() + }; + let provider = ProviderBuilder::new_with_network::() + .connect("http://127.0.0.1:1") + .await + .expect("HTTP transport construction is lazy") + .erased(); + let reader = L1StateProvider::new_raw( + config, + L1StateCache::default(), + provider, + tokio::runtime::Handle::current(), + ); + + let err = + tokio::task::spawn_blocking(move || reader.get_storage(Address::ZERO, B256::ZERO, 7)) + .await + .expect("storage task must not panic") + .expect_err("dead endpoint must fail after one attempt"); + let message = err.to_string(); + assert!(message.contains("after 1 attempts"), "{message}"); + assert!(message.contains("block=7"), "{message}"); + } +} diff --git a/crates/l1/src/state/tip403/provider.rs b/crates/l1/src/state/tip403/provider.rs index ac9d980e4..a3a435f0a 100644 --- a/crates/l1/src/state/tip403/provider.rs +++ b/crates/l1/src/state/tip403/provider.rs @@ -12,13 +12,11 @@ use alloy_primitives::Address; use alloy_provider::DynProvider; use alloy_rpc_types_eth::BlockId; use eyre::Result; -use revm::precompile::PrecompileError; use tempo_alloy::TempoNetwork; use tempo_contracts::precompiles::{ ITIP20, ITIP403Registry, ITIP403Registry::PolicyType, TIP403_REGISTRY_ADDRESS, }; use tracing::{debug, info, warn}; -use zone_precompiles::policy::PolicyCheck; use super::builtin_authorization; @@ -605,61 +603,3 @@ impl PolicyProvider { Ok(authorized) } } - -impl PolicyCheck for PolicyProvider { - fn is_authorized( - &self, - policy_id: u64, - user: Address, - role: AuthRole, - ) -> Result { - self.is_authorized_by_policy(policy_id, user, role) - .map_err(|e| { - zone_precompiles::zone_rpc_error(format!( - "auth check failed for policy {policy_id} user {user}: {e}" - )) - }) - } - - fn resolve_transfer_policy_id(&self, token: Address) -> Result { - Self::resolve_transfer_policy_id(self, token).map_err(|e| { - zone_precompiles::zone_rpc_error(format!( - "failed to resolve transfer_policy_id for {token}: {e}" - )) - }) - } - - fn policy_type_sync(&self, policy_id: u64) -> Result { - self.resolve_policy_type_sync(policy_id).map_err(|e| { - zone_precompiles::zone_rpc_error(format!( - "policyData failed for policy {policy_id}: {e}" - )) - }) - } - - fn compound_policy_data(&self, policy_id: u64) -> Result<(u64, u64, u64), PrecompileError> { - let compound = self.resolve_compound_data_sync(policy_id).map_err(|e| { - zone_precompiles::zone_rpc_error(format!( - "compoundPolicyData failed for policy {policy_id}: {e}" - )) - })?; - Ok(( - compound.sender_policy_id, - compound.recipient_policy_id, - compound.mint_recipient_policy_id, - )) - } - - fn policy_exists(&self, policy_id: u64) -> Result { - self.policy_exists_sync(policy_id).map_err(|e| { - zone_precompiles::zone_rpc_error(format!( - "policyExists failed for policy {policy_id}: {e}" - )) - }) - } - - fn policy_id_counter(&self) -> u64 { - let cache = self.cache.read(); - cache.policies().keys().max().map_or(2, |max| max + 1) - } -} diff --git a/crates/l1/src/subscriber.rs b/crates/l1/src/subscriber.rs index d6cab03a1..d34ff35b3 100644 --- a/crates/l1/src/subscriber.rs +++ b/crates/l1/src/subscriber.rs @@ -762,48 +762,24 @@ pub(crate) fn apply_sequencer_events_to_cache( block_number: u64, sequencer_events: &[L1SequencerEvent], ) { + let mut set_cache_slot = |slot, value| cache.set(portal_address, slot, block_number, value); + for event in sequencer_events { match *event { L1SequencerEvent::TransferStarted { current_sequencer, pending_sequencer, } => { - cache.set( - portal_address, - PORTAL_SEQUENCER_SLOT, - block_number, - address_to_storage_value(current_sequencer), - ); - cache.set( - portal_address, - PORTAL_PENDING_SEQUENCER_SLOT, - block_number, - address_to_storage_value(pending_sequencer), - ); + set_cache_slot(PORTAL_SEQUENCER_SLOT, current_sequencer.into_word()); + set_cache_slot(PORTAL_PENDING_SEQUENCER_SLOT, pending_sequencer.into_word()); } L1SequencerEvent::Transferred { previous_sequencer: _, new_sequencer, } => { - cache.set( - portal_address, - PORTAL_SEQUENCER_SLOT, - block_number, - address_to_storage_value(new_sequencer), - ); - cache.set( - portal_address, - PORTAL_PENDING_SEQUENCER_SLOT, - block_number, - B256::ZERO, - ); + set_cache_slot(PORTAL_SEQUENCER_SLOT, new_sequencer.into_word()); + set_cache_slot(PORTAL_PENDING_SEQUENCER_SLOT, B256::ZERO); } } } } - -pub(crate) fn address_to_storage_value(address: Address) -> B256 { - let mut bytes = [0u8; 32]; - bytes[12..].copy_from_slice(address.as_slice()); - B256::new(bytes) -} diff --git a/crates/l1/src/tests.rs b/crates/l1/src/tests.rs index 4c792ea78..079e52c0c 100644 --- a/crates/l1/src/tests.rs +++ b/crates/l1/src/tests.rs @@ -636,11 +636,11 @@ fn test_apply_sequencer_events_to_cache_sets_pending_sequencer() { assert_eq!( cache.get(portal_address, PORTAL_SEQUENCER_SLOT, 42), - Some(address_to_storage_value(current_sequencer)) + Some(current_sequencer.into_word()) ); assert_eq!( cache.get(portal_address, PORTAL_PENDING_SEQUENCER_SLOT, 42), - Some(address_to_storage_value(pending_sequencer)) + Some(pending_sequencer.into_word()) ); } @@ -663,7 +663,7 @@ fn test_apply_sequencer_events_to_cache_accept_clears_pending_sequencer() { assert_eq!( cache.get(portal_address, PORTAL_SEQUENCER_SLOT, 43), - Some(address_to_storage_value(new_sequencer)) + Some(new_sequencer.into_word()) ); assert_eq!( cache.get(portal_address, PORTAL_PENDING_SEQUENCER_SLOT, 43), @@ -701,11 +701,11 @@ fn test_apply_sequencer_events_to_cache_preserves_in_block_event_order() { assert_eq!( cache.get(portal_address, PORTAL_SEQUENCER_SLOT, 44), - Some(address_to_storage_value(sequencer_b)) + Some(sequencer_b.into_word()) ); assert_eq!( cache.get(portal_address, PORTAL_PENDING_SEQUENCER_SLOT, 44), - Some(address_to_storage_value(sequencer_c)) + Some(sequencer_c.into_word()) ); } diff --git a/crates/node/README.md b/crates/node/README.md index 574ff4f3e..7d947e8cb 100644 --- a/crates/node/README.md +++ b/crates/node/README.md @@ -19,11 +19,11 @@ graph TD L1Sub["L1Subscriber
WebSocket + backfill"] DQ["DepositQueue"] Cache["L1StateCache"] - PolicyCache["PolicyCache"] + PolicyCache["PolicyCache
encrypted deposits only"] Engine["ZoneEngine"] Builder["PayloadBuilder
advanceTempo + pool txs"] - PolicyPrefetch["PolicyResolutionTask
pool pre-warm"] + PolicyPrefetch["PolicyResolutionTask
legacy cache pre-warm"] Monitor["ZoneMonitor"] Batch["BatchSubmitter"] @@ -124,26 +124,24 @@ header chain linking back to the target block. ## TIP-403 Policy Enforcement The zone enforces TIP-403 transfer policies (whitelist, blacklist, compound) -identically to L1. Policy state is mirrored via: - -1. **L1Subscriber** — extracts `PolicyCreated`, `WhitelistUpdated`, - `BlacklistUpdated`, `CompoundPolicyCreated`, and `TransferPolicyUpdate` - events from L1 block receipts (via `eth_getBlockReceipts`) and applies - them to the in-memory `PolicyCache`. -2. **PolicyProvider** — cache-first, RPC-fallback resolution. On cache miss - it queries L1 via `block_in_place` and populates the cache for subsequent - lookups. -3. **ZoneTip403ProxyRegistry** — a read-only precompile at the same address - as the L1 `TIP403Registry` (`0x403C…0000`). It intercepts `isAuthorized`, - `policyData`, `compoundPolicyData`, etc. and serves them from the - `PolicyProvider`. Mutating calls are reverted. -4. **Pool pre-fetching** — the `PolicyResolutionTask` pre-warms the cache for - pending pool transactions so payload building doesn't block on RPC. - -The payload builder checks sender/recipient authorization during -`advanceTempo` deposit processing. Encrypted deposits that fail policy checks -are included with a zeroed-out amount (the deposit hash chain must still -match L1). +using Tempo's upstream policy implementation over anchored raw L1 state: + +1. **L1StateProvider** — resolves storage slots at an explicit L1 block through + the block-versioned `L1StateCache`, falling back to an exact-block L1 RPC + read and caching the result. +2. **AnchoredZoneDb** — composes ordinary zone EVM storage with the raw L1 + reader at the exact finalized block recorded in `TempoState`. TIP-403 + registry state and each token's L1-owned transfer-policy field come from L1; + balances and all other TIP-20 state remain zone-local. Mirrored reads retain + normal EVM gas, warming, and storage accounting, while persistent writes to + L1-owned slots are rejected. +3. **Tempo TIP-20 and TIP-403 precompiles** — execute the upstream business + logic against that composed storage view, replacing the zone's duplicated + policy dispatch. Missing or invalid anchored state fails closed, and zone + privacy, bridge authorization, admission, and fixed-gas rules remain in the + surrounding execution layer. + +> NOTE: encrypted-deposit checks temporarily retain the legacy `PolicyProvider` and `PolicyCache`; they will move to the same anchored raw-state path before that pipeline is removed. Encrypted deposits that fail policy checks are included with a zeroed-out amount so the deposit hash chain still matches L1. ## Demo: Token Creation with Transfer Policy @@ -228,8 +226,8 @@ just send-deposit 1000000 "" $TOKEN ### 7. Test enforcement on the zone -Transfers to blacklisted addresses will be rejected by the zone's -`ZoneTip403ProxyRegistry` precompile, which mirrors L1 policy state. +Transfers to blacklisted addresses will be rejected by upstream TIP-20 policy +logic reading raw L1 policy state at the zone's finalized Tempo anchor. ```bash # Check balance on zone @@ -265,7 +263,7 @@ just set-transfer-policy $TOKEN | `0x1C00…0100` | `ChaumPedersenVerify` | Verify DLOG equality proofs for ECDH | | `0x1C00…0101` | `AesGcmDecrypt` | AES-256-GCM authenticated decryption | | `0x20FC…0000` | `ZoneTokenFactory` | Initialize TIP-20 tokens on the zone | -| `0x403C…0000` | `ZoneTip403ProxyRegistry` | Read-only proxy mirroring L1 TIP-403 policy state | +| `0x403C…0000` | `TIP403Registry` | Upstream read-only execution over exact-block raw L1 policy state | ## EVM Configuration @@ -275,9 +273,9 @@ L1 with these differences: - The **TIP20Factory** precompile is replaced by `ZoneTokenFactory`, which only supports `enableToken` (no `createToken`) since zone tokens are always bridged from L1. -- The **TIP403Registry** precompile is replaced by `ZoneTip403ProxyRegistry`, - a storage-less read-only proxy that resolves authorization from the in-memory - policy cache rather than on-chain storage. +- The **TIP403Registry** uses upstream Tempo execution through a read-only + storage overlay anchored at the finalized L1 block recorded in `TempoState`. + Policy mutations revert because registry state is owned by L1. - The **block executor** is simplified: no subblock ordering, shared-gas accounting, or end-of-block metadata system transactions — those are L1-only concerns. diff --git a/crates/node/src/node.rs b/crates/node/src/node.rs index 0f1038e9e..aacf662c4 100644 --- a/crates/node/src/node.rs +++ b/crates/node/src/node.rs @@ -287,6 +287,21 @@ impl ZoneNode { self } + /// Bound L1 state-provider retries for callers that must fail finitely on cache misses. + pub fn with_l1_state_provider_retry_limits( + mut self, + transport_retries: u32, + sync_attempts: u32, + ) -> Self { + assert!( + sync_attempts > 0, + "at least one synchronous attempt is required" + ); + self.l1_state_provider_config.max_retries = transport_retries; + self.l1_state_provider_config.max_sync_attempts = Some(sync_attempts); + self + } + /// Set the number of zone blocks between empty withdrawal batch /// finalization. pub fn with_withdrawal_batch_interval_blocks(mut self, interval_blocks: u64) -> Self { @@ -906,7 +921,6 @@ where let executor_builder = ZoneExecutorBuilder::new( self.l1_state_provider_config.clone(), self.l1_state_cache.clone(), - self.policy_cache.clone(), ); let mut payload_factory = ZonePayloadFactory::new(self.withdrawal_batch_interval_blocks); if let Some(encryptor) = self.withdrawal_reveal_encryptor.clone() { @@ -970,20 +984,17 @@ impl PayloadAttributesBuilder for ZonePayloa pub struct ZoneExecutorBuilder { l1_state_provider_config: L1StateProviderConfig, l1_state_cache: L1StateCache, - policy_cache: PolicyCache, } impl ZoneExecutorBuilder { - /// Create a zone executor builder with the shared L1 state/policy caches. + /// Create a zone executor builder with the shared L1 state cache. pub fn new( l1_state_provider_config: L1StateProviderConfig, l1_state_cache: L1StateCache, - policy_cache: PolicyCache, ) -> Self { Self { l1_state_provider_config, l1_state_cache, - policy_cache, } } } @@ -1007,20 +1018,8 @@ where let tempo_chain_spec = tempo_chain_spec_for_l1(l1_chain_id) .ok_or_else(|| eyre::eyre!("unsupported parent Tempo chain ID {l1_chain_id}"))?; // Keep the Zone chain settings and use the parent L1 schedule for Tempo hardforks. - let mut evm_config = ZoneEvmConfig::new(ctx.chain_spec(), tempo_chain_spec, l1_provider); - - // Create PolicyProvider for the TIP-403 proxy precompile. - let policy_l1 = alloy_provider::ProviderBuilder::new_with_network::() - .connect_with_config( - &self.l1_state_provider_config.l1_rpc_url, - rpc_connection_config(self.l1_state_provider_config.retry_connection_interval), - ) - .await? - .erased(); - - 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); + info!(target: "reth::cli", "Zone EVM initialized with L1-backed Tempo precompiles"); Ok(evm_config) } diff --git a/crates/node/tests/it/l1_e2e.rs b/crates/node/tests/it/l1_e2e.rs index 36f1737df..7569ee818 100644 --- a/crates/node/tests/it/l1_e2e.rs +++ b/crates/node/tests/it/l1_e2e.rs @@ -1415,10 +1415,9 @@ async fn test_blacklisted_sender_transfer_rejected() -> eyre::Result<()> { ) .await?; - // --- Step 5: Alice attempts a transfer → should be rejected --- - // The transfer may be rejected at the pool level (send() returns Err) or - // accepted into a block but reverted (receipt.status() == false). Either - // outcome proves the blacklist is enforced. + // --- Step 5: Alice simulates a transfer → should be rejected --- + // Use an exact stateful call instead of waiting on pool inclusion: policy-invalid + // transactions are allowed to remain pending, so absence of a receipt is not proof. let bob = Address::with_last_byte(0xBB); let alice_provider = alloy::providers::ProviderBuilder::new() @@ -1426,28 +1425,15 @@ async fn test_blacklisted_sender_transfer_rejected() -> eyre::Result<()> { .connect_http(zone.http_url().clone()); let tip20 = tempo_contracts::precompiles::ITIP20::new(ZONE_TOKEN_ADDRESS, &alice_provider); - let send_result = tip20 + let transfer = tip20 .transfer(bob, U256::from(200_000u128)) - .gas_price(tempo_chainspec::spec::TEMPO_T1_BASE_FEE as u128) - .gas(500_000) - .send() + .from(alice) + .call() .await; - - match send_result { - Err(_) => { - // Pool-level rejection — blacklist enforced before inclusion. - } - Ok(pending) => { - // Tx was accepted into the pool — wait for it to be included and - // verify it reverted. - tokio::time::sleep(Duration::from_secs(3)).await; - let receipt = pending.get_receipt().await?; - assert!( - !receipt.status(), - "transfer from blacklisted sender should revert, but succeeded" - ); - } - } + assert!( + transfer.is_err(), + "transfer simulation from blacklisted sender should revert" + ); // Bob should have zero balance let bob_balance = zone.balance_of(ZONE_TOKEN_ADDRESS, bob).await?; diff --git a/crates/node/tests/it/tip403_policy.rs b/crates/node/tests/it/tip403_policy.rs index 4a7ef719f..ddb0fa95a 100644 --- a/crates/node/tests/it/tip403_policy.rs +++ b/crates/node/tests/it/tip403_policy.rs @@ -1,18 +1,25 @@ //! E2E tests for the TIP-403 policy proxy precompile on the zone. //! -//! These tests verify that the `ZoneTip403ProxyRegistry` precompile correctly -//! serves authorization queries from the `PolicyCache` and rejects -//! mutating calls. The cache is populated directly in tests (no L1 subscriber). +//! These tests verify that the zone TIP-403 precompile correctly serves authorization queries from +//! finalized raw L1 storage via `L1StateCache` and rejects mutating calls. The cache is populated +//! directly in tests (no L1 subscriber). -use alloy::primitives::{U256, address}; -use alloy_provider::ProviderBuilder; +use alloy::primitives::{TxKind, U256, address}; +use alloy_provider::{Provider, ProviderBuilder}; +use alloy_rpc_types_eth::TransactionRequest; use alloy_signer_local::{MnemonicBuilder, coins_bip39::English}; use tempo_chainspec::spec::TEMPO_T0_BASE_FEE; -use tempo_contracts::precompiles::{ITIP20, ITIP403Registry}; -use tempo_precompiles::{PATH_USD_ADDRESS, TIP403_REGISTRY_ADDRESS}; -use zone_l1::state::tip403::{CompoundData, PolicyEvent}; - -use crate::utils::{DEFAULT_TIMEOUT, TEST_MNEMONIC, TIP20_TX_GAS, start_local_zone_with_fixture}; +use tempo_contracts::precompiles::{ + ITIP20, + ITIP403Registry::{self, PolicyType}, +}; +use tempo_precompiles::{PATH_USD_ADDRESS, TIP_FEE_MANAGER_ADDRESS, TIP403_REGISTRY_ADDRESS}; +use zone_l1::state::tip403::PolicyEvent; + +use crate::utils::{ + DEFAULT_TIMEOUT, PolicySeed, TEST_MNEMONIC, TIP20_TX_GAS, seed_raw_tip20_policy_id, + seed_raw_tip403_policy, start_local_zone_with_fixture, +}; /// Deposit pathUSD to Alice, then transfer a portion to Bob on the zone. /// @@ -50,6 +57,10 @@ async fn test_tip20_transfer_on_zone() -> eyre::Result<()> { .wallet(alice_signer) .connect_http(zone.http_url().clone()); + // T6+ transfers also consult the recipient's address-level receive policy on L1. + // Seed the anchor before pool validation; the next execution block inherits this baseline. + fixture.seed_no_receive_policy(bob)?; + let tip20 = ITIP20::new(PATH_USD_ADDRESS, &alice_provider); let pending = tip20 .transfer(bob, U256::from(transfer_amount)) @@ -58,7 +69,7 @@ async fn test_tip20_transfer_on_zone() -> eyre::Result<()> { .send() .await?; - // Inject an empty L1 block to trigger block production including the pool tx + // Inject an empty L1 block to trigger block production including the pool tx. fixture.inject_empty_block(zone.deposit_queue()); let receipt = pending.get_receipt().await?; @@ -86,6 +97,85 @@ async fn test_tip20_transfer_on_zone() -> eyre::Result<()> { Ok(()) } +/// Protocol fee collection must use the finalized L1 policy even when the tx does not call TIP-20. +#[tokio::test(flavor = "multi_thread")] +async fn test_l1_blacklisted_sender_cannot_pay_for_empty_transaction() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (zone, mut fixture) = start_local_zone_with_fixture(10).await?; + let alice_signer = MnemonicBuilder::::default() + .phrase(TEST_MNEMONIC) + .build()?; + let alice = alice_signer.address(); + + let deposit_amount = 1_000_000u128; + let deposit = fixture.make_deposit(PATH_USD_ADDRESS, alice, alice, deposit_amount); + fixture.inject_deposits(zone.deposit_queue(), vec![deposit]); + zone.wait_for_balance( + PATH_USD_ADDRESS, + alice, + U256::from(deposit_amount), + DEFAULT_TIMEOUT, + ) + .await?; + let anchor = zone.wait_for_tempo_block_number(1, DEFAULT_TIMEOUT).await?; + + const BLACKLIST_POLICY_ID: u64 = 42; + seed_raw_tip20_policy_id( + &mut zone.l1_state_cache().write(), + anchor, + PATH_USD_ADDRESS, + BLACKLIST_POLICY_ID, + ); + seed_raw_tip403_policy( + zone.l1_state_cache(), + anchor, + &[PolicySeed::simple( + BLACKLIST_POLICY_ID, + PolicyType::BLACKLIST, + &[(alice, true), (TIP_FEE_MANAGER_ADDRESS, false)], + )], + )?; + + let alice_provider = ProviderBuilder::new() + .wallet(alice_signer) + .connect_http(zone.http_url().clone()); + let request = TransactionRequest { + to: Some(TxKind::Call(alice)), + gas: Some(TIP20_TX_GAS), + gas_price: Some(TEMPO_T0_BASE_FEE as u128), + ..Default::default() + }; + + let nonce_before = alice_provider.get_transaction_count(alice).await?; + let pending = alice_provider.send_transaction(request).await?; + let tx_hash = *pending.tx_hash(); + + fixture.inject_empty_block(zone.deposit_queue()); + zone.wait_for_tempo_block_number(anchor + 1, DEFAULT_TIMEOUT) + .await?; + + assert!( + alice_provider + .get_transaction_receipt(tx_hash) + .await? + .is_none(), + "L1-blacklisted fee payer transaction must not be included" + ); + assert_eq!( + alice_provider.get_transaction_count(alice).await?, + nonce_before, + "rejected fee payment must not consume the sender nonce" + ); + assert_eq!( + zone.balance_of(PATH_USD_ADDRESS, alice).await?, + U256::from(deposit_amount), + "rejected fee payment must leave the sender balance unchanged" + ); + + Ok(()) +} + /// Whitelist policy: set entries are authorized, non-set entries are not (fail-closed). #[tokio::test(flavor = "multi_thread")] async fn test_policy_proxy_whitelist_authorization() -> eyre::Result<()> { @@ -93,21 +183,21 @@ async fn test_policy_proxy_whitelist_authorization() -> eyre::Result<()> { let (zone, mut fixture) = start_local_zone_with_fixture(10).await?; - // Inject a few empty L1 blocks so the zone is running - fixture.inject_empty_blocks(zone.deposit_queue(), 3); - zone.wait_for_tempo_block_number(3, DEFAULT_TIMEOUT).await?; - let alice = address!("0x000000000000000000000000000000000000A11C"); let bob = address!("0x0000000000000000000000000000000000000B0B"); - // Populate the cache: policy 5 = WHITELIST, Alice is in the set, Bob is not - { - let cache = zone.policy_cache(); - let mut w = cache.write(); - w.set_policy_type(5, ITIP403Registry::PolicyType::WHITELIST); - w.set_policy_status(5, alice, 1, true); - w.set_policy_status(5, bob, 1, false); - } + // Populate raw L1 state at block 1. + seed_raw_tip403_policy( + zone.l1_state_cache(), + 1, + &[ + PolicySeed::simple(5, PolicyType::WHITELIST, &[(alice, true)]), + PolicySeed::simple(5, PolicyType::WHITELIST, &[(bob, false)]), + ], + )?; + + fixture.inject_empty_blocks(zone.deposit_queue(), 3); + zone.wait_for_tempo_block_number(3, DEFAULT_TIMEOUT).await?; let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, zone.provider()); @@ -130,7 +220,7 @@ async fn test_policy_proxy_whitelist_authorization() -> eyre::Result<()> { let data = registry.policyData(5).call().await?; assert_eq!( data.policyType, - ITIP403Registry::PolicyType::WHITELIST, + PolicyType::WHITELIST, "policy 5 should be WHITELIST" ); @@ -144,20 +234,21 @@ async fn test_policy_proxy_blacklist_authorization() -> eyre::Result<()> { let (zone, mut fixture) = start_local_zone_with_fixture(10).await?; - fixture.inject_empty_blocks(zone.deposit_queue(), 3); - zone.wait_for_tempo_block_number(3, DEFAULT_TIMEOUT).await?; - let alice = address!("0x000000000000000000000000000000000000A11C"); let bob = address!("0x0000000000000000000000000000000000000B0B"); - // Populate the cache: policy 5 = BLACKLIST, Alice is blacklisted, Bob is not - { - let cache = zone.policy_cache(); - let mut w = cache.write(); - w.set_policy_type(5, ITIP403Registry::PolicyType::BLACKLIST); - w.set_policy_status(5, alice, 1, true); - w.set_policy_status(5, bob, 1, false); - } + // Populate raw L1 state at block 1. + seed_raw_tip403_policy( + zone.l1_state_cache(), + 1, + &[ + PolicySeed::simple(5, PolicyType::BLACKLIST, &[(alice, true)]), + PolicySeed::simple(5, PolicyType::BLACKLIST, &[(bob, false)]), + ], + )?; + + fixture.inject_empty_blocks(zone.deposit_queue(), 3); + zone.wait_for_tempo_block_number(3, DEFAULT_TIMEOUT).await?; let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, zone.provider()); @@ -182,30 +273,24 @@ async fn test_policy_proxy_compound_policy() -> eyre::Result<()> { let (zone, mut fixture) = start_local_zone_with_fixture(10).await?; - fixture.inject_empty_blocks(zone.deposit_queue(), 3); - zone.wait_for_tempo_block_number(3, DEFAULT_TIMEOUT).await?; - let alice = address!("0x000000000000000000000000000000000000A11C"); let bob = address!("0x0000000000000000000000000000000000000B0B"); - // Policy 5 = sender whitelist, policy 6 = recipient blacklist - // Compound policy 10 references them - { - let cache = zone.policy_cache(); - let mut w = cache.write(); - w.set_policy_type(5, ITIP403Registry::PolicyType::WHITELIST); - w.set_policy_status(5, alice, 1, true); // Alice whitelisted as sender - w.set_policy_type(6, ITIP403Registry::PolicyType::BLACKLIST); - w.set_policy_status(6, bob, 1, true); // Bob blacklisted as recipient - w.set_compound( - 10, - CompoundData { - sender_policy_id: 5, - recipient_policy_id: 6, - mint_recipient_policy_id: 1, // builtin allow - }, - ); - } + // Seed the compound policy graph at block 1. + // Policy 5 = sender whitelist, policy 6 = recipient blacklist; compound policy 10 + // references them. + seed_raw_tip403_policy( + zone.l1_state_cache(), + 1, + &[ + PolicySeed::simple(5, PolicyType::WHITELIST, &[(alice, true)]), + PolicySeed::simple(6, PolicyType::BLACKLIST, &[(bob, true)]), + PolicySeed::compound(10, 5, 6, 1), + ], + )?; + + fixture.inject_empty_blocks(zone.deposit_queue(), 3); + zone.wait_for_tempo_block_number(3, DEFAULT_TIMEOUT).await?; let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, zone.provider()); @@ -259,11 +344,11 @@ async fn test_policy_proxy_builtin_policies() -> eyre::Result<()> { // Policy 0 = WHITELIST semantics (empty whitelist = reject all) let data0 = registry.policyData(0).call().await?; - assert_eq!(data0.policyType, ITIP403Registry::PolicyType::WHITELIST); + assert_eq!(data0.policyType, PolicyType::WHITELIST); // Policy 1 = BLACKLIST semantics (empty blacklist = allow all) let data1 = registry.policyData(1).call().await?; - assert_eq!(data1.policyType, ITIP403Registry::PolicyType::BLACKLIST); + assert_eq!(data1.policyType, PolicyType::BLACKLIST); Ok(()) } @@ -284,7 +369,7 @@ async fn test_policy_proxy_reverts_mutating_calls() -> eyre::Result<()> { let result = registry .createPolicy( address!("0x0000000000000000000000000000000000000001"), - ITIP403Registry::PolicyType::WHITELIST, + PolicyType::WHITELIST, ) .call() .await; @@ -301,33 +386,31 @@ async fn test_compound_policy_transfer_role_authorization() -> eyre::Result<()> let (zone, mut fixture) = start_local_zone_with_fixture(10).await?; - fixture.inject_empty_blocks(zone.deposit_queue(), 3); - zone.wait_for_tempo_block_number(3, DEFAULT_TIMEOUT).await?; - let alice = address!("0x000000000000000000000000000000000000A11C"); let bob = address!("0x0000000000000000000000000000000000000B0B"); let carol = address!("0x000000000000000000000000000000000000CA01"); - // Policy 5 = sender whitelist, policy 6 = recipient blacklist - // Compound policy 10 references them - { - let cache = zone.policy_cache(); - let mut w = cache.write(); - w.set_policy_type(5, ITIP403Registry::PolicyType::WHITELIST); - w.set_policy_status(5, alice, 1, true); // Alice whitelisted as sender - w.set_policy_status(5, bob, 1, false); // Bob not whitelisted as sender - w.set_policy_type(6, ITIP403Registry::PolicyType::BLACKLIST); - w.set_policy_status(6, alice, 1, false); // Alice not blacklisted as recipient - w.set_policy_status(6, bob, 1, true); // Bob blacklisted as recipient - w.set_compound( - 10, - CompoundData { - sender_policy_id: 5, - recipient_policy_id: 6, - mint_recipient_policy_id: 1, // builtin allow - }, - ); - } + // Seed the complete policy membership at block 1. + seed_raw_tip403_policy( + zone.l1_state_cache(), + 1, + &[ + PolicySeed::simple( + 5, + PolicyType::WHITELIST, + &[(alice, true), (bob, false), (carol, true)], + ), + PolicySeed::simple( + 6, + PolicyType::BLACKLIST, + &[(alice, false), (bob, true), (carol, true)], + ), + PolicySeed::compound(10, 5, 6, 1), + ], + )?; + + fixture.inject_empty_blocks(zone.deposit_queue(), 3); + zone.wait_for_tempo_block_number(3, DEFAULT_TIMEOUT).await?; let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, zone.provider()); @@ -345,15 +428,7 @@ async fn test_compound_policy_transfer_role_authorization() -> eyre::Result<()> "bob should NOT be authorized (not in sender whitelist)" ); - // Carol: whitelisted as sender AND in recipient blacklist - { - let cache = zone.policy_cache(); - let mut w = cache.write(); - w.set_policy_status(5, carol, 1, true); // whitelisted as sender - w.set_policy_status(6, carol, 1, true); // blacklisted as recipient - } - - // Carol passes sender check but fails recipient → false + // Carol is whitelisted as sender but blacklisted as recipient, so transfer auth fails. let carol_auth = registry.isAuthorized(10, carol).call().await?; assert!( !carol_auth, @@ -363,74 +438,58 @@ async fn test_compound_policy_transfer_role_authorization() -> eyre::Result<()> Ok(()) } -/// Applying a sequence of `PolicyEvent`s correctly updates the proxy's responses. +/// Block-versioned raw L1 policy writes update the proxy's responses. #[tokio::test(flavor = "multi_thread")] -async fn test_policy_type_change_via_events() -> eyre::Result<()> { +async fn test_policy_proxy_uses_block_versioned_raw_state() -> eyre::Result<()> { reth_tracing::init_test_tracing(); let (zone, mut fixture) = start_local_zone_with_fixture(10).await?; - fixture.inject_empty_blocks(zone.deposit_queue(), 3); - zone.wait_for_tempo_block_number(3, DEFAULT_TIMEOUT).await?; - let alice = address!("0x000000000000000000000000000000000000A11C"); let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, zone.provider()); - // Step 1: Create policy 5 as WHITELIST via event, add Alice - { - let cache = zone.policy_cache(); - cache.write().apply_events( - 1, - &[ - PolicyEvent::PolicyCreated { - policy_id: 5, - policy_type: ITIP403Registry::PolicyType::WHITELIST, - }, - PolicyEvent::MembershipChanged { - policy_id: 5, - account: alice, - in_set: true, - }, - ], - ); - } + // Step 1: materialize block-1 state before accepting block 1, then query at anchor 1. + seed_raw_tip403_policy( + zone.l1_state_cache(), + 1, + &[PolicySeed::simple( + 5, + PolicyType::WHITELIST, + &[(alice, true)], + )], + )?; + fixture.inject_empty_block(zone.deposit_queue()); + zone.wait_for_tempo_block_number(1, DEFAULT_TIMEOUT).await?; - // Alice should be authorized (whitelisted) let authorized = registry.isAuthorized(5, alice).call().await?; - assert!(authorized, "alice should be authorized (whitelisted)"); - - // Step 2: Remove Alice via event at block 2 - { - let cache = zone.policy_cache(); - cache.write().apply_events( - 2, - &[PolicyEvent::MembershipChanged { - policy_id: 5, - account: alice, - in_set: false, - }], - ); - } + assert!(authorized, "alice should be authorized at block 1"); + + // Step 2: materialize block-2 state before accepting block 2, then query at anchor 2. + seed_raw_tip403_policy( + zone.l1_state_cache(), + 2, + &[PolicySeed::simple( + 5, + PolicyType::WHITELIST, + &[(alice, false)], + )], + )?; + fixture.inject_empty_block(zone.deposit_queue()); + zone.wait_for_tempo_block_number(2, DEFAULT_TIMEOUT).await?; - // Alice should no longer be authorized let authorized = registry.isAuthorized(5, alice).call().await?; - assert!(!authorized, "alice should NOT be authorized after removal"); - - // Step 3: Create compound policy 10 via event - { - let cache = zone.policy_cache(); - cache.write().apply_events( - 3, - &[PolicyEvent::CompoundPolicyCreated { - policy_id: 10, - sender_policy_id: 5, - recipient_policy_id: 1, // allow all - mint_recipient_policy_id: 1, - }], - ); - } + assert!(!authorized, "alice should NOT be authorized at block 2"); + + // Step 3: materialize the compound policy before accepting block 3. + seed_raw_tip403_policy( + zone.l1_state_cache(), + 3, + &[PolicySeed::compound(10, 5, 1, 1)], + )?; + fixture.inject_empty_block(zone.deposit_queue()); + zone.wait_for_tempo_block_number(3, DEFAULT_TIMEOUT).await?; - // Compound data should be queryable + // Compound data should be queryable at anchor 3. let compound = registry.compoundPolicyData(10).call().await?; assert_eq!(compound.senderPolicyId, 5); assert_eq!(compound.recipientPolicyId, 1); diff --git a/crates/node/tests/it/tip403_transfers.rs b/crates/node/tests/it/tip403_transfers.rs index 4f226014b..2080aa2de 100644 --- a/crates/node/tests/it/tip403_transfers.rs +++ b/crates/node/tests/it/tip403_transfers.rs @@ -53,6 +53,8 @@ async fn test_deposit_then_transfer() -> eyre::Result<()> { // Dev transfers 400,000 to Bob. // Submit the tx then inject an L1 block so the zone produces a block including it. let transfer_amount: u128 = 400_000; + // Seed the current anchor before estimation/pool validation. The next empty block inherits it. + fixture.seed_no_receive_policy(bob)?; let tip20 = ITIP20::new(PATH_USD_ADDRESS, &provider); let native_balance = provider.get_balance(dev_address).await?; @@ -237,6 +239,7 @@ async fn test_sequential_transfers() -> eyre::Result<()> { let alice_provider = ProviderBuilder::new() .wallet(alice_signer) .connect_http(zone.http_url().clone()); + fixture.seed_no_receive_policy(bob)?; let tip20_alice = ITIP20::new(PATH_USD_ADDRESS, &alice_provider); let pending = tip20_alice @@ -265,6 +268,7 @@ async fn test_sequential_transfers() -> eyre::Result<()> { let bob_provider = ProviderBuilder::new() .wallet(bob_signer) .connect_http(zone.http_url().clone()); + fixture.seed_no_receive_policy(charlie)?; let tip20_bob = ITIP20::new(PATH_USD_ADDRESS, &bob_provider); let pending = tip20_bob @@ -347,7 +351,8 @@ async fn test_transfer_emits_events() -> eyre::Result<()> { ) .await?; - // Transfer to Bob + // Transfer to Bob. Seed its receive-policy baseline before pool validation. + fixture.seed_no_receive_policy(bob)?; let tip20 = ITIP20::new(PATH_USD_ADDRESS, &provider); let pending = tip20 @@ -413,7 +418,8 @@ async fn test_transfer_with_memo() -> eyre::Result<()> { ) .await?; - // Transfer with memo + // Transfer with memo. Seed its receive-policy baseline before pool validation. + fixture.seed_no_receive_policy(bob)?; let tip20 = ITIP20::new(PATH_USD_ADDRESS, &provider); let pending = tip20 diff --git a/crates/node/tests/it/utils.rs b/crates/node/tests/it/utils.rs index ccdc2e70b..c32a0199c 100644 --- a/crates/node/tests/it/utils.rs +++ b/crates/node/tests/it/utils.rs @@ -25,20 +25,33 @@ use std::{ ops::Deref, pin::Pin, sync::{ - Arc, + Arc, Mutex, atomic::{AtomicU64, Ordering}, }, time::Duration, }; use tempo_alloy::TempoNetwork; -use tempo_chainspec::spec::{TEMPO_T0_BASE_FEE, TempoChainSpec}; +use tempo_chainspec::{ + hardfork::TempoHardfork, + spec::{TEMPO_T0_BASE_FEE, TempoChainSpec}, +}; use tempo_contracts::precompiles::{ - ACCOUNT_KEYCHAIN_ADDRESS, ITIP20, + ACCOUNT_KEYCHAIN_ADDRESS, ITIP20, TIP403_REGISTRY_ADDRESS, account_keychain::IAccountKeychain::{ IAccountKeychainInstance, KeyRestrictions, SignatureType as KeyInfoSignatureType, }, }; -use tempo_precompiles::{PATH_USD_ADDRESS, tip403_registry::ALLOW_ALL_POLICY_ID}; +use tempo_precompiles::{ + PATH_USD_ADDRESS, + storage::{ + Handler, PrecompileStorageProvider, StorageCtx, StorageKey, hashmap::HashMapStorageProvider, + }, + tip20::tip20_slots, + tip403_registry::{ + ALLOW_ALL_POLICY_ID, CompoundPolicyData as RawCompoundPolicyData, PolicyData, PolicyType, + TIP403Registry, tip403_registry_slots, + }, +}; use tempo_primitives::{TempoHeader, transaction::tt_signature::TempoSignature}; use tempo_zone_contracts::{ZONE_FACTORY_ADDRESS, ZONE_OUTBOX_ADDRESS}; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; @@ -310,18 +323,135 @@ fn seed_local_policy_cache(policy_cache: &zone_l1::PolicyCache) { ); } +// TODO(rusowsky): Remove once Tempo L1 stores transfer policy IDs in the TIP403 precompile. +fn pack_transfer_policy_id(policy_id: u64) -> U256 { + U256::from(policy_id) << (tip20_slots::TRANSFER_POLICY_ID_OFFSET * 8) +} + +/// Seed a TIP-20 transfer policy ID in the canonical packed L1 storage slot. +pub(crate) fn seed_raw_tip20_policy_id( + cache: &mut zone_l1::state::L1StateCacheInner, + block_number: u64, + token: Address, + policy_id: u64, +) { + let packed = pack_transfer_policy_id(policy_id); + cache.set( + token, + B256::from(tip20_slots::TRANSFER_POLICY_ID.to_be_bytes()), + block_number, + B256::from(packed.to_be_bytes()), + ); +} + +/// A TIP-403 policy write for [`seed_raw_tip403_policy`]. +pub(crate) struct PolicySeed<'a> { + pub(crate) id: u64, + pub(crate) ty: PolicyType, + pub(crate) members: &'a [(Address, bool)], + pub(crate) compound: Option<(u64, u64, u64)>, +} + +impl<'a> PolicySeed<'a> { + pub(crate) fn simple(id: u64, ty: PolicyType, members: &'a [(Address, bool)]) -> Self { + Self { + id, + ty, + members, + compound: None, + } + } + + pub(crate) fn compound(id: u64, sender: u64, recipient: u64, mint_recipient: u64) -> Self { + Self { + id, + ty: PolicyType::COMPOUND, + members: &[], + compound: Some((sender, recipient, mint_recipient)), + } + } +} + +/// Materialize one or more TIP-403 policy writes into the raw L1 cache. +/// A batch shares a single storage snapshot, so multiple policy writes can reference each other. +pub(crate) fn seed_raw_tip403_policy( + cache: &L1StateCache, + block_number: u64, + policies: &[PolicySeed<'_>], +) -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T8); + let registry = TIP403Registry::new(); + let counter_slot = registry.policy_id_counter.slot(); + let existing_next_policy_id = cache + .read() + .get(TIP403_REGISTRY_ADDRESS, counter_slot.into(), block_number) + .and_then(|value| U256::from_be_bytes(value.0).try_into().ok()) + .unwrap_or(2u64); + let mut slots = vec![counter_slot]; + for policy in policies { + slots.push(registry.policy_records[policy.id].base.base_slot()); + if policy.compound.is_some() { + slots.push(registry.policy_records[policy.id].compound.base_slot()); + } + slots.extend( + policy + .members + .iter() + .map(|(account, _)| registry.policy_set[policy.id][*account].slot()), + ); + } + + StorageCtx::enter(&mut storage, || -> tempo_precompiles::Result<()> { + let mut registry = TIP403Registry::new(); + let next_policy_id = policies + .iter() + .map(|policy| policy.id + 1) + .max() + .unwrap_or(2) + .max(existing_next_policy_id); + registry.policy_id_counter.write(next_policy_id)?; + for policy in policies { + registry.policy_records[policy.id].base.write(PolicyData { + policy_type: policy.ty as u8, + admin: Address::ZERO, + })?; + if let Some((sender, recipient, mint_recipient)) = policy.compound { + registry.policy_records[policy.id] + .compound + .write(RawCompoundPolicyData { + sender_policy_id: sender, + recipient_policy_id: recipient, + mint_recipient_policy_id: mint_recipient, + })?; + } + for &(account, in_set) in policy.members { + registry.policy_set[policy.id][account].write(in_set)?; + } + } + Ok(()) + })?; + + let mut cache = cache.write(); + for slot in slots { + let value = storage.sload(TIP403_REGISTRY_ADDRESS, slot)?; + cache.set( + TIP403_REGISTRY_ADDRESS, + slot.into(), + block_number, + value.into(), + ); + } + Ok(()) +} + /// Compute the TIP-20 token address for a given sender and salt. /// /// Mirrors `compute_tip20_address` in the factory precompile. pub(crate) fn compute_tip20_address(sender: Address, salt: B256) -> Address { let hash = keccak256((sender, salt).abi_encode()); - let tip20_prefix: [u8; 12] = [ - 0x20, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - ]; - let mut address_bytes = [0u8; 20]; - address_bytes[..12].copy_from_slice(&tip20_prefix); + address_bytes[..12].copy_from_slice(&tempo_primitives::transaction::TIP20_PAYMENT_PREFIX); address_bytes[12..].copy_from_slice(&hash[..8]); Address::from(address_bytes) @@ -814,7 +944,9 @@ impl ZoneTestNode { ) .with_withdrawal_batch_interval_blocks(withdrawal_batch_interval_blocks); if is_local_dummy_l1 { - zone_node = zone_node.with_l1_chain_id(1337); + zone_node = zone_node + .with_l1_chain_id(1337) + .with_l1_state_provider_retry_limits(0, 1); } if let Some(initial_tokens) = initial_tokens { zone_node = zone_node.with_initial_tokens(initial_tokens); @@ -849,6 +981,8 @@ impl ZoneTestNode { let policy_cache = zone_node.policy_cache(); if is_local_dummy_l1 { seed_local_policy_cache(&policy_cache); + let mut cache = l1_state_cache.write(); + seed_raw_tip20_policy_id(&mut cache, 0, PATH_USD_ADDRESS, ALLOW_ALL_POLICY_ID); } let node_handle = NodeBuilder::new(node_config) @@ -3495,6 +3629,8 @@ pub(crate) struct L1Fixture { next_block_number: u64, next_timestamp: u64, last_hash: B256, + /// Raw L1 caches seeded by this fixture, updated with state implied by injected deposits. + caches: Mutex>, } impl L1Fixture { @@ -3510,6 +3646,7 @@ impl L1Fixture { next_block_number: 1, next_timestamp: 1_000_000, last_hash: genesis_hash, + caches: Mutex::new(Vec::new()), } } @@ -3521,16 +3658,27 @@ impl L1Fixture { /// for each block we plan to inject. pub(crate) fn seed_l1_cache( &self, - cache: &L1StateCache, + cache_handle: &L1StateCache, portal_address: Address, sequencer: Address, num_blocks: u64, ) { - let mut cache = cache.write(); + let mut cache = cache_handle.write(); let deposit_queue_hash_slot = B256::with_last_byte(5); let refunds_slot = B256::with_last_byte(10); let path_usd_config_slot = portal_token_config_slot(PATH_USD_ADDRESS); let enabled_token_config = enabled_deposits_active_token_config(); + let outbox_receive_policy_slot = + ZONE_OUTBOX_ADDRESS.mapping_slot(tip403_registry_slots::RECEIVE_POLICIES); + + // Local fixtures have no RPC fallback. A withdrawal transfers to the outbox, so seed the + // absence of its address-level receive policy as baseline raw L1 state. + cache.set( + TIP403_REGISTRY_ADDRESS, + B256::from(outbox_receive_policy_slot.to_be_bytes()), + 0, + B256::ZERO, + ); for block in 0..=num_blocks { let mut sequencer_bytes = [0u8; 32]; @@ -3557,10 +3705,54 @@ impl L1Fixture { ); } + seed_raw_tip20_policy_id(&mut cache, 0, PATH_USD_ADDRESS, ALLOW_ALL_POLICY_ID); cache.update_anchor(NumHash { number: num_blocks, hash: B256::ZERO, }); + drop(cache); + self.caches.lock().unwrap().push(cache_handle.clone()); + } + + /// Seed the absence of an address-level TIP-403 receive policy at the current Zone anchor. + pub(crate) fn seed_no_receive_policy(&self, recipient: Address) -> eyre::Result<()> { + let current_anchor = self.next_block_number.saturating_sub(1); + self.seed_no_receive_policy_at(current_anchor, recipient) + } + + fn seed_no_receive_policy_at(&self, block_number: u64, recipient: Address) -> eyre::Result<()> { + // TODO(rusowsky): make `ReceivePolicy` public upstream to use the handlers + let receive_policy_slot = recipient.mapping_slot(tip403_registry_slots::RECEIVE_POLICIES); + for cache in self.caches.lock().unwrap().iter() { + cache.write().set( + TIP403_REGISTRY_ADDRESS, + B256::from(receive_policy_slot.to_be_bytes()), + block_number, + B256::ZERO, + ); + } + Ok(()) + } + + fn seed_regular_deposit_policy_state(&self, block_number: u64, deposits: &[Deposit]) { + for deposit in deposits { + self.seed_no_receive_policy_at(block_number, deposit.to) + .expect("deposit receive-policy fixture seed must be admitted"); + } + } + + fn seed_enabled_token_policy_state(&self, block_number: u64, tokens: &[EnabledToken]) { + for cache in self.caches.lock().unwrap().iter() { + let mut cache = cache.write(); + for token in tokens { + seed_raw_tip20_policy_id( + &mut cache, + block_number, + token.token, + ALLOW_ALL_POLICY_ID, + ); + } + } } /// Build a [`TempoHeader`] for the next L1 block. @@ -3606,6 +3798,7 @@ impl L1Fixture { queue: &DepositQueue, deposits: Vec, ) { + self.seed_regular_deposit_policy_state(block.header.inner.number, &deposits); let l1_deposits = deposits.into_iter().map(L1Deposit::Regular).collect(); let events = L1PortalEvents::from_deposits(l1_deposits); queue.enqueue(block.header.clone(), events, vec![]); @@ -3618,6 +3811,14 @@ impl L1Fixture { queue: &DepositQueue, events: L1PortalEvents, ) { + let block_number = block.header.inner.number; + self.seed_enabled_token_policy_state(block_number, &events.enabled_tokens); + for deposit in &events.deposits { + if let L1Deposit::Regular(deposit) = deposit { + self.seed_no_receive_policy_at(block_number, deposit.to) + .expect("event receive-policy fixture seed must be admitted"); + } + } queue.enqueue(block.header.clone(), events, vec![]); } @@ -3646,6 +3847,7 @@ impl L1Fixture { tokens: Vec, ) { let header = self.next_header(); + self.seed_enabled_token_policy_state(header.inner.number, &tokens); let events = L1PortalEvents { deposits: vec![], enabled_tokens: tokens, @@ -3670,6 +3872,7 @@ impl L1Fixture { /// Inject an L1 block with the given deposits into the queue. pub(crate) fn inject_deposits(&mut self, queue: &DepositQueue, deposits: Vec) { let header = self.next_header(); + self.seed_regular_deposit_policy_state(header.inner.number, &deposits); let l1_deposits = deposits.into_iter().map(L1Deposit::Regular).collect(); let events = L1PortalEvents::from_deposits(l1_deposits); queue.enqueue(header, events, vec![]); diff --git a/crates/precompiles/Cargo.toml b/crates/precompiles/Cargo.toml index be4d51412..599d7f63d 100644 --- a/crates/precompiles/Cargo.toml +++ b/crates/precompiles/Cargo.toml @@ -47,6 +47,7 @@ tracing = { version = "0.1.41", default-features = false } [dev-dependencies] const-hex.workspace = true +eyre.workspace = true tempo-precompiles = { workspace = true, features = ["test-utils"] } [features] diff --git a/crates/precompiles/src/aes_gcm/mod.rs b/crates/precompiles/src/aes_gcm/mod.rs index 1960972b7..03c7c387c 100644 --- a/crates/precompiles/src/aes_gcm/mod.rs +++ b/crates/precompiles/src/aes_gcm/mod.rs @@ -3,9 +3,9 @@ //! Registered at [`AES_GCM_DECRYPT_ADDRESS`] (`0x1C00...0101`). //! //! Decrypts ECIES ciphertext and verifies the GCM authentication tag, -//! enabling the [`ZoneInbox`] contract to process encrypted deposits. +//! enabling the `ZoneInbox` contract to process encrypted deposits. //! -//! Uses the NCC-audited [`aes-gcm`] crate (v0.10.3). +//! Uses the NCC-audited `aes-gcm` crate (v0.10.3). use alloc::vec::Vec; @@ -15,9 +15,7 @@ use aes_gcm::{ }; mod dispatch; -use alloy_evm::precompiles::DynPrecompile; use alloy_primitives::{Address, address}; -use revm::precompile::PrecompileId; /// AES-256-GCM Decrypt precompile address on Zone L2. pub const AES_GCM_DECRYPT_ADDRESS: Address = address!("0x1C00000000000000000000000000000000000101"); @@ -50,39 +48,6 @@ pub use IAesGcmDecrypt::{decryptCall, decryptReturn}; /// `(empty, false)` if tag verification fails. pub struct AesGcmDecrypt; -impl AesGcmDecrypt { - /// Wrap this precompile in a [`DynPrecompile`] with the Tempo storage context - /// required by the upstream dispatch macro. - pub fn create( - cfg: &revm::context::CfgEnv, - ) -> DynPrecompile { - use tempo_precompiles::{ - Precompile as _, - storage::{StorageCtx, evm::EvmPrecompileStorageProvider}, - }; - - let spec = cfg.spec; - let amsterdam_eip8037_enabled = cfg.enable_amsterdam_eip8037; - let gas_params = cfg.gas_params.clone(); - DynPrecompile::new_stateful(PrecompileId::Custom("AesGcmDecrypt".into()), move |input| { - let mut storage = EvmPrecompileStorageProvider::new( - input.internals, - input.gas, - input.reservoir, - spec, - amsterdam_eip8037_enabled, - input.is_static, - gas_params.clone(), - ); - - StorageCtx::enter(&mut storage, || { - let mut precompile = Self; - precompile.call(input.data, input.caller) - }) - }) - } -} - /// Decrypt AES-256-GCM ciphertext with tag verification. /// /// The ciphertext, AAD, and tag are passed separately (matching the Solidity interface). @@ -117,33 +82,11 @@ pub fn decrypt_aes_gcm( #[cfg(test)] mod tests { use super::*; - use alloy_evm::{ - EvmInternals, - precompiles::{Precompile, PrecompileInput}, - }; - use alloy_primitives::{Bytes, U256}; + use crate::test_utils::{test_context, test_env, test_storage_provider}; + use alloy_primitives::Bytes; use alloy_sol_types::SolCall; - use revm::{ - Context, - database::{CacheDB, EmptyDB}, - precompile::PrecompileOutput, - }; - use tempo_chainspec::hardfork::TempoHardfork; - use tempo_precompiles::{ - charge_input_cost, - storage::{StorageCtx, evm::EvmPrecompileStorageProvider}, - }; - - type TestContext = Context< - revm::context::BlockEnv, - revm::context::TxEnv, - revm::context::CfgEnv, - CacheDB, - >; - - fn test_context() -> TestContext { - Context::new(CacheDB::new(EmptyDB::new()), TempoHardfork::default()) - } + use revm::precompile::PrecompileOutput; + use tempo_precompiles::{charge_input_cost, storage::StorageCtx}; fn encrypt(plaintext: &[u8], aad: &[u8]) -> decryptCall { let key = [0x42u8; 32]; @@ -173,34 +116,23 @@ mod tests { fn call_precompile(calldata: Bytes) -> PrecompileOutput { let mut ctx = test_context(); - let cfg = revm::context::CfgEnv::::default(); - AesGcmDecrypt::create(&cfg) - .call(PrecompileInput { - data: &calldata, - gas: u64::MAX, - reservoir: 0, - caller: Address::ZERO, - value: U256::ZERO, - target_address: AES_GCM_DECRYPT_ADDRESS, - is_static: true, - bytecode_address: AES_GCM_DECRYPT_ADDRESS, - internals: EvmInternals::from_context(&mut ctx), - }) - .expect("precompile call succeeds") + let precompile = AesGcmDecrypt::create(&test_env(&ctx)); + crate::test_utils::call_precompile( + &mut ctx, + &precompile, + Address::ZERO, + &calldata, + u64::MAX, + true, + AES_GCM_DECRYPT_ADDRESS, + AES_GCM_DECRYPT_ADDRESS, + ) + .expect("precompile call succeeds") } fn charged_input_gas(calldata: &[u8]) -> u64 { let mut ctx = test_context(); - let cfg = revm::context::CfgEnv::::default(); - let mut provider = EvmPrecompileStorageProvider::new( - EvmInternals::from_context(&mut ctx), - u64::MAX, - 0, - cfg.spec, - cfg.enable_amsterdam_eip8037, - true, - cfg.gas_params, - ); + let mut provider = test_storage_provider(&mut ctx, u64::MAX, true); StorageCtx::enter(&mut provider, || { let mut storage = StorageCtx::default(); let gas_before = storage.gas_used(); diff --git a/crates/precompiles/src/chaum_pedersen/mod.rs b/crates/precompiles/src/chaum_pedersen/mod.rs index bc05d3548..bb175a60a 100644 --- a/crates/precompiles/src/chaum_pedersen/mod.rs +++ b/crates/precompiles/src/chaum_pedersen/mod.rs @@ -12,7 +12,6 @@ use alloc::vec::Vec; mod dispatch; -use alloy_evm::precompiles::DynPrecompile; use alloy_primitives::{Address, address}; use k256::{ AffinePoint, ProjectivePoint, Scalar, @@ -21,8 +20,6 @@ use k256::{ sec1::{FromEncodedPoint, ToEncodedPoint}, }, }; -use revm::precompile::PrecompileId; - /// Chaum-Pedersen Verify precompile address on Zone L2. pub const CHAUM_PEDERSEN_VERIFY_ADDRESS: Address = address!("0x1C00000000000000000000000000000000000100"); @@ -66,42 +63,6 @@ pub use IChaumPedersenVerify::verifyProofCall; /// - Check: `c == c'` pub struct ChaumPedersenVerify; -impl ChaumPedersenVerify { - /// Wrap this precompile in a [`DynPrecompile`] with the Tempo storage context - /// required by the upstream dispatch macro. - pub fn create( - cfg: &revm::context::CfgEnv, - ) -> DynPrecompile { - use tempo_precompiles::{ - Precompile as _, - storage::{StorageCtx, evm::EvmPrecompileStorageProvider}, - }; - - let spec = cfg.spec; - let amsterdam_eip8037_enabled = cfg.enable_amsterdam_eip8037; - let gas_params = cfg.gas_params.clone(); - DynPrecompile::new_stateful( - PrecompileId::Custom("ChaumPedersenVerify".into()), - move |input| { - let mut storage = EvmPrecompileStorageProvider::new( - input.internals, - input.gas, - input.reservoir, - spec, - amsterdam_eip8037_enabled, - input.is_static, - gas_params.clone(), - ); - - StorageCtx::enter(&mut storage, || { - let mut precompile = Self; - precompile.call(input.data, input.caller) - }) - }, - ) - } -} - /// Recover a secp256k1 affine point from compressed form (x coordinate + y parity). /// /// `y_parity` follows SEC1: `0x02` for even y, `0x03` for odd y. diff --git a/crates/precompiles/src/ecies.rs b/crates/precompiles/src/ecies.rs index d1f6e6770..afd835bc9 100644 --- a/crates/precompiles/src/ecies.rs +++ b/crates/precompiles/src/ecies.rs @@ -1,7 +1,7 @@ //! Sequencer-side ECIES operations for encrypted deposit decryption. //! //! These functions run **off-chain** in the payload builder to produce the -//! [`DecryptionData`] that the on-chain ZoneInbox contract verifies via the +//! `DecryptionData` that the on-chain ZoneInbox contract verifies via the //! Chaum-Pedersen and AES-GCM precompiles. use alloc::vec::Vec; diff --git a/crates/precompiles/src/execution.rs b/crates/precompiles/src/execution.rs new file mode 100644 index 000000000..3cefc0414 --- /dev/null +++ b/crates/precompiles/src/execution.rs @@ -0,0 +1,348 @@ +//! Shared execution for Zone-native and upstream Tempo precompiles. +//! +//! Every Zone wrapper installs the same EVM-backed [`StorageCtx`], applies Zone-specific +//! [`CallRules`], and forwards admitted calls without changing their calldata or caller. +//! The EVM database decides whether each storage read is local or resolved from L1-anchored state. +//! +//! # Call ordering +//! +//! 1. Direct-call-only rules reject delegate calls before storage access. +//! 2. Decode the selector and reject calls that cannot cover a configured fixed gas charge. +//! 3. Apply pure [`CallRules`] admission checks using calldata and caller metadata. +//! 4. Forward the original calldata and caller, applying any configured fixed gas charge. +//! +//! Admission-rule rejections include calldata input gas, while early delegate-call rejection is +//! unmetered. Calls without a fixed charge retain normal provider metering, and successful +//! fixed-price calls report exactly the configured charge. + +use alloc::rc::Rc; +use core::cell::RefCell; + +use alloy_evm::precompiles::DynPrecompile; +use alloy_primitives::{Address, Bytes}; +use alloy_sol_types::SolError; +use revm::precompile::{PrecompileHalt, PrecompileId, PrecompileOutput, PrecompileResult}; +use tempo_chainspec::hardfork::TempoHardfork; +use tempo_precompiles::{ + DelegateCallNotAllowed, charge_input_cost, + dispatch::selector_from_calldata, + storage::{StorageCtx, actions::StorageActions, evm::EvmPrecompileStorageProvider}, + storage_credits::NonCreditableSlots, +}; + +/// Shared EVM configuration and accounting state installed for every Zone precompile wrapper. +#[derive(Clone)] +pub struct ZonePrecompileEnv { + cfg: revm::context::CfgEnv, + actions: StorageActions, + non_creditable_slots: Rc>, +} + +impl ZonePrecompileEnv { + /// Captures the active EVM configuration and transaction-local storage accounting state. + pub fn new( + cfg: &revm::context::CfgEnv, + actions: StorageActions, + non_creditable_slots: Rc>, + ) -> Self { + Self { + cfg: cfg.clone(), + actions, + non_creditable_slots, + } + } +} + +/// Result of applying zone-specific pre-execution rules. +pub(crate) enum CallCheck { + /// Invoke the supplied precompile implementation. + Continue, + /// Revert with ABI-encoded data. The execution wrapper MUST apply input gas and reservoir. + Revert(Bytes), +} + +/// Pure, selector and caller dependent, precompile call rules evaluated before storage setup. +/// +/// Rules may enforce admission policy and duplicate cheap business checks as fail-fast preflight. +/// All state access remains in the implementation and resolves through the EVM database adapter. +pub(crate) trait CallRules: 'static { + /// Returns whether this precompile accepts delegate calls. + fn is_delegate_call_allowed(&self) -> bool { + false + } + + /// Return the fixed gas charge for this selector, if one applies. + fn fixed_gas(&self, _selector: Option<[u8; 4]>) -> Option { + None + } + + /// Applies pure Zone-specific admission rules before storage setup. + fn admit(&self, _data: &[u8], _caller: Address) -> CallCheck { + CallCheck::Continue + } +} + +/// Rules for precompiles whose semantics require execution at their registered address. +pub(crate) struct DirectCallOnly; +impl CallRules for DirectCallOnly {} + +/// Rules for precompiles that allow delegate calls without additional admission checks. +pub(crate) struct NoCallRules; +impl CallRules for NoCallRules { + fn is_delegate_call_allowed(&self) -> bool { + true + } +} + +pub(crate) fn create_precompile( + id: &'static str, + env: &ZonePrecompileEnv, + rules: impl CallRules, + execute: impl Fn(&[u8], Address) -> PrecompileResult + 'static, +) -> DynPrecompile { + let env = env.clone(); + DynPrecompile::new_stateful(PrecompileId::Custom(id.into()), move |input| { + if !rules.is_delegate_call_allowed() && !input.is_direct_call() { + return Ok(PrecompileOutput::revert( + 0, + SolError::abi_encode(&DelegateCallNotAllowed {}).into(), + input.reservoir, + )); + } + + let (data, caller) = (input.data, input.caller); + let fixed_gas = rules.fixed_gas(selector_from_calldata(data)); + if fixed_gas.is_some_and(|gas| input.gas < gas) { + return Ok(PrecompileOutput::halt( + PrecompileHalt::OutOfGas, + input.reservoir, + )); + } + + let mut storage = EvmPrecompileStorageProvider::new( + input.internals, + fixed_gas.map_or(input.gas, |_| u64::MAX), + input.reservoir, + env.cfg.spec, + env.cfg.enable_amsterdam_eip8037, + input.is_static, + env.cfg.gas_params.clone(), + ) + .with_actions(env.actions.clone()) + .with_non_creditable_slots(env.non_creditable_slots.clone()); + + let mut result = StorageCtx::enter(&mut storage, || match rules.admit(data, caller) { + CallCheck::Continue => execute(data, caller), + CallCheck::Revert(output) => { + let s = StorageCtx::default(); + let output = s.revert_output(output); + add_input_cost(s, data, Ok(output)) + } + }); + if let (Ok(output), Some(gas)) = (&mut result, fixed_gas) { + output.gas_used = gas; + } + result + }) +} + +fn add_input_cost(mut s: StorageCtx, data: &[u8], mut res: PrecompileResult) -> PrecompileResult { + let gas_before = s.gas_used(); + if let Some(err) = charge_input_cost(&mut s, data) { + return err; + } + if let Ok(output) = &mut res { + let input_gas = s.gas_used().saturating_sub(gas_before); + output.gas_used = output.gas_used.saturating_add(input_gas); + } + res +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::{test_context, test_storage_provider}; + use alloy_evm::{ + EvmInternals, + precompiles::{Precompile as _, PrecompileInput}, + }; + use alloy_primitives::{Bytes, U256}; + use std::{ + cell::{Cell, RefCell}, + rc::Rc, + }; + + const FIXED_GAS: u64 = 123; + type RuleRecord = Rc, Address)>>>; + + struct RecordingRules(RuleRecord); + + impl CallRules for RecordingRules { + fn fixed_gas(&self, _selector: Option<[u8; 4]>) -> Option { + Some(FIXED_GAS) + } + + fn admit(&self, data: &[u8], caller: Address) -> CallCheck { + *self.0.borrow_mut() = Some(( + Bytes::copy_from_slice(data), + selector_from_calldata(data), + caller, + )); + CallCheck::Continue + } + } + + fn input<'a>( + ctx: &'a mut crate::test_utils::TestContext, + data: &'a [u8], + caller: Address, + gas: u64, + ) -> PrecompileInput<'a> { + let target = Address::repeat_byte(0x11); + PrecompileInput { + data, + gas, + reservoir: 0, + caller, + value: U256::ZERO, + target_address: target, + is_static: false, + bytecode_address: target, + internals: EvmInternals::from_context(ctx), + } + } + + #[test] + fn forwards_original_call_applies_rules_and_restores_storage_context() { + let recorded_rule = Rc::new(RefCell::new(None)); + let recorded_execute = Rc::new(RefCell::new(None)); + let execute_record = recorded_execute.clone(); + let cfg = revm::context::CfgEnv::::default(); + let env = ZonePrecompileEnv::new( + &cfg, + StorageActions::disabled(), + Rc::new(RefCell::new(NonCreditableSlots::empty())), + ); + let precompile = create_precompile( + "ForwardingTest", + &env, + RecordingRules(recorded_rule.clone()), + move |data, caller| { + *execute_record.borrow_mut() = Some((Bytes::copy_from_slice(data), caller)); + Ok(StorageCtx::default().success_output(Bytes::new())) + }, + ); + + let mut outer_ctx = test_context(); + let mut inner_ctx = test_context(); + let mut outer = test_storage_provider(&mut outer_ctx, 777, false); + let calldata = [0xde, 0xad, 0xbe, 0xef, 0x01]; + let caller = Address::repeat_byte(0x22); + let output = StorageCtx::enter(&mut outer, || { + let output = precompile + .call(input(&mut inner_ctx, &calldata, caller, FIXED_GAS)) + .unwrap(); + assert_eq!(StorageCtx::default().gas_limit(), 777); + output + }); + + assert_eq!(output.gas_used, FIXED_GAS); + assert_eq!( + *recorded_rule.borrow(), + Some((calldata.into(), Some([0xde, 0xad, 0xbe, 0xef]), caller)) + ); + assert_eq!(*recorded_execute.borrow(), Some((calldata.into(), caller))); + } + + #[test] + fn protocol_precompile_applies_admission_and_evm_spec() { + let observed_spec = Rc::new(Cell::new(None)); + let execute_spec = observed_spec.clone(); + let mut cfg = revm::context::CfgEnv::::default(); + cfg.spec = TempoHardfork::T8; + let env = ZonePrecompileEnv::new( + &cfg, + StorageActions::disabled(), + Rc::new(RefCell::new(NonCreditableSlots::empty())), + ); + let checked = Rc::new(Cell::new(false)); + let rejected = create_precompile( + "L1AdmissionTest", + &env, + RejectRules(checked.clone()), + |_, _| panic!("rejected call must not execute"), + ); + let mut ctx = test_context(); + assert!( + rejected + .call(input(&mut ctx, &[1, 2, 3, 4], Address::ZERO, FIXED_GAS)) + .unwrap() + .is_revert() + ); + assert!(checked.get()); + + let precompile = create_precompile("ProtocolTest", &env, NoCallRules, move |_, _| { + execute_spec.set(Some(StorageCtx::default().spec())); + Ok(StorageCtx::default().success_output(Bytes::new())) + }); + + precompile + .call(input(&mut ctx, &[], Address::ZERO, u64::MAX)) + .unwrap(); + + assert_eq!(observed_spec.get(), Some(TempoHardfork::T8)); + } + + struct RejectRules(Rc>); + + impl CallRules for RejectRules { + fn fixed_gas(&self, _selector: Option<[u8; 4]>) -> Option { + Some(FIXED_GAS) + } + + fn admit(&self, _data: &[u8], _caller: Address) -> CallCheck { + self.0.set(true); + CallCheck::Revert(Bytes::from_static(b"denied")) + } + } + + #[test] + fn admission_and_fixed_gas_run_before_forwarded_execution() { + let checked = Rc::new(Cell::new(false)); + let executed = Rc::new(Cell::new(false)); + let execute_flag = executed.clone(); + let cfg = revm::context::CfgEnv::::default(); + let env = ZonePrecompileEnv::new( + &cfg, + StorageActions::disabled(), + Rc::new(RefCell::new(NonCreditableSlots::empty())), + ); + let precompile = create_precompile( + "AdmissionTest", + &env, + RejectRules(checked.clone()), + move |_, _| { + execute_flag.set(true); + Ok(StorageCtx::default().success_output(Bytes::new())) + }, + ); + let mut ctx = test_context(); + let calldata = [1, 2, 3, 4]; + + let out_of_gas = precompile + .call(input(&mut ctx, &calldata, Address::ZERO, FIXED_GAS - 1)) + .unwrap(); + assert!(out_of_gas.is_halt()); + assert_eq!(out_of_gas.halt_reason(), Some(&PrecompileHalt::OutOfGas)); + assert!(!checked.get()); + assert!(!executed.get()); + + let rejected = precompile + .call(input(&mut ctx, &calldata, Address::ZERO, FIXED_GAS)) + .unwrap(); + assert!(checked.get()); + assert!(!executed.get()); + assert_eq!(rejected.gas_used, FIXED_GAS); + assert_eq!(rejected.bytes, Bytes::from_static(b"denied")); + } +} diff --git a/crates/precompiles/src/lib.rs b/crates/precompiles/src/lib.rs index 32427d908..f04547c2d 100644 --- a/crates/precompiles/src/lib.rs +++ b/crates/precompiles/src/lib.rs @@ -1,4 +1,9 @@ -//! Zone-specific precompile implementations. +//! Zone-native precompiles and shared execution for Tempo precompiles on a Zone. +//! +//! All implementations use ordinary EVM storage. The Zone EVM installs an anchored database below +//! the revm journal, so upstream TIP-20, TIP-403, and fee-manager code transparently observe +//! finalized Tempo policy state. Zone admission, delegate-call, fixed-gas, and privacy rules remain +//! outside the forwarded business logic. //! //! This crate is `no_std` compatible so these precompiles can run inside the //! SP1 prover guest (RISC-V) as well as in the zone node. @@ -14,17 +19,14 @@ //! ## Policy/token precompiles //! //! - **TIP-20 Factory** ([`tip20_factory`]) — zone-side TIP-20 token factory. -//! - **TIP-403 Proxy** ([`tip403_proxy`]) — read-only TIP-403 registry proxy. -//! - **Zone TIP-20** ([`ztip20`]) — policy-aware TIP-20 wrapper. +//! - **TIP-403 Registry** ([`tip403_proxy`]) — upstream registry over finalized L1 state. +//! - **Zone TIP-20** ([`ztip20`]) — upstream TIP-20 with zone call rules. #![cfg_attr(not(feature = "std"), no_std)] #![allow(clippy::too_many_arguments)] extern crate alloc; -// Required by the `#[contract]` proc macro expansion (references `crate::storage`). -pub(crate) use tempo_precompiles::storage; - pub mod error; pub use error::{Result, ZonePrecompileError, ZoneResult}; @@ -40,7 +42,9 @@ pub mod dispatch { }; } -pub mod policy; +mod execution; +pub use execution::ZonePrecompileEnv; +pub mod storage; pub mod tempo_state; pub mod tip20_factory; pub mod tip403_proxy; @@ -48,12 +52,173 @@ pub mod ztip20; pub use aes_gcm::{AES_GCM_DECRYPT_ADDRESS, AesGcmDecrypt}; pub use chaum_pedersen::{CHAUM_PEDERSEN_VERIFY_ADDRESS, ChaumPedersenVerify}; -pub use tempo_state::{L1StorageReader, TempoState}; +pub use storage::{L1AnchorController, L1StorageReader}; +pub use tempo_contracts::precompiles::TIP403_REGISTRY_ADDRESS; +pub use tempo_state::TempoState; pub use tip20_factory::{ZONE_TIP20_FACTORY_ADDRESS, ZoneTokenFactory}; -pub use tip403_proxy::{ZONE_TIP403_PROXY_ADDRESS, ZoneTip403ProxyRegistry}; -pub use ztip20::{SequencerExt, ZoneTip20Token}; +pub use tip403_proxy::ZONE_TIP403_PROXY_ADDRESS; +pub use ztip20::SequencerExt; + +use alloc::{rc::Rc, sync::Arc}; +use core::cell::RefCell; + +use alloy_evm::precompiles::{DynPrecompile, PrecompilesMap}; +use revm::{context::CfgEnv, precompile::PrecompileError}; +use tempo_chainspec::hardfork::TempoHardfork; +use tempo_precompiles::{ + ACCOUNT_KEYCHAIN_ADDRESS, NONCE_PRECOMPILE_ADDRESS, Precompile as _, PrecompileEnv, + STABLECOIN_DEX_ADDRESS, TIP_FEE_MANAGER_ADDRESS, + account_keychain::AccountKeychain, + nonce::NonceManager, + storage::actions::StorageActions, + storage_credits::NonCreditableSlots, + tip_fee_manager::TipFeeManager, + tip20::{TIP20Token, is_tip20_prefix}, + tip403_registry::TIP403Registry, +}; +use zone_primitives::constants::TEMPO_STATE_ADDRESS; + +/// Registers Zone-native and supported Tempo precompiles. +/// +/// Every Zone wrapper receives the same [`ZonePrecompileEnv`]. Storage remains ordinary EVM +/// storage: the Zone database adapter transparently resolves mirrored TIP-20/TIP-403 reads at the +/// active Tempo anchor, while all other slots remain Zone-local. `TempoState` keeps its explicit L1 +/// reader for the system-only arbitrary-storage read ABI. +pub fn extend_zone_precompiles( + precompiles: &mut PrecompilesMap, + cfg: &CfgEnv, + l1_reader: L1, + sequencer: Arc, + actions: StorageActions, + non_creditable_slots: Rc>, + controller: L1AnchorController, +) { + let env = ZonePrecompileEnv::new(cfg, actions.clone(), non_creditable_slots.clone()); + let tempo_env = PrecompileEnv::new(cfg, actions, non_creditable_slots); -use revm::precompile::PrecompileError; + precompiles.apply_precompile(&TEMPO_STATE_ADDRESS, |_| { + Some(TempoState::create( + l1_reader.clone(), + controller.clone(), + &env, + )) + }); + precompiles.apply_precompile(&CHAUM_PEDERSEN_VERIFY_ADDRESS, |_| { + Some(ChaumPedersenVerify::create(&env)) + }); + precompiles.apply_precompile(&AES_GCM_DECRYPT_ADDRESS, |_| { + Some(AesGcmDecrypt::create(&env)) + }); + precompiles.apply_precompile(&ZONE_TIP20_FACTORY_ADDRESS, |_| { + Some(ZoneTokenFactory::create(&env)) + }); + + let tip403_env = env.clone(); + precompiles.apply_precompile(&ZONE_TIP403_PROXY_ADDRESS, move |_| { + Some(create_tip403_precompile(&tip403_env)) + }); + + // Static zone entries above take priority. Dynamic TIP-20 entries use upstream execution with + // zone privacy, bridge-authorization, fixed-gas, and anchored policy-storage rules. + precompiles.set_precompile_lookup(move |address: &alloy_primitives::Address| { + if is_tip20_prefix(*address) { + Some(create_tip20_precompile(*address, &env, sequencer.clone())) + } else if *address == TIP_FEE_MANAGER_ADDRESS { + Some(execution::create_precompile( + "TipFeeManager", + &env, + execution::DirectCallOnly, + |data, caller| TipFeeManager::new().call(data, caller), + )) + } else if *address == STABLECOIN_DEX_ADDRESS { + None + } else if *address == NONCE_PRECOMPILE_ADDRESS { + Some(NonceManager::create_precompile(&tempo_env)) + } else if *address == ACCOUNT_KEYCHAIN_ADDRESS { + Some(AccountKeychain::create_precompile(&tempo_env)) + } else { + None + } + }); +} + +impl AesGcmDecrypt { + /// Creates the AES-GCM precompile with the shared zone execution environment. + pub fn create(env: &ZonePrecompileEnv) -> DynPrecompile { + execution::create_precompile( + "AesGcmDecrypt", + env, + execution::NoCallRules, + |data, caller| Self.call(data, caller), + ) + } +} + +impl ChaumPedersenVerify { + /// Creates the Chaum-Pedersen precompile with the shared zone execution environment. + pub fn create(env: &ZonePrecompileEnv) -> DynPrecompile { + execution::create_precompile( + "ChaumPedersenVerify", + env, + execution::NoCallRules, + |data, caller| Self.call(data, caller), + ) + } +} + +impl TempoState { + /// Creates the direct-call-only `TempoState` precompile with checkpoint storage. + /// + /// System-only arbitrary L1 storage reads are delegated to `reader` at the stored checkpoint. + pub fn create( + reader: P, + controller: L1AnchorController, + env: &ZonePrecompileEnv, + ) -> DynPrecompile { + execution::create_precompile( + "TempoState", + env, + execution::DirectCallOnly, + move |data, caller| Self::new().call_with_provider(&reader, &controller, data, caller), + ) + } +} + +impl ZoneTokenFactory { + /// Creates the direct-call-only token factory with zone-local storage and execution. + pub fn create(env: &ZonePrecompileEnv) -> DynPrecompile { + execution::create_precompile( + "ZoneTokenFactory", + env, + execution::DirectCallOnly, + |data, caller| Self::new().call(data, caller), + ) + } +} + +/// Creates upstream TIP-403 execution with zone read-only rules and adapter-backed L1 reads. +pub(crate) fn create_tip403_precompile(env: &ZonePrecompileEnv) -> DynPrecompile { + execution::create_precompile( + "ZoneTip403Registry", + env, + tip403_proxy::Tip403Rules, + |data, caller| TIP403Registry::new().call(data, caller), + ) +} + +/// Creates upstream TIP-20 execution with zone rules and adapter-backed L1 policy reads. +pub(crate) fn create_tip20_precompile( + address: alloy_primitives::Address, + env: &ZonePrecompileEnv, + sequencer: Arc, +) -> DynPrecompile { + execution::create_precompile( + "TIP20Token", + env, + ztip20::TIP20Rules::new(sequencer), + move |data, caller| TIP20Token::from_address_unchecked(address).call(data, caller), + ) +} const ZONE_RPC_ERROR_PREFIX: &str = "[zone rpc]"; @@ -65,10 +230,11 @@ pub fn zone_rpc_error(msg: impl core::fmt::Display) -> PrecompileError { PrecompileError::Fatal(alloc::format!("{ZONE_RPC_ERROR_PREFIX} {msg}")) } -/// Returns `true` if the error string was produced by [`zone_rpc_error`]. +/// Returns `true` if the error chain contains a failure produced by [`zone_rpc_error`]. pub fn is_zone_rpc_error(err: &str) -> bool { - err.starts_with(ZONE_RPC_ERROR_PREFIX) + err.contains(ZONE_RPC_ERROR_PREFIX) } -#[cfg(test)] -mod test_utils; +#[cfg(any(test, feature = "test-utils"))] +#[doc(hidden)] +pub mod test_utils; diff --git a/crates/precompiles/src/policy.rs b/crates/precompiles/src/policy.rs deleted file mode 100644 index f88c8f152..000000000 --- a/crates/precompiles/src/policy.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Policy authorization trait for zone precompiles. -//! -//! Defines [`PolicyCheck`], an abstraction over the concrete `PolicyProvider` -//! so that the policy/token precompiles in this crate don't depend on tokio, -//! alloy providers, or any std-only infrastructure. - -use alloy_primitives::Address; -use revm::precompile::PrecompileError; -use zone_primitives::policy::AuthRole; - -/// Authorization provider used by the TIP-403 proxy and zone TIP-20 precompiles. -/// -/// Implementors resolve policy queries — either from an in-memory cache with -/// RPC fallback (zone node) or from a witness database (SP1 prover guest). -pub trait PolicyCheck { - /// Check whether `user` is authorized under `policy_id` for the given `role`. - fn is_authorized( - &self, - policy_id: u64, - user: Address, - role: AuthRole, - ) -> Result; - - /// Resolve the `transferPolicyId` for a token. - fn resolve_transfer_policy_id(&self, token: Address) -> Result; - - /// Resolve policy type and admin for a policy ID. - /// - /// Returns `Ok(Some((policy_type, admin)))` if the policy exists, `Ok(None)` otherwise. - fn policy_type_sync( - &self, - policy_id: u64, - ) -> Result; - - /// Resolve compound policy sub-IDs. - /// - /// Returns `(sender_policy_id, recipient_policy_id, mint_recipient_policy_id)`. - fn compound_policy_data(&self, policy_id: u64) -> Result<(u64, u64, u64), PrecompileError>; - - /// Check whether a policy exists. - fn policy_exists(&self, policy_id: u64) -> Result; - - /// Return the highest known policy ID counter. - fn policy_id_counter(&self) -> u64; -} diff --git a/crates/precompiles/src/storage.rs b/crates/precompiles/src/storage.rs new file mode 100644 index 000000000..6be4245ce --- /dev/null +++ b/crates/precompiles/src/storage.rs @@ -0,0 +1,196 @@ +//! Anchor coordination and packed-storage compatibility shared by the zone EVM database adapter +//! and the native `TempoState` precompile. + +use alloc::rc::Rc; +use core::{cell::Cell, fmt}; + +use alloy_primitives::{Address, B256, U256}; +use revm::precompile::PrecompileError; +use tempo_precompiles::tip20::tip20_slots; +use tempo_primitives::TempoAddressExt; +use thiserror::Error; + +pub(crate) use tempo_precompiles::storage::*; + +/// L1 storage access needed by the anchored Zone database and `TempoState` reads. +pub trait L1StorageReader: Clone + Send + Sync + 'static { + /// Read `account[slot]` at `block_number` on Tempo L1. + fn read_l1_storage( + &self, + account: Address, + slot: B256, + block_number: u64, + ) -> core::result::Result; +} + +/// Invalid operation for the current anchor phase. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("invalid anchor operation {operation:?} in phase {phase:?}")] +pub struct L1AnchorError { + /// Attempted operation. + pub operation: L1AnchorOperation, + /// Phase in which the operation was attempted. + pub phase: L1AnchorPhase, +} + +/// Operation applied to the execution-local anchor state machine. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum L1AnchorOperation { + /// Observe external Tempo state at an anchor. + Read { anchor: u64 }, + /// Advance from a parent Tempo block to its direct child. + Advance { from: u64, to: u64 }, +} + +/// Current execution-local Tempo anchor phase. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum L1AnchorPhase { + /// The selected Zone state's checkpoint has not been loaded yet. + #[default] + Uninitialized, + /// External Tempo state was read at the parent anchor. + Parent { + /// Parent Tempo block number. + anchor: u64, + }, + /// The required system transaction advanced this execution to the child anchor. + Advanced { + /// Parent Tempo block number. + from: u64, + /// Child Tempo block number. + to: u64, + }, +} + +impl L1AnchorPhase { + /// Returns the anchor used by reads in this phase, if initialized. + pub const fn current(self) -> Option { + match self { + Self::Uninitialized => None, + Self::Parent { anchor } => Some(anchor), + Self::Advanced { to, .. } => Some(to), + } + } + + fn apply(self, operation: L1AnchorOperation) -> Result { + let invalid = || L1AnchorError { + operation, + phase: self, + }; + + match operation { + L1AnchorOperation::Read { anchor: new } => match self { + Self::Uninitialized => Ok(Self::Parent { anchor: new }), + Self::Parent { anchor } if anchor == new => Ok(self), + Self::Advanced { to, .. } if to == new => Ok(self), + _ => Err(invalid()), + }, + L1AnchorOperation::Advance { from, to } => { + if from.checked_add(1) == Some(to) && matches!(self, Self::Uninitialized) { + Ok(Self::Advanced { from, to }) + } else { + Err(invalid()) + } + } + } + } +} + +/// The execution-local Tempo anchor used by the database adapter. +#[derive(Clone, Default)] +pub struct L1AnchorController { + state: Rc>, +} + +impl fmt::Debug for L1AnchorController { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AnchorController") + .field("phase", &self.phase()) + .finish() + } +} + +impl L1AnchorController { + fn apply(&self, operation: L1AnchorOperation) -> Result { + let next = self.state.get().apply(operation)?; + self.state.set(next); + Ok(next) + } + + /// Returns the current phase. + pub fn phase(&self) -> L1AnchorPhase { + self.state.get() + } + + /// Resets the controller for the next transaction attempt. + pub fn reset(&self) { + self.state.set(L1AnchorPhase::Uninitialized); + } + + /// Returns the anchor used by reads in the current phase, if initialized. + pub fn current(&self) -> Option { + self.phase().current() + } + + /// Validates the active anchor and records an external Tempo state read. + pub fn observe_read(&self, anchor: u64) -> Result<(), L1AnchorError> { + self.apply(L1AnchorOperation::Read { anchor })?; + Ok(()) + } + + /// Advances the execution-local anchor after `finalizeTempo` validates the next header. + pub fn begin_advance(&self, from: u64, to: u64) -> Result<(), L1AnchorError> { + self.apply(L1AnchorOperation::Advance { from, to })?; + Ok(()) + } +} + +/// Returns whether this is the packed TIP-20 transfer-policy slot. +pub fn is_tip20_policy_id_slot(address: Address, key: U256) -> bool { + address.is_tip20() && key == tip20_slots::TRANSFER_POLICY_ID +} + +/// Replaces the L1-owned transfer-policy field while preserving Zone-local packed fields. +pub fn merge_transfer_policy_id(local_slot: U256, l1_slot: U256) -> U256 { + let offset_bits = tip20_slots::TRANSFER_POLICY_ID_OFFSET * 8; + let field_bits = core::mem::size_of::() * 8; + let field_mask = ((U256::ONE << field_bits) - U256::ONE) << offset_bits; + (local_slot & !field_mask) | (l1_slot & field_mask) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn controller_rejects_advance_after_parent_read() { + let controller = L1AnchorController::default(); + controller.observe_read(10).unwrap(); + assert!(controller.begin_advance(10, 11).is_err()); + } + + #[test] + fn controller_accepts_reads_at_advanced_anchor() { + let controller = L1AnchorController::default(); + controller.begin_advance(10, 11).unwrap(); + controller.observe_read(11).unwrap(); + assert_eq!( + controller.phase(), + L1AnchorPhase::Advanced { from: 10, to: 11 } + ); + } + + #[test] + fn controller_rejects_reads_at_wrong_anchor() { + let controller = L1AnchorController::default(); + controller.observe_read(10).unwrap(); + assert!(controller.observe_read(11).is_err()); + } + + #[test] + fn controller_rejects_duplicate_advance() { + let controller = L1AnchorController::default(); + controller.begin_advance(10, 11).unwrap(); + assert!(controller.begin_advance(11, 12).is_err()); + } +} diff --git a/crates/precompiles/src/tempo_state.rs b/crates/precompiles/src/tempo_state.rs index 555cf2ba5..833755b27 100644 --- a/crates/precompiles/src/tempo_state.rs +++ b/crates/precompiles/src/tempo_state.rs @@ -3,19 +3,16 @@ //! Replaces the Solidity TempoState predeploy at `0x1c00...0000` while //! preserving the zone-facing checkpoint and Tempo storage read ABI. -use alloc::vec::Vec; +use alloc::{format, string::ToString, vec::Vec}; +use crate::storage::{L1AnchorController, L1StorageReader}; use alloy_consensus::BlockHeader; -use alloy_evm::precompiles::DynPrecompile; use alloy_primitives::{Address, B256, Bytes, keccak256}; use alloy_rlp::Decodable as _; use alloy_sol_types::{SolCall, SolError}; -use revm::precompile::{PrecompileError, PrecompileId, PrecompileOutput, PrecompileResult}; +use revm::precompile::PrecompileResult; use tempo_precompiles::{ - DelegateCallNotAllowed, charge_input_cost, dispatch, - error::TempoPrecompileError, - storage::{Handler, StorageCtx, evm::EvmPrecompileStorageProvider}, - view, + charge_input_cost, dispatch, error::TempoPrecompileError, storage::Handler, view, }; use tempo_precompiles_macros::contract; use tempo_primitives::TempoHeader; @@ -29,23 +26,15 @@ alloy_sol_types::sol! { error StaticCallNotAllowed(); } -/// L1 storage access needed by `readTempoStorageSlot(s)`. -pub trait L1StorageReader: Clone + Send + Sync + 'static { - /// Read `account[slot]` at `block_number` on Tempo L1. - fn read_l1_storage( - &self, - account: Address, - slot: B256, - block_number: u64, - ) -> Result; -} - #[contract(addr = TEMPO_STATE_ADDRESS)] pub struct TempoState { tempo_block_hash: B256, tempo_block_number: u64, } +/// Storage slot containing the finalized Tempo block number in Zone state. +pub const TEMPO_BLOCK_NUMBER_SLOT: alloy_primitives::U256 = slots::TEMPO_BLOCK_NUMBER; + impl TempoState { /// Initializes the predeploy account code and checkpoint from the genesis Tempo header. pub fn initialize(&mut self, header_rlp: &[u8]) -> tempo_precompiles::Result<()> { @@ -91,8 +80,20 @@ impl TempoState { .revert_output(Error(message.into()).abi_encode().into())) } + fn observed_l1_anchor( + &mut self, + controller: &L1AnchorController, + ) -> tempo_precompiles::Result { + let anchor = self.tempo_block_number.read()?; + controller + .observe_read(anchor) + .map_err(|err| TempoPrecompileError::Fatal(err.to_string()))?; + Ok(anchor) + } + fn apply_checkpoint( &mut self, + controller: &L1AnchorController, sender: Address, call: TempoStateAbi::finalizeTempoCall, ) -> PrecompileResult { @@ -124,10 +125,16 @@ impl TempoState { if header.parent_hash() != prev_block_hash { return self.revert_error(TempoStateAbi::InvalidParentHash {}); } - if header.number() != prev_block_number.saturating_add(1) { + if prev_block_number.checked_add(1) != Some(header.number()) { return self.revert_error(TempoStateAbi::InvalidBlockNumber {}); } + if let Err(err) = controller.begin_advance(prev_block_number, header.number()) { + return self + .storage + .error_result(TempoPrecompileError::Fatal(err.to_string())); + } + let tempo_block_hash = match self.write_checkpoint(&call.header, header.number()) { Ok(hash) => hash, Err(err) => return self.storage.error_result(err), @@ -146,6 +153,7 @@ impl TempoState { fn read_tempo_storage_slot( &mut self, provider: &P, + controller: &L1AnchorController, sender: Address, call: TempoStateAbi::readTempoStorageSlotCall, ) -> PrecompileResult { @@ -154,7 +162,7 @@ impl TempoState { .revert_string("TempoState: only zone system contracts can read Tempo state"); } - let block_number = match self.tempo_block_number.read() { + let block_number = match self.observed_l1_anchor(controller) { Ok(number) => number, Err(err) => return self.storage.error_result(err), }; @@ -167,6 +175,7 @@ impl TempoState { fn read_tempo_storage_slots( &mut self, provider: &P, + controller: &L1AnchorController, sender: Address, call: TempoStateAbi::readTempoStorageSlotsCall, ) -> PrecompileResult { @@ -175,7 +184,7 @@ impl TempoState { .revert_string("TempoState: only zone system contracts can read Tempo state"); } - let block_number = match self.tempo_block_number.read() { + let block_number = match self.observed_l1_anchor(controller) { Ok(number) => number, Err(err) => return self.storage.error_result(err), }; @@ -188,43 +197,11 @@ impl TempoState { )) } - /// Wraps this precompile for registration in the zone EVM. - pub fn create( - provider: P, - cfg: &revm::context::CfgEnv, - ) -> DynPrecompile { - let spec = cfg.spec; - let amsterdam_eip8037_enabled = cfg.enable_amsterdam_eip8037; - let gas_params = cfg.gas_params.clone(); - - DynPrecompile::new_stateful(PrecompileId::Custom("TempoState".into()), move |input| { - if !input.is_direct_call() { - return Ok(PrecompileOutput::revert( - 0, - SolError::abi_encode(&DelegateCallNotAllowed {}).into(), - input.reservoir, - )); - } - - let mut storage = EvmPrecompileStorageProvider::new( - input.internals, - input.gas, - input.reservoir, - spec, - amsterdam_eip8037_enabled, - input.is_static, - gas_params.clone(), - ); - - StorageCtx::enter(&mut storage, || { - Self::new().call_with_provider(&provider, input.data, input.caller) - }) - }) - } - - fn call_with_provider( + /// Dispatch a `TempoState` call using `provider` for anchored Tempo storage reads. + pub(crate) fn call_with_provider( &mut self, provider: &P, + controller: &L1AnchorController, calldata: &[u8], msg_sender: Address, ) -> PrecompileResult { @@ -238,12 +215,12 @@ impl TempoState { TempoStateAbi::TempoStateCalls { tempoBlockHash(call) => view(call, |_| self.tempo_block_hash.read()), tempoBlockNumber(call) => view(call, |_| self.tempo_block_number.read()), - finalizeTempo(call) => self.apply_checkpoint(msg_sender, call), + finalizeTempo(call) => self.apply_checkpoint(controller, msg_sender, call), readTempoStorageSlot(call) => { - self.read_tempo_storage_slot(provider, msg_sender, call) + self.read_tempo_storage_slot(provider, controller, msg_sender, call) }, readTempoStorageSlots(call) => { - self.read_tempo_storage_slots(provider, msg_sender, call) + self.read_tempo_storage_slots(provider, controller, msg_sender, call) }, } }, @@ -255,110 +232,132 @@ impl TempoState { mod tests { use super::*; - use alloy_evm::{ - EvmInternals, - precompiles::{DynPrecompile, Precompile as AlloyEvmPrecompile, PrecompileInput}, + use crate::{ + storage::L1AnchorPhase, + test_utils::{ + MockL1Reader, TestContext, call_precompile, test_context, test_env, + test_storage_provider, + }, }; - use alloy_primitives::{U256, address, b256}; + use alloy_evm::precompiles::DynPrecompile; + use alloy_primitives::{address, b256}; use alloy_rlp::Encodable as _; use alloy_sol_types::SolCall; - use revm::{ - Context, - database::{CacheDB, EmptyDB}, - }; - use tempo_chainspec::hardfork::TempoHardfork; - - type TestContext = Context< - revm::context::BlockEnv, - revm::context::TxEnv, - revm::context::CfgEnv, - CacheDB, - >; - type TestResult = Result>; - - #[derive(Clone)] - struct MockL1Reader { - value: B256, + use tempo_precompiles::storage::StorageCtx; + + struct TempoStateHarness { + ctx: TestContext, + controller: L1AnchorController, + precompile: DynPrecompile, } - impl L1StorageReader for MockL1Reader { - fn read_l1_storage( - &self, - _account: Address, - _slot: B256, - _block_number: u64, - ) -> Result { - Ok(self.value) + impl TempoStateHarness { + fn new(header: &TempoHeader) -> eyre::Result { + Self::with_reader(header, MockL1Reader::default()) } - } - fn encode_header(header: &TempoHeader) -> Bytes { - let mut encoded = Vec::new(); - header.encode(&mut encoded); - encoded.into() - } + fn with_reader(header: &TempoHeader, reader: MockL1Reader) -> eyre::Result { + let mut ctx = test_context(); + let encoded = encode_header(header); + let mut storage = test_storage_provider(&mut ctx, u64::MAX, false); + StorageCtx::enter(&mut storage, || TempoState::new().initialize(&encoded))?; + drop(storage); + + let controller = L1AnchorController::default(); + let precompile = TempoState::create(reader, controller.clone(), &test_env(&ctx)); + Ok(Self { + ctx, + controller, + precompile, + }) + } - fn test_context() -> TestContext { - Context::new(CacheDB::new(EmptyDB::new()), TempoHardfork::default()) - } + fn call( + &mut self, + caller: Address, + calldata: impl Into, + is_static: bool, + ) -> PrecompileResult { + self.call_as( + caller, + calldata, + is_static, + TEMPO_STATE_ADDRESS, + TEMPO_STATE_ADDRESS, + ) + } - fn initialize(ctx: &mut TestContext, header: &[u8]) -> TestResult { - let spec = ctx.cfg.spec; - let amsterdam_eip8037_enabled = ctx.cfg.enable_amsterdam_eip8037; - let gas_params = ctx.cfg.gas_params.clone(); - let mut storage = EvmPrecompileStorageProvider::new( - EvmInternals::from_context(ctx), - u64::MAX, - 0, - spec, - amsterdam_eip8037_enabled, - false, - gas_params, - ); + fn call_as( + &mut self, + caller: Address, + calldata: impl Into, + is_static: bool, + target: Address, + bytecode_address: Address, + ) -> PrecompileResult { + let calldata = calldata.into(); + call_precompile( + &mut self.ctx, + &self.precompile, + caller, + &calldata, + u64::MAX, + is_static, + target, + bytecode_address, + ) + } - StorageCtx::enter(&mut storage, || TempoState::new().initialize(header))?; - Ok(()) - } + fn finalize_raw( + &mut self, + caller: Address, + header: Bytes, + is_static: bool, + ) -> PrecompileResult { + self.call(caller, finalize_calldata(header), is_static) + } - fn call( - ctx: &mut TestContext, - precompile: &DynPrecompile, - caller: Address, - calldata: Bytes, - is_static: bool, - ) -> PrecompileResult { - call_with_bytecode_address( - ctx, - precompile, - caller, - calldata, - is_static, - TEMPO_STATE_ADDRESS, - ) + fn finalize( + &mut self, + caller: Address, + header: &TempoHeader, + is_static: bool, + ) -> PrecompileResult { + self.finalize_raw(caller, encode_header(header), is_static) + } + + fn assert_checkpoint( + &mut self, + expected_hash: B256, + expected_number: u64, + ) -> eyre::Result<()> { + let block_hash = self.call( + Address::ZERO, + TempoStateAbi::tempoBlockHashCall {}.abi_encode(), + true, + )?; + assert_eq!( + TempoStateAbi::tempoBlockHashCall::abi_decode_returns(&block_hash.bytes)?, + expected_hash + ); + + let block_number = self.call( + Address::ZERO, + TempoStateAbi::tempoBlockNumberCall {}.abi_encode(), + true, + )?; + assert_eq!( + TempoStateAbi::tempoBlockNumberCall::abi_decode_returns(&block_number.bytes)?, + expected_number + ); + Ok(()) + } } - fn call_with_bytecode_address( - ctx: &mut TestContext, - precompile: &DynPrecompile, - caller: Address, - calldata: Bytes, - is_static: bool, - bytecode_address: Address, - ) -> PrecompileResult { - AlloyEvmPrecompile::call( - precompile, - PrecompileInput { - data: &calldata, - gas: u64::MAX, - reservoir: 0, - caller, - value: U256::ZERO, - target_address: TEMPO_STATE_ADDRESS, - is_static, - bytecode_address, - internals: EvmInternals::from_context(ctx), - }, - ) + fn encode_header(header: &TempoHeader) -> Bytes { + let mut encoded = Vec::new(); + header.encode(&mut encoded); + encoded.into() } fn child_header(parent_hash: B256, number: u64) -> TempoHeader { @@ -397,251 +396,186 @@ mod tests { .into() } - fn assert_checkpoint( - ctx: &mut TestContext, - precompile: &DynPrecompile, - expected_hash: B256, - expected_number: u64, - ) -> TestResult { - let block_hash = call( - ctx, - precompile, - Address::ZERO, - TempoStateAbi::tempoBlockHashCall {}.abi_encode().into(), - true, + fn read_slot_calldata() -> Bytes { + TempoStateAbi::readTempoStorageSlotCall { + account: Address::repeat_byte(0x44), + slot: B256::ZERO, + } + .abi_encode() + .into() + } + + #[test] + fn explicit_read_before_finalize_blocks_advancement() -> eyre::Result<()> { + let genesis = child_header(B256::repeat_byte(0xaa), 10); + let genesis_hash = keccak256(encode_header(&genesis)); + let mut harness = TempoStateHarness::with_reader( + &genesis, + MockL1Reader::returning(B256::repeat_byte(0x11)), )?; + + harness.call(ZONE_INBOX_ADDRESS, read_slot_calldata(), true)?; assert_eq!( - TempoStateAbi::tempoBlockHashCall::abi_decode_returns(&block_hash.bytes)?, - expected_hash + harness.controller.phase(), + L1AnchorPhase::Parent { anchor: 10 } ); - let block_number = call( - ctx, - precompile, - Address::ZERO, - TempoStateAbi::tempoBlockNumberCall {}.abi_encode().into(), - true, - )?; + let child = child_header(genesis_hash, 11); + assert!(harness.finalize(ZONE_INBOX_ADDRESS, &child, false).is_err()); + Ok(()) + } + + #[test] + fn explicit_read_after_finalize_uses_advanced_anchor() -> eyre::Result<()> { + let genesis = child_header(B256::repeat_byte(0xaa), 10); + let genesis_hash = keccak256(encode_header(&genesis)); + let reader = MockL1Reader::returning(B256::repeat_byte(0x11)); + let mut harness = TempoStateHarness::with_reader(&genesis, reader.clone())?; + + let child = child_header(genesis_hash, 11); + harness.finalize(ZONE_INBOX_ADDRESS, &child, false)?; + harness.call(ZONE_INBOX_ADDRESS, read_slot_calldata(), true)?; assert_eq!( - TempoStateAbi::tempoBlockNumberCall::abi_decode_returns(&block_number.bytes)?, - expected_number + harness.controller.phase(), + L1AnchorPhase::Advanced { from: 10, to: 11 } + ); + assert!( + reader + .storage_requests() + .iter() + .all(|request| request.2 == 11) ); Ok(()) } #[test] - fn initialize_sets_checkpoint() -> TestResult { + fn initialize_sets_checkpoint() -> eyre::Result<()> { let header = child_header(B256::repeat_byte(0xaa), 42); - let header_rlp = encode_header(&header); - let mut ctx = test_context(); - initialize(&mut ctx, &header_rlp)?; - - let precompile = TempoState::create(MockL1Reader { value: B256::ZERO }, &ctx.cfg.clone()); - assert_checkpoint(&mut ctx, &precompile, keccak256(&header_rlp), 42)?; - - Ok(()) + let mut harness = TempoStateHarness::new(&header)?; + harness.assert_checkpoint(keccak256(encode_header(&header)), 42) } #[test] - fn finalize_tempo_updates_checkpoint() -> TestResult { + fn finalize_tempo_updates_checkpoint() -> eyre::Result<()> { let genesis = TempoHeader::default(); - let genesis_rlp = encode_header(&genesis); - let genesis_hash = keccak256(&genesis_rlp); - let mut ctx = test_context(); - initialize(&mut ctx, &genesis_rlp)?; - + let genesis_hash = keccak256(encode_header(&genesis)); + let mut harness = TempoStateHarness::new(&genesis)?; let child = child_header(genesis_hash, 1); - let child_rlp = encode_header(&child); - let child_hash = keccak256(&child_rlp); - let precompile = TempoState::create(MockL1Reader { value: B256::ZERO }, &ctx.cfg.clone()); - - let output = call( - &mut ctx, - &precompile, - ZONE_INBOX_ADDRESS, - finalize_calldata(child_rlp), - false, - )?; - assert!(output.is_success()); - assert_checkpoint(&mut ctx, &precompile, child_hash, 1)?; - Ok(()) + let output = harness.finalize(ZONE_INBOX_ADDRESS, &child, false)?; + assert!(output.is_success()); + harness.assert_checkpoint(keccak256(encode_header(&child)), 1) } #[test] - fn finalize_tempo_reverts_for_non_inbox_caller() -> TestResult { + fn finalize_tempo_reverts_for_non_inbox_caller() -> eyre::Result<()> { let genesis = TempoHeader::default(); - let genesis_rlp = encode_header(&genesis); - let genesis_hash = keccak256(&genesis_rlp); - let mut ctx = test_context(); - initialize(&mut ctx, &genesis_rlp)?; - - let child_rlp = encode_header(&child_header(genesis_hash, 1)); - let precompile = TempoState::create(MockL1Reader { value: B256::ZERO }, &ctx.cfg.clone()); - let output = call( - &mut ctx, - &precompile, - Address::ZERO, - finalize_calldata(child_rlp), - false, - )?; - - assert!(output.is_revert()); - assert_checkpoint(&mut ctx, &precompile, genesis_hash, genesis.number())?; + let genesis_hash = keccak256(encode_header(&genesis)); + let mut harness = TempoStateHarness::new(&genesis)?; + let child = child_header(genesis_hash, 1); - Ok(()) + assert!(harness.finalize(Address::ZERO, &child, false)?.is_revert()); + harness.assert_checkpoint(genesis_hash, genesis.number()) } #[test] - fn delegate_call_reverts() -> TestResult { - let genesis_rlp = encode_header(&TempoHeader::default()); - let mut ctx = test_context(); - initialize(&mut ctx, &genesis_rlp)?; - - let precompile = TempoState::create(MockL1Reader { value: B256::ZERO }, &ctx.cfg.clone()); - let output = call_with_bytecode_address( - &mut ctx, - &precompile, + fn delegate_call_reverts() -> eyre::Result<()> { + let mut harness = TempoStateHarness::new(&TempoHeader::default())?; + let output = harness.call_as( Address::ZERO, - TempoStateAbi::tempoBlockHashCall {}.abi_encode().into(), + TempoStateAbi::tempoBlockHashCall {}.abi_encode(), true, + TEMPO_STATE_ADDRESS, address!("0x000000000000000000000000000000000000dEaD"), )?; assert!(output.is_revert()); - + assert_eq!(output.gas_used, 0); Ok(()) } #[test] - fn finalize_tempo_reverts_on_static_call() -> TestResult { + fn finalize_tempo_reverts_on_static_call() -> eyre::Result<()> { let genesis = TempoHeader::default(); - let genesis_rlp = encode_header(&genesis); - let genesis_hash = keccak256(&genesis_rlp); - let mut ctx = test_context(); - initialize(&mut ctx, &genesis_rlp)?; - - let child_rlp = encode_header(&child_header(genesis_hash, 1)); - let precompile = TempoState::create(MockL1Reader { value: B256::ZERO }, &ctx.cfg.clone()); - let output = call( - &mut ctx, - &precompile, - ZONE_INBOX_ADDRESS, - finalize_calldata(child_rlp), - true, - )?; - - assert!(output.is_revert()); - assert_checkpoint(&mut ctx, &precompile, genesis_hash, genesis.number())?; + let genesis_hash = keccak256(encode_header(&genesis)); + let mut harness = TempoStateHarness::new(&genesis)?; + let child = child_header(genesis_hash, 1); - Ok(()) + assert!( + harness + .finalize(ZONE_INBOX_ADDRESS, &child, true)? + .is_revert() + ); + harness.assert_checkpoint(genesis_hash, genesis.number()) } #[test] - fn finalize_tempo_reverts_on_invalid_rlp() -> TestResult { + fn finalize_tempo_reverts_on_invalid_rlp() -> eyre::Result<()> { let genesis = TempoHeader::default(); - let genesis_rlp = encode_header(&genesis); - let genesis_hash = keccak256(&genesis_rlp); - let mut ctx = test_context(); - initialize(&mut ctx, &genesis_rlp)?; - - let precompile = TempoState::create(MockL1Reader { value: B256::ZERO }, &ctx.cfg.clone()); - let output = call( - &mut ctx, - &precompile, - ZONE_INBOX_ADDRESS, - finalize_calldata(Bytes::from(vec![0xff])), - false, - )?; + let genesis_hash = keccak256(encode_header(&genesis)); + let mut harness = TempoStateHarness::new(&genesis)?; - assert!(output.is_revert()); - assert_checkpoint(&mut ctx, &precompile, genesis_hash, genesis.number())?; - - Ok(()) + assert!( + harness + .finalize_raw(ZONE_INBOX_ADDRESS, Bytes::from(vec![0xff]), false)? + .is_revert() + ); + harness.assert_checkpoint(genesis_hash, genesis.number()) } #[test] - fn finalize_tempo_reverts_on_trailing_header_bytes() -> TestResult { + fn finalize_tempo_reverts_on_trailing_header_bytes() -> eyre::Result<()> { let genesis = TempoHeader::default(); - let genesis_rlp = encode_header(&genesis); - let genesis_hash = keccak256(&genesis_rlp); - let mut ctx = test_context(); - initialize(&mut ctx, &genesis_rlp)?; - - let child_rlp = encode_header(&child_header(genesis_hash, 1)); - let mut malformed = child_rlp.to_vec(); + let genesis_hash = keccak256(encode_header(&genesis)); + let mut harness = TempoStateHarness::new(&genesis)?; + let mut malformed = encode_header(&child_header(genesis_hash, 1)).to_vec(); malformed.push(0); - let precompile = TempoState::create(MockL1Reader { value: B256::ZERO }, &ctx.cfg.clone()); - let output = call( - &mut ctx, - &precompile, - ZONE_INBOX_ADDRESS, - finalize_calldata(Bytes::from(malformed)), - false, - )?; - - assert!(output.is_revert()); - assert_checkpoint(&mut ctx, &precompile, genesis_hash, genesis.number())?; - Ok(()) + assert!( + harness + .finalize_raw(ZONE_INBOX_ADDRESS, Bytes::from(malformed), false)? + .is_revert() + ); + harness.assert_checkpoint(genesis_hash, genesis.number()) } #[test] - fn finalize_tempo_reverts_on_invalid_parent_hash() -> TestResult { + fn finalize_tempo_reverts_on_invalid_parent_hash() -> eyre::Result<()> { let genesis = TempoHeader::default(); - let genesis_rlp = encode_header(&genesis); - let genesis_hash = keccak256(&genesis_rlp); - let mut ctx = test_context(); - initialize(&mut ctx, &genesis_rlp)?; - - let child_rlp = encode_header(&child_header(B256::ZERO, 1)); - let precompile = TempoState::create(MockL1Reader { value: B256::ZERO }, &ctx.cfg.clone()); - let output = call( - &mut ctx, - &precompile, - ZONE_INBOX_ADDRESS, - finalize_calldata(child_rlp), - false, - )?; - - assert!(output.is_revert()); - assert_checkpoint(&mut ctx, &precompile, genesis_hash, genesis.number())?; - - Ok(()) + let genesis_hash = keccak256(encode_header(&genesis)); + let mut harness = TempoStateHarness::new(&genesis)?; + let child = child_header(B256::ZERO, 1); + + assert!( + harness + .finalize(ZONE_INBOX_ADDRESS, &child, false)? + .is_revert() + ); + harness.assert_checkpoint(genesis_hash, genesis.number()) } #[test] - fn finalize_tempo_reverts_on_invalid_block_number() -> TestResult { + fn finalize_tempo_reverts_on_invalid_block_number() -> eyre::Result<()> { let genesis = TempoHeader::default(); - let genesis_rlp = encode_header(&genesis); - let genesis_hash = keccak256(&genesis_rlp); - let mut ctx = test_context(); - initialize(&mut ctx, &genesis_rlp)?; - - let child_rlp = encode_header(&child_header(genesis_hash, 2)); - let precompile = TempoState::create(MockL1Reader { value: B256::ZERO }, &ctx.cfg.clone()); - let output = call( - &mut ctx, - &precompile, - ZONE_INBOX_ADDRESS, - finalize_calldata(child_rlp), - false, - )?; - - assert!(output.is_revert()); - assert_checkpoint(&mut ctx, &precompile, genesis_hash, genesis.number())?; - - Ok(()) + let genesis_hash = keccak256(encode_header(&genesis)); + let mut harness = TempoStateHarness::new(&genesis)?; + let child = child_header(genesis_hash, 2); + + assert!( + harness + .finalize(ZONE_INBOX_ADDRESS, &child, false)? + .is_revert() + ); + harness.assert_checkpoint(genesis_hash, genesis.number()) } #[test] - fn read_tempo_storage_slot_is_system_only() -> TestResult { - let genesis_rlp = encode_header(&TempoHeader::default()); - let mut ctx = test_context(); - initialize(&mut ctx, &genesis_rlp)?; - + fn read_tempo_storage_slot_is_system_only() -> eyre::Result<()> { let expected = b256!("0xabababababababababababababababababababababababababababababababab"); - let precompile = TempoState::create(MockL1Reader { value: expected }, &ctx.cfg.clone()); + let mut harness = TempoStateHarness::with_reader( + &TempoHeader::default(), + MockL1Reader::returning(expected), + )?; let calldata: Bytes = TempoStateAbi::readTempoStorageSlotCall { account: address!("0x0000000000000000000000000000000000009999"), slot: B256::ZERO, @@ -649,42 +583,37 @@ mod tests { .abi_encode() .into(); - let outsider = call( - &mut ctx, - &precompile, - address!("0x000000000000000000000000000000000000aaaa"), - calldata.clone(), - true, - )?; - assert!(outsider.is_revert()); - - let system = call(&mut ctx, &precompile, ZONE_CONFIG_ADDRESS, calldata, true)?; + assert!( + harness + .call( + address!("0x000000000000000000000000000000000000aaaa"), + calldata.clone(), + true, + )? + .is_revert() + ); + let system = harness.call(ZONE_CONFIG_ADDRESS, calldata, true)?; assert_eq!( TempoStateAbi::readTempoStorageSlotCall::abi_decode_returns(&system.bytes)?, expected ); - Ok(()) } #[test] - fn read_tempo_storage_slots_returns_batch() -> TestResult { - let genesis_rlp = encode_header(&TempoHeader::default()); - let mut ctx = test_context(); - initialize(&mut ctx, &genesis_rlp)?; - + fn read_tempo_storage_slots_returns_batch() -> eyre::Result<()> { let expected = b256!("0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"); - let precompile = TempoState::create(MockL1Reader { value: expected }, &ctx.cfg.clone()); - let output = call( - &mut ctx, - &precompile, + let mut harness = TempoStateHarness::with_reader( + &TempoHeader::default(), + MockL1Reader::returning(expected), + )?; + let output = harness.call( ZONE_OUTBOX_ADDRESS, TempoStateAbi::readTempoStorageSlotsCall { account: address!("0x0000000000000000000000000000000000009999"), slots: vec![B256::ZERO, B256::with_last_byte(1)], } - .abi_encode() - .into(), + .abi_encode(), true, )?; @@ -692,7 +621,6 @@ mod tests { TempoStateAbi::readTempoStorageSlotsCall::abi_decode_returns(&output.bytes)?, vec![expected, expected] ); - Ok(()) } } diff --git a/crates/precompiles/src/test_utils/l1_reader.rs b/crates/precompiles/src/test_utils/l1_reader.rs new file mode 100644 index 000000000..42360e68f --- /dev/null +++ b/crates/precompiles/src/test_utils/l1_reader.rs @@ -0,0 +1,206 @@ +//! Shared L1 reader fixtures for precompile and EVM integration tests. +use crate::{L1StorageReader, SequencerExt}; +use alloy_primitives::{Address, B256, U256}; +use revm::precompile::PrecompileError; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; +use tempo_precompiles::{ + storage::{Handler, PrecompileStorageProvider, StorageCtx, hashmap::HashMapStorageProvider}, + tip403_registry::{CompoundPolicyData, PolicyData, TIP403Registry}, +}; + +pub type L1Slot = (Address, B256, u64); +type Shared = Arc>; + +/// In-memory exact-block L1 reader shared by EVM and precompile tests. +#[derive(Clone)] +pub struct MockL1Reader { + slots: Shared>, + registry_storage: Shared, + storage_requests: Shared>, + fallback: B256, + policy_id: u64, + fail_storage: bool, +} + +impl Default for MockL1Reader { + fn default() -> Self { + Self { + slots: Default::default(), + registry_storage: Arc::new(Mutex::new(HashMapStorageProvider::new(1))), + storage_requests: Default::default(), + fallback: B256::ZERO, + policy_id: 0, + fail_storage: false, + } + } +} + +impl MockL1Reader { + pub fn insert(&self, address: Address, slot: U256, anchor: u64, value: U256) { + self.set_u256(address, slot, anchor, value); + } + + pub fn allow_all() -> Self { + Self::with_policy_id(1) + } + + pub fn failing() -> Self { + Self { + fail_storage: true, + ..Self::allow_all() + } + } + + pub fn with_policy_id(policy_id: u64) -> Self { + Self { + policy_id, + ..Default::default() + } + } + + pub fn returning(value: B256) -> Self { + Self { + fallback: value, + ..Default::default() + } + } + + pub fn failing_storage() -> Self { + Self { + fail_storage: true, + ..Default::default() + } + } + + pub fn set_u256(&self, address: Address, slot: U256, block: u64, value: U256) { + self.slots.lock().unwrap().insert( + (address, B256::from(slot.to_be_bytes()), block), + B256::from(value.to_be_bytes()), + ); + } + + pub fn storage_requests(&self) -> Vec { + self.storage_requests.lock().unwrap().clone() + } + + pub fn seed_transfer_policy_id(&self, token: Address, block_number: u64) { + let packed = U256::from(self.policy_id) + << (tempo_precompiles::tip20::tip20_slots::TRANSFER_POLICY_ID_OFFSET * 8); + self.set_u256( + token, + tempo_precompiles::tip20::tip20_slots::TRANSFER_POLICY_ID, + block_number, + packed, + ); + } + + pub fn seed_simple_policy( + &self, + policy_id: u64, + policy_type: tempo_contracts::precompiles::ITIP403Registry::PolicyType, + accounts: &[Address], + ) -> tempo_precompiles::Result<()> { + let mut storage = self.registry_storage.lock().unwrap(); + StorageCtx::enter(&mut *storage, || { + let mut registry = TIP403Registry::new(); + let next_policy_id = registry.policy_id_counter()?.max(policy_id + 1); + registry.policy_id_counter.write(next_policy_id)?; + registry.policy_records[policy_id].base.write(PolicyData { + policy_type: policy_type as u8, + admin: Address::ZERO, + })?; + for account in accounts { + registry.policy_set[policy_id][*account].write(true)?; + } + Ok(()) + }) + } + + pub fn seed_blacklist_policy( + &self, + policy_id: u64, + accounts: &[Address], + ) -> tempo_precompiles::Result<()> { + self.seed_simple_policy( + policy_id, + tempo_contracts::precompiles::ITIP403Registry::PolicyType::BLACKLIST, + accounts, + ) + } + + pub fn seed_compound_policy( + &self, + policy_id: u64, + sender_policy_id: u64, + recipient_policy_id: u64, + mint_recipient_policy_id: u64, + ) -> tempo_precompiles::Result<()> { + let mut storage = self.registry_storage.lock().unwrap(); + StorageCtx::enter(&mut *storage, || { + let mut registry = TIP403Registry::new(); + let next_policy_id = registry.policy_id_counter()?.max(policy_id + 1); + registry.policy_id_counter.write(next_policy_id)?; + registry.policy_records[policy_id].base.write(PolicyData { + policy_type: tempo_contracts::precompiles::ITIP403Registry::PolicyType::COMPOUND + as u8, + admin: Address::ZERO, + })?; + registry.policy_records[policy_id] + .compound + .write(CompoundPolicyData { + sender_policy_id, + recipient_policy_id, + mint_recipient_policy_id, + })?; + Ok(()) + }) + } +} + +impl SequencerExt for MockL1Reader { + fn latest_sequencer(&self) -> Option
{ + None + } +} + +impl L1StorageReader for MockL1Reader { + fn read_l1_storage( + &self, + account: Address, + slot: B256, + block_number: u64, + ) -> Result { + self.storage_requests + .lock() + .unwrap() + .push((account, slot, block_number)); + if self.fail_storage { + return Err(crate::zone_rpc_error("RPC unavailable")); + } + if let Some(value) = self + .slots + .lock() + .unwrap() + .get(&(account, slot, block_number)) + .copied() + { + return Ok(value); + } + + let key = U256::from_be_bytes(slot.0); + let value = self + .registry_storage + .lock() + .unwrap() + .sload(account, key) + .map_err(|err| PrecompileError::Fatal(err.to_string()))?; + if value.is_zero() { + Ok(self.fallback) + } else { + Ok(B256::from(value.to_be_bytes())) + } + } +} diff --git a/crates/precompiles/src/test_utils.rs b/crates/precompiles/src/test_utils/local.rs similarity index 66% rename from crates/precompiles/src/test_utils.rs rename to crates/precompiles/src/test_utils/local.rs index fcfd11cad..6ca818614 100644 --- a/crates/precompiles/src/test_utils.rs +++ b/crates/precompiles/src/test_utils/local.rs @@ -1,18 +1,92 @@ -//! Shared test utilities for precompile tests. - +use alloy_evm::{ + EvmInternals, + precompiles::{DynPrecompile, Precompile as _, PrecompileInput}, +}; use alloy_primitives::{Address, B256, U256}; use k256::{ AffinePoint, ProjectivePoint, Scalar, elliptic_curve::{ops::Reduce, sec1::ToEncodedPoint}, }; +use revm::{ + Context, + context::{BlockEnv, CfgEnv, TxEnv}, + database::{CacheDB, EmptyDB}, + precompile::PrecompileResult, +}; +use std::{cell::RefCell, rc::Rc}; +use tempo_chainspec::hardfork::TempoHardfork; +use tempo_precompiles::{ + storage::{actions::StorageActions, evm::EvmPrecompileStorageProvider}, + storage_credits::NonCreditableSlots, +}; use crate::{ + ZonePrecompileEnv, chaum_pedersen::{challenge_hash, recover_point}, ecies::DecryptedDeposit, }; pub(crate) use crate::ecies::{build_plaintext, compressed_x_and_parity, encrypt_plaintext}; +/// EVM context used by local precompile unit tests. +pub(crate) type TestContext = Context, CacheDB>; + +/// Create an empty test EVM context at the default Tempo hardfork. +pub(crate) fn test_context() -> TestContext { + Context::new(CacheDB::new(EmptyDB::new()), TempoHardfork::default()) +} + +/// Create an EVM-backed precompile storage provider over `ctx`. +pub(crate) fn test_storage_provider( + ctx: &mut TestContext, + gas_limit: u64, + is_static: bool, +) -> EvmPrecompileStorageProvider<'_> { + let cfg = ctx.cfg.clone(); + EvmPrecompileStorageProvider::new( + EvmInternals::from_context(ctx), + gas_limit, + 0, + cfg.spec, + cfg.enable_amsterdam_eip8037, + is_static, + cfg.gas_params, + ) +} + +/// Create the ordinary precompile environment for a local unit test. +pub(crate) fn test_env(ctx: &TestContext) -> ZonePrecompileEnv { + ZonePrecompileEnv::new( + &ctx.cfg, + StorageActions::disabled(), + Rc::new(RefCell::new(NonCreditableSlots::empty())), + ) +} + +/// Call a dynamic precompile with test defaults for value and reservoir. +pub(crate) fn call_precompile( + ctx: &mut TestContext, + precompile: &DynPrecompile, + caller: Address, + data: &[u8], + gas: u64, + is_static: bool, + target: Address, + bytecode_address: Address, +) -> PrecompileResult { + precompile.call(PrecompileInput { + data, + gas, + reservoir: 0, + caller, + value: U256::ZERO, + target_address: target, + is_static, + bytecode_address, + internals: EvmInternals::from_context(ctx), + }) +} + /// Assert that the Chaum-Pedersen proof inside a [`DecryptedDeposit`] is valid. pub(crate) fn assert_cp_proof_valid( dec: &DecryptedDeposit, diff --git a/crates/precompiles/src/test_utils/mod.rs b/crates/precompiles/src/test_utils/mod.rs new file mode 100644 index 000000000..a6afa4007 --- /dev/null +++ b/crates/precompiles/src/test_utils/mod.rs @@ -0,0 +1,9 @@ +//! Shared test utilities for precompile and EVM integration tests. + +pub mod l1_reader; +pub use l1_reader::MockL1Reader; + +#[cfg(test)] +mod local; +#[cfg(test)] +pub(crate) use local::*; diff --git a/crates/precompiles/src/tip20_factory/mod.rs b/crates/precompiles/src/tip20_factory/mod.rs index acdbad3d6..7b35ae1eb 100644 --- a/crates/precompiles/src/tip20_factory/mod.rs +++ b/crates/precompiles/src/tip20_factory/mod.rs @@ -1,11 +1,11 @@ //! Zone-side TIP20 factory precompile. //! -//! Deployed at the same address as the L1 [`TIP20Factory`] (`0x20FC…0000`), this +//! Deployed at the same address as the L1 `TIP20Factory` (`0x20FC…0000`), this //! precompile replaces the standard factory on the zone with a single //! `enableToken(address, string, string, string)` entrypoint. //! //! When the sequencer bridges a new TIP-20 token to the zone, the -//! [`ZoneInbox`] contract calls `enableToken` during `advanceTempo` to: +//! `ZoneInbox` contract calls `enableToken` during `advanceTempo` to: //! //! 1. Initialize the TIP-20 storage at the given address (name, symbol, currency). //! 2. Grant [`ISSUER_ROLE`] to both [`ZONE_INBOX_ADDRESS`] (for minting on @@ -18,7 +18,6 @@ mod dispatch; pub use IZoneTokenFactory::IZoneTokenFactoryErrors as ZoneTokenFactoryError; use alloy_primitives::Address; -use alloy_sol_types::SolError; use tempo_precompiles::{ PATH_USD_ADDRESS, TIP20_FACTORY_ADDRESS, tip20::{ISSUER_ROLE, TIP20Token}, @@ -83,46 +82,4 @@ impl ZoneTokenFactory { Ok(()) } - - /// Wraps this precompile in a [`DynPrecompile`] for registration in the zone EVM. - /// - /// The returned precompile handles delegate-call rejection, EVM storage - /// context setup, and dispatches to [`TempoPrecompile::call`]. - pub fn create( - cfg: &revm::context::CfgEnv, - ) -> alloy_evm::precompiles::DynPrecompile { - use revm::precompile::{PrecompileId, PrecompileOutput}; - use tempo_precompiles::{ - DelegateCallNotAllowed, Precompile as _, - storage::{StorageCtx, evm::EvmPrecompileStorageProvider}, - }; - - let spec = cfg.spec; - let amsterdam_eip8037_enabled = cfg.enable_amsterdam_eip8037; - let gas_params = cfg.gas_params.clone(); - alloy_evm::precompiles::DynPrecompile::new_stateful( - PrecompileId::Custom("ZoneTokenFactory".into()), - move |input| { - if !input.is_direct_call() { - return Ok(PrecompileOutput::revert( - 0, - SolError::abi_encode(&DelegateCallNotAllowed {}).into(), - input.reservoir, - )); - } - - let mut storage = EvmPrecompileStorageProvider::new( - input.internals, - input.gas, - input.reservoir, - spec, - amsterdam_eip8037_enabled, - input.is_static, - gas_params.clone(), - ); - - StorageCtx::enter(&mut storage, || Self::new().call(input.data, input.caller)) - }, - ) - } } diff --git a/crates/precompiles/src/tip403_proxy/dispatch.rs b/crates/precompiles/src/tip403_proxy/dispatch.rs deleted file mode 100644 index 71ae2f96c..000000000 --- a/crates/precompiles/src/tip403_proxy/dispatch.rs +++ /dev/null @@ -1,213 +0,0 @@ -//! ABI dispatch for the [`ZoneTip403ProxyRegistry`] precompile. - -use alloy_evm::precompiles::DynPrecompile; -use alloy_primitives::{Address, Bytes}; -use alloy_sol_types::{SolCall, SolError}; -use revm::precompile::{PrecompileHalt, PrecompileId, PrecompileOutput, PrecompileResult}; -use tempo_contracts::precompiles::ITIP403Registry::{self, PolicyType}; -use tempo_precompiles::{ - Precompile as TempoPrecompile, charge_input_cost, dispatch, - storage::{StorageCtx, evm::EvmPrecompileStorageProvider}, - tip403_registry::{ALLOW_ALL_POLICY_ID, REJECT_ALL_POLICY_ID}, -}; -use tracing::{debug, warn}; -use zone_primitives::policy::AuthRole; - -use super::{POLICY_DATA_GAS, ReadOnlyRegistry, ZoneTip403ProxyRegistry}; -use crate::{ZonePrecompileError, policy::PolicyCheck}; - -impl ZoneTip403ProxyRegistry

{ - /// Create a [`DynPrecompile`] that dispatches TIP-403 registry calls - /// to the zone's policy provider. - pub fn create( - provider: P, - cfg: &revm::context::CfgEnv, - ) -> DynPrecompile { - let registry = Self::new(provider); - let spec = cfg.spec; - let amsterdam_eip8037_enabled = cfg.enable_amsterdam_eip8037; - let gas_params = cfg.gas_params.clone(); - DynPrecompile::new_stateful( - PrecompileId::Custom("ZoneTip403ProxyRegistry".into()), - move |input| { - if !input.is_direct_call() { - warn!( - target: "zone::precompile", - "ZoneTip403ProxyRegistry called via DELEGATECALL - rejecting" - ); - return Ok(PrecompileOutput::revert( - 0, - ReadOnlyRegistry {}.abi_encode().into(), - input.reservoir, - )); - } - - let mut storage = EvmPrecompileStorageProvider::new( - input.internals, - input.gas, - input.reservoir, - spec, - amsterdam_eip8037_enabled, - input.is_static, - gas_params.clone(), - ); - - StorageCtx::enter(&mut storage, || { - let mut registry = registry.clone(); - registry.call(input.data, input.caller) - }) - }, - ) - } -} - -impl TempoPrecompile for ZoneTip403ProxyRegistry

{ - /// Dispatch based on the 4-byte selector. - fn call(&mut self, calldata: &[u8], _msg_sender: Address) -> PrecompileResult { - let mut storage = StorageCtx::default(); - if let Some(err) = charge_input_cost(&mut storage, calldata) { - return err; - } - - dispatch!( - calldata, - |call| match call { - ITIP403Registry::ITIP403RegistryCalls { - policyIdCounter(_) => self.handle_policy_id_counter(), - policyExists(call) => self.handle_policy_exists(call.policyId), - policyData(call) => self.handle_policy_data(call.policyId), - isAuthorized(call) => { - self.handle_is_authorized(call.policyId, call.user, AuthRole::Transfer) - }, - isAuthorizedSender(call) => { - self.handle_is_authorized(call.policyId, call.user, AuthRole::Sender) - }, - isAuthorizedRecipient(call) => { - self.handle_is_authorized(call.policyId, call.user, AuthRole::Recipient) - }, - isAuthorizedMintRecipient(call) => { - self.handle_is_authorized(call.policyId, call.user, AuthRole::MintRecipient) - }, - compoundPolicyData(call) => { - self.handle_compound_policy_data(call.policyId) - }, - createPolicy(_) => self.read_only_revert(), - createPolicyWithAccounts(_) => self.read_only_revert(), - setPolicyAdmin(_) => self.read_only_revert(), - modifyPolicyWhitelist(_) => self.read_only_revert(), - modifyPolicyBlacklist(_) => self.read_only_revert(), - createCompoundPolicy(_) => self.read_only_revert(), - receivePolicy(_) => Ok(StorageCtx::default().revert_output(Bytes::new())), - validateReceivePolicy(_) => Ok(StorageCtx::default().revert_output(Bytes::new())), - setReceivePolicy(_) => Ok(StorageCtx::default().revert_output(Bytes::new())), - } - }, - ) - } -} - -impl ZoneTip403ProxyRegistry

{ - fn read_only_revert(&self) -> PrecompileResult { - debug!(target: "zone::precompile", "ZoneTip403ProxyRegistry: mutating call reverted"); - StorageCtx.error_result(ZonePrecompileError::from(ReadOnlyRegistry {})) - } - - /// Handle `isAuthorized(policyId, user)` and the directional variants. - fn handle_is_authorized( - &self, - policy_id: u64, - user: Address, - role: AuthRole, - ) -> PrecompileResult { - let authorized = self.is_authorized(policy_id, user, role)?; - let mut storage = StorageCtx::default(); - if storage.deduct_gas(super::AUTH_CHECK_GAS).is_err() { - return Ok(storage.halt_output(PrecompileHalt::OutOfGas)); - } - let encoded = ITIP403Registry::isAuthorizedCall::abi_encode_returns(&authorized); - Ok(storage.success_output(encoded.into())) - } - - /// Handle `policyData(policyId) -> (PolicyType, address admin)`. - fn handle_policy_data(&self, policy_id: u64) -> PrecompileResult { - // Builtins: reject-all is an empty whitelist, allow-all is an empty blacklist. - let builtin_type = match policy_id { - REJECT_ALL_POLICY_ID => Some(PolicyType::WHITELIST), - ALLOW_ALL_POLICY_ID => Some(PolicyType::BLACKLIST), - _ => None, - }; - if let Some(policy_type) = builtin_type { - let ret = ITIP403Registry::policyDataReturn { - policyType: policy_type, - admin: Address::ZERO, - }; - let mut storage = StorageCtx::default(); - if storage.deduct_gas(POLICY_DATA_GAS).is_err() { - return Ok(storage.halt_output(PrecompileHalt::OutOfGas)); - } - let encoded = ITIP403Registry::policyDataCall::abi_encode_returns(&ret); - return Ok(storage.success_output(encoded.into())); - } - - let policy_type = self.provider.policy_type_sync(policy_id)?; - - let ret = ITIP403Registry::policyDataReturn { - policyType: policy_type, - admin: Address::ZERO, - }; - let mut storage = StorageCtx::default(); - if storage.deduct_gas(POLICY_DATA_GAS).is_err() { - return Ok(storage.halt_output(PrecompileHalt::OutOfGas)); - } - let encoded = ITIP403Registry::policyDataCall::abi_encode_returns(&ret); - Ok(storage.success_output(encoded.into())) - } - - /// Handle `compoundPolicyData(policyId) -> (uint64, uint64, uint64)`. - fn handle_compound_policy_data(&self, policy_id: u64) -> PrecompileResult { - let (sender, recipient, mint_recipient) = self.provider.compound_policy_data(policy_id)?; - - let ret = ITIP403Registry::compoundPolicyDataReturn { - senderPolicyId: sender, - recipientPolicyId: recipient, - mintRecipientPolicyId: mint_recipient, - }; - let mut storage = StorageCtx::default(); - if storage.deduct_gas(POLICY_DATA_GAS).is_err() { - return Ok(storage.halt_output(PrecompileHalt::OutOfGas)); - } - let encoded = ITIP403Registry::compoundPolicyDataCall::abi_encode_returns(&ret); - Ok(storage.success_output(encoded.into())) - } - - /// Handle `policyExists(policyId) -> bool`. - fn handle_policy_exists(&self, policy_id: u64) -> PrecompileResult { - if matches!(policy_id, REJECT_ALL_POLICY_ID | ALLOW_ALL_POLICY_ID) { - let mut storage = StorageCtx::default(); - if storage.deduct_gas(POLICY_DATA_GAS).is_err() { - return Ok(storage.halt_output(PrecompileHalt::OutOfGas)); - } - let encoded = ITIP403Registry::policyExistsCall::abi_encode_returns(&true); - return Ok(storage.success_output(encoded.into())); - } - - let exists = self.provider.policy_exists(policy_id)?; - let mut storage = StorageCtx::default(); - if storage.deduct_gas(POLICY_DATA_GAS).is_err() { - return Ok(storage.halt_output(PrecompileHalt::OutOfGas)); - } - let encoded = ITIP403Registry::policyExistsCall::abi_encode_returns(&exists); - Ok(storage.success_output(encoded.into())) - } - - /// Handle `policyIdCounter() -> uint64`. - fn handle_policy_id_counter(&self) -> PrecompileResult { - let counter = self.provider.policy_id_counter(); - let mut storage = StorageCtx::default(); - if storage.deduct_gas(POLICY_DATA_GAS).is_err() { - return Ok(storage.halt_output(PrecompileHalt::OutOfGas)); - } - let encoded = ITIP403Registry::policyIdCounterCall::abi_encode_returns(&counter); - Ok(storage.success_output(encoded.into())) - } -} diff --git a/crates/precompiles/src/tip403_proxy/mod.rs b/crates/precompiles/src/tip403_proxy/mod.rs index fb1025057..663ba0230 100644 --- a/crates/precompiles/src/tip403_proxy/mod.rs +++ b/crates/precompiles/src/tip403_proxy/mod.rs @@ -1,92 +1,182 @@ -//! Zone-side TIP-403 registry proxy precompile. +//! Zone rules for the L1-backed Tempo TIP-403 registry. //! -//! Deployed at the same address as the L1 [`TIP403Registry`] (`0x403C…0000`), this -//! precompile intercepts external EVM calls to the registry and serves authorization -//! queries from the zone's [`PolicyCheck`] provider (cache-first, L1 RPC fallback). -//! -//! **Read-only calls** (`isAuthorized`, `isAuthorizedSender`, `isAuthorizedRecipient`, -//! `isAuthorizedMintRecipient`, `policyData`, `compoundPolicyData`, `policyExists`) -//! are resolved via the [`PolicyCheck`] trait. -//! -//! **Mutating calls** (`createPolicy`, `modifyPolicyWhitelist`, etc.) are reverted — -//! policy state is managed on L1, not on the zone. - -mod dispatch; +//! Calls at the canonical registry address execute the upstream +//! [`tempo_precompiles::tip403_registry::TIP403Registry`] implementation over finalized L1 +//! storage. The zone keeps mutating selectors read-only and otherwise follows upstream dispatch, +//! gas, delegate-call, and receive-policy behavior. +use crate::execution::{CallCheck, CallRules}; use alloy_primitives::Address; -use revm::precompile::PrecompileError; -use tempo_contracts::precompiles::TIP403_REGISTRY_ADDRESS; -use zone_primitives::policy::AuthRole; +use alloy_sol_types::{SolCall, SolError}; +use tempo_contracts::precompiles::{ITIP403Registry, TIP403_REGISTRY_ADDRESS}; -use crate::policy::PolicyCheck; - -/// The precompile address — same as the L1 TIP403Registry. +/// Canonical TIP-403 registry address, shared with Tempo L1. pub const ZONE_TIP403_PROXY_ADDRESS: Address = TIP403_REGISTRY_ADDRESS; -/// Fixed gas cost for authorization checks. -pub const AUTH_CHECK_GAS: u64 = 200; - -/// Fixed gas cost for policy data lookups. -const POLICY_DATA_GAS: u64 = 200; +const TIP403_MUTATING_SELECTORS: &[[u8; 4]] = &[ + ITIP403Registry::createPolicyCall::SELECTOR, + ITIP403Registry::createPolicyWithAccountsCall::SELECTOR, + ITIP403Registry::setPolicyAdminCall::SELECTOR, + ITIP403Registry::modifyPolicyWhitelistCall::SELECTOR, + ITIP403Registry::modifyPolicyBlacklistCall::SELECTOR, + ITIP403Registry::createCompoundPolicyCall::SELECTOR, + ITIP403Registry::setReceivePolicyCall::SELECTOR, +]; alloy_sol_types::sol! { - /// Returned when a mutating call is attempted on the read-only zone registry. + /// Returned when a mutating call is attempted on the zone's read-only, L1-backed registry. #[derive(Debug, PartialEq, Eq)] error ReadOnlyRegistry(); } -/// Read-only zone-side proxy that mirrors the L1 TIP-403 registry. -/// -/// Unlike the L1 [`TIP403Registry`] (which is a storage-backed `#[contract]` -/// precompile), this proxy has **no on-chain storage**. It intercepts EVM calls -/// at the same address (`0x403C…0000`) and resolves authorization queries via -/// the [`PolicyCheck`] trait. -/// -/// All mutating calls (`createPolicy`, `modifyPolicyWhitelist`, etc.) are -/// rejected with `ReadOnlyRegistry` — policy state lives exclusively on L1. -/// -/// The struct also exposes [`is_authorized`](Self::is_authorized) and -/// [`is_transfer_authorized`](Self::is_transfer_authorized) for use by the -/// [`ZoneTip20Token`](super::ZoneTip20Token) precompile, which needs the same -/// authorization logic during transfer/mint pre-checks. -#[derive(Debug, Clone)] -pub struct ZoneTip403ProxyRegistry

{ - provider: P, -} +/// Rules that keep the zone registry read-only before upstream execution. +pub(crate) struct Tip403Rules; -impl ZoneTip403ProxyRegistry

{ - /// Create a new proxy registry backed by the given policy provider. - pub fn new(provider: P) -> Self { - Self { provider } +impl CallRules for Tip403Rules { + fn admit(&self, data: &[u8], _caller: Address) -> CallCheck { + if TIP403_MUTATING_SELECTORS + .iter() + .any(|selector| data.starts_with(selector)) + { + return CallCheck::Revert(ReadOnlyRegistry {}.abi_encode().into()); + } + + CallCheck::Continue } +} + +#[cfg(test)] +mod tests { + use super::*; + + use alloy_evm::precompiles::DynPrecompile; + use alloy_primitives::{Bytes, U256, address}; + use revm::precompile::{PrecompileError, PrecompileOutput}; + use tempo_chainspec::hardfork::TempoHardfork; + use tempo_precompiles::{DelegateCallNotAllowed, storage::PrecompileStorageProvider}; + + use crate::{ + create_tip403_precompile, + tempo_state::slots::TEMPO_BLOCK_NUMBER, + test_utils::{TestContext, call_precompile, test_context, test_env, test_storage_provider}, + }; - /// Resolve the `transferPolicyId` for a token. - pub fn resolve_transfer_policy_id(&self, token: Address) -> Result { - self.provider.resolve_transfer_policy_id(token) + const ANCHOR: u64 = 77; + const CALLER: Address = address!("0x0000000000000000000000000000000000000aaa"); + const ALICE: Address = address!("0x00000000000000000000000000000000000000a1"); + const BOB: Address = address!("0x00000000000000000000000000000000000000b2"); + + struct RegistryHarness { + ctx: TestContext, + precompile: DynPrecompile, } - /// Check whether `user` is authorized under `policy_id` for the given `role`. - pub fn is_authorized( - &self, - policy_id: u64, - user: Address, - role: AuthRole, - ) -> Result { - self.provider.is_authorized(policy_id, user, role) + impl RegistryHarness { + fn new() -> Self { + let mut ctx = test_context(); + ctx.cfg.spec = TempoHardfork::T8; + test_storage_provider(&mut ctx, u64::MAX, false) + .sstore( + zone_primitives::constants::TEMPO_STATE_ADDRESS, + TEMPO_BLOCK_NUMBER, + U256::from(ANCHOR), + ) + .unwrap(); + let env = test_env(&ctx); + Self { + ctx, + precompile: create_tip403_precompile(&env), + } + } + + fn call(&mut self, data: &[u8], gas: u64) -> Result { + self.call_as( + data, + gas, + ZONE_TIP403_PROXY_ADDRESS, + ZONE_TIP403_PROXY_ADDRESS, + ) + } + + fn call_as( + &mut self, + data: &[u8], + gas: u64, + target: Address, + bytecode: Address, + ) -> Result { + call_precompile( + &mut self.ctx, + &self.precompile, + CALLER, + data, + gas, + true, + target, + bytecode, + ) + } } - /// Check sender + recipient authorization for a transfer. - /// - /// Short-circuits on sender failure (matching L1 T2 behavior). - pub fn is_transfer_authorized( - &self, - policy_id: u64, - from: Address, - to: Address, - ) -> Result { - if !self.is_authorized(policy_id, from, AuthRole::Sender)? { - return Ok(false); + #[test] + fn mutating_selectors_are_rejected_by_admission() { + let rules = Tip403Rules; + for selector in TIP403_MUTATING_SELECTORS { + assert!(matches!( + rules.admit(selector, CALLER), + CallCheck::Revert(data) if data == ReadOnlyRegistry {}.abi_encode() + )); } - self.is_authorized(policy_id, to, AuthRole::Recipient) + } + + #[test] + fn receive_policy_reads_use_upstream_dispatch() -> eyre::Result<()> { + let mut harness = RegistryHarness::new(); + let receive = harness.call( + &ITIP403Registry::receivePolicyCall { account: CALLER }.abi_encode(), + u64::MAX, + )?; + assert!(receive.is_success()); + let receive = ITIP403Registry::receivePolicyCall::abi_decode_returns(&receive.bytes)?; + assert!(!receive.hasReceivePolicy); + + let validation = harness.call( + &ITIP403Registry::validateReceivePolicyCall { + token: Address::repeat_byte(0x20), + sender: ALICE, + receiver: BOB, + } + .abi_encode(), + u64::MAX, + )?; + assert!(validation.is_success()); + let validation = + ITIP403Registry::validateReceivePolicyCall::abi_decode_returns(&validation.bytes)?; + assert!(validation.authorized); + assert_eq!( + validation.blockedReason, + ITIP403Registry::BlockedReason::NONE + ); + Ok(()) + } + + #[test] + fn delegate_calls_revert_before_execution() -> eyre::Result<()> { + let mut harness = RegistryHarness::new(); + let call = ITIP403Registry::policyIdCounterCall {}.abi_encode(); + let output = harness.call_as( + &call, + u64::MAX, + ZONE_TIP403_PROXY_ADDRESS, + Address::repeat_byte(0x44), + )?; + + assert!(output.is_revert()); + assert_eq!(output.gas_used, 0); + assert_eq!( + output.bytes, + Bytes::from(DelegateCallNotAllowed {}.abi_encode()) + ); + Ok(()) } } diff --git a/crates/precompiles/src/ztip20/dispatch.rs b/crates/precompiles/src/ztip20/dispatch.rs deleted file mode 100644 index 5217cfaa8..000000000 --- a/crates/precompiles/src/ztip20/dispatch.rs +++ /dev/null @@ -1,229 +0,0 @@ -//! ABI dispatch and precheck routing for the [`ZoneTip20Token`] wrapper. - -use alloc::sync::Arc; - -use alloy_evm::precompiles::DynPrecompile; -use alloy_primitives::{Address, Bytes}; -use alloy_sol_types::{SolCall, SolError}; -use revm::precompile::{PrecompileHalt, PrecompileId, PrecompileOutput, PrecompileResult}; -use tempo_precompiles::{ - DelegateCallNotAllowed, Precompile as TempoPrecompile, charge_input_cost, - storage::{StorageCtx, evm::EvmPrecompileStorageProvider}, - tip20::{IRolesAuth, ITIP20, TIP20Token}, -}; - -use super::{FIXED_TRANSFER_GAS, SequencerExt, ZoneTip20Token}; -use crate::{policy::PolicyCheck, tip403_proxy::ZoneTip403ProxyRegistry}; - -/// Decode ABI args or return a reverted precompile output. -/// -/// Unlike `.ok()?` (which silently skips the policy check on decode failure), -/// this macro returns a definitive revert so malformed calldata cannot bypass -/// the zone policy layer. -macro_rules! decode_or_revert { - ($call_ty:ty, $args:expr) => { - match <$call_ty>::abi_decode_raw_validate($args) { - Ok(c) => c, - Err(_) => { - return Some(Ok(StorageCtx::default().revert_output(Bytes::new()))); - } - } - }; -} - -impl ZoneTip20Token

{ - fn add_input_cost(calldata: &[u8], result: PrecompileResult) -> PrecompileResult { - let mut storage = StorageCtx::default(); - let gas_before = storage.gas_used(); - if let Some(err) = charge_input_cost(&mut storage, calldata) { - return err; - } - let input_gas = storage.gas_used().saturating_sub(gas_before); - - result.map(|mut output| { - output.gas_used = output.gas_used.saturating_add(input_gas); - output - }) - } - - fn selector(data: &[u8]) -> Option<[u8; 4]> { - tempo_precompiles::dispatch::selector_from_calldata(data) - } - - fn is_fixed_gas_selector(selector: [u8; 4]) -> bool { - matches!( - selector, - ITIP20::transferCall::SELECTOR - | ITIP20::transferFromCall::SELECTOR - | ITIP20::transferWithMemoCall::SELECTOR - | ITIP20::transferFromWithMemoCall::SELECTOR - | ITIP20::approveCall::SELECTOR - ) - } - - fn apply_fixed_gas(result: PrecompileResult) -> PrecompileResult { - match result { - Ok(mut output) => { - output.gas_used = FIXED_TRANSFER_GAS; - Ok(output) - } - Err(err) => Err(err), - } - } - - /// Check selector-specific privacy/auth rules before delegating. - /// - /// Returns `Some(Ok(reverted_output))` if the call is forbidden. - /// Returns `None` if the call may delegate to vanilla TIP20. - fn precheck( - &self, - selector: [u8; 4], - address: Address, - data: &[u8], - caller: Address, - ) -> Option { - let args = &data[4..]; - - match selector { - ITIP20::balanceOfCall::SELECTOR => { - let call = decode_or_revert!(ITIP20::balanceOfCall, args); - self.enforce_balance_of(call.account, caller) - } - ITIP20::allowanceCall::SELECTOR => { - let call = decode_or_revert!(ITIP20::allowanceCall, args); - self.enforce_allowance(call.owner, call.spender, caller) - } - ITIP20::transferCall::SELECTOR => { - let call = decode_or_revert!(ITIP20::transferCall, args); - self.enforce_transfer(address, caller, call.to) - } - ITIP20::transferFromCall::SELECTOR => { - let call = decode_or_revert!(ITIP20::transferFromCall, args); - self.enforce_transfer(address, call.from, call.to) - } - ITIP20::transferWithMemoCall::SELECTOR => { - let call = decode_or_revert!(ITIP20::transferWithMemoCall, args); - self.enforce_transfer(address, caller, call.to) - } - ITIP20::transferFromWithMemoCall::SELECTOR => { - let call = decode_or_revert!(ITIP20::transferFromWithMemoCall, args); - self.enforce_transfer(address, call.from, call.to) - } - ITIP20::mintCall::SELECTOR => { - if let Some(revert) = self.reject_crossed_mint_caller(caller) { - return Some(revert); - } - let call = decode_or_revert!(ITIP20::mintCall, args); - self.enforce_mint(address, call.to) - } - ITIP20::mintWithMemoCall::SELECTOR => { - if let Some(revert) = self.reject_crossed_mint_caller(caller) { - return Some(revert); - } - let call = decode_or_revert!(ITIP20::mintWithMemoCall, args); - self.enforce_mint(address, call.to) - } - ITIP20::burnCall::SELECTOR | ITIP20::burnWithMemoCall::SELECTOR => { - self.reject_crossed_burn_caller(caller) - } - ITIP20::userRewardInfoCall::SELECTOR => { - let call = decode_or_revert!(ITIP20::userRewardInfoCall, args); - self.enforce_balance_of(call.account, caller) - } - ITIP20::getPendingRewardsCall::SELECTOR => { - let call = decode_or_revert!(ITIP20::getPendingRewardsCall, args); - self.enforce_balance_of(call.account, caller) - } - IRolesAuth::hasRoleCall::SELECTOR => { - let call = decode_or_revert!(IRolesAuth::hasRoleCall, args); - self.enforce_balance_of(call.account, caller) - } - _ => None, - } - } -} - -impl

ZoneTip20Token

-where - P: PolicyCheck + Clone + Send + Sync + 'static, -{ - /// Create a [`DynPrecompile`] for a zone-side TIP-20 token at `address`. - /// - /// The returned precompile: - /// 1. Rejects uninitialized TIP-20-prefix addresses. - /// 2. Checks the 4-byte selector for transfer/mint calls. - /// 3. When a TIP-403 registry is configured, reads `transfer_policy_id` - /// from EVM storage and checks authorization via the - /// [`ZoneTip403ProxyRegistry`]. - /// 4. Delegates to the vanilla `TIP20Token::call()` for execution. - pub fn create( - address: Address, - cfg: &revm::context::CfgEnv, - registry: Option>, - sequencer: Arc, - ) -> DynPrecompile { - let spec = cfg.spec; - let amsterdam_eip8037_enabled = cfg.enable_amsterdam_eip8037; - let gas_params = cfg.gas_params.clone(); - let token = Self::new(registry, sequencer); - - DynPrecompile::new_stateful( - PrecompileId::Custom("ZoneTip20Token".into()), - move |input| { - if !input.is_direct_call() { - return Ok(PrecompileOutput::revert( - 0, - SolError::abi_encode(&DelegateCallNotAllowed {}).into(), - input.reservoir, - )); - } - - let selector = Self::selector(input.data); - let is_fixed_gas = selector.is_some_and(Self::is_fixed_gas_selector); - if is_fixed_gas && input.gas < FIXED_TRANSFER_GAS { - return Ok(PrecompileOutput::halt( - PrecompileHalt::OutOfGas, - input.reservoir, - )); - } - - let mut storage = EvmPrecompileStorageProvider::new( - input.internals, - if is_fixed_gas { u64::MAX } else { input.gas }, - input.reservoir, - spec, - amsterdam_eip8037_enabled, - input.is_static, - gas_params.clone(), - ); - - StorageCtx::enter(&mut storage, || { - let storage = StorageCtx::default(); - let finish = |result| { - if is_fixed_gas { - Self::apply_fixed_gas(result) - } else { - result - } - }; - - let mut tip20 = - TIP20Token::from_address(address).expect("TIP20 prefix already verified"); - - if let Err(err) = Self::ensure_initialized(&tip20) { - return finish(Self::add_input_cost(input.data, storage.error_result(err))); - } - - if let Some(selector) = selector - && let Some(revert) = - token.precheck(selector, address, input.data, input.caller) - { - return finish(Self::add_input_cost(input.data, revert)); - } - - finish(tip20.call(input.data, input.caller)) - }) - }, - ) - } -} diff --git a/crates/precompiles/src/ztip20/mod.rs b/crates/precompiles/src/ztip20/mod.rs index b64901a27..ddc9af5e5 100644 --- a/crates/precompiles/src/ztip20/mod.rs +++ b/crates/precompiles/src/ztip20/mod.rs @@ -1,232 +1,152 @@ -//! Zone-specific TIP-20 token precompile with PolicyCheck-backed authorization. +//! Zone pre-execution rules for the upstream Tempo TIP20 precompile. //! -//! On L1, the vanilla [`TIP20Token`] checks transfer/mint authorization by -//! instantiating a `TIP403Registry` in Rust which reads EVM storage at -//! `0x403C…0000`. On the zone, that storage is empty (defaults to policy 1 = -//! allow-all), so all transfers pass regardless of L1 blacklists. +//! `TIP20Token` remains the source of truth for token and TIP403 policy behavior. +//! Before forwarding a call to Tempo, `TIP20Rules` applies only zone-specific checks: +//! privacy-gated reads, fixed gas for selected selectors, and bridge mint/burn callers. //! -//! This wrapper intercepts transfer and mint calls, checks authorization -//! against the zone's [`ZoneTip403ProxyRegistry`] (which delegates to -//! [`PolicyCheck`] — cache-first, L1 RPC fallback), and only then delegates -//! to the vanilla `TIP20Token` implementation. +//! Accepted calldata and callers are forwarded unchanged to Tempo. Ordinary token state remains +//! Zone-local while the EVM context's database adapter exposes selected policy values from the +//! finalized Tempo L1 state. use alloc::sync::Arc; -mod dispatch; - use alloy_primitives::Address; -use alloy_sol_types::{SolError, SolInterface}; -use revm::precompile::{PrecompileError, PrecompileOutput, PrecompileResult}; -use tempo_contracts::precompiles::TIP20Error; +use alloy_sol_types::{SolCall, SolError}; use tempo_precompiles::{ - Result as TempoResult, - storage::{ContractStorage, StorageCtx}, - tip20::{RolesAuthError, TIP20Token}, + dispatch::selector_from_calldata, + tip20::{IRolesAuth, ITIP20}, }; use tempo_zone_contracts::Unauthorized; -use tracing::{trace, warn}; -use zone_primitives::{ - constants::{ZONE_INBOX_ADDRESS, ZONE_OUTBOX_ADDRESS}, - policy::AuthRole, -}; +use zone_primitives::constants::{ZONE_INBOX_ADDRESS, ZONE_OUTBOX_ADDRESS}; -use crate::{ - policy::PolicyCheck, - tip403_proxy::{AUTH_CHECK_GAS, ZoneTip403ProxyRegistry}, -}; +use crate::execution::{CallCheck, CallRules}; -const FIXED_TRANSFER_GAS: u64 = 100_000; +/// Fixed gas charged for TIP20 transfer and approval selectors on the zone. +pub const TIP20_FIXED_TRANSFER_GAS: u64 = 100_000; + +pub(crate) const TIP20_FIXED_GAS_SELECTORS: &[[u8; 4]] = &[ + ITIP20::transferCall::SELECTOR, + ITIP20::transferFromCall::SELECTOR, + ITIP20::transferWithMemoCall::SELECTOR, + ITIP20::transferFromWithMemoCall::SELECTOR, + ITIP20::approveCall::SELECTOR, +]; + +fn decode_and_check(args: &[u8], check: impl FnOnce(C) -> CallCheck) -> CallCheck { + match C::abi_decode_raw_validate(args) { + Ok(decoded) => check(decoded), + Err(_) => CallCheck::Continue, + } +} /// Capability trait for resolving the active zone sequencer. /// -/// The zone runtime implements this for its L1-backed state provider so the -/// precompile can enforce sequencer-visible reads without knowing about the -/// concrete provider type. +/// The zone runtime implements this for its L1 state provider so rules can authorize +/// sequencer-visible reads without depending on the concrete provider type. pub trait SequencerExt: Send + Sync { /// Return the latest known active sequencer. fn latest_sequencer(&self) -> Option

; } -/// Zone-specific TIP-20 token precompile. -/// -/// Wraps the vanilla [`TIP20Token`] and the [`ZoneTip403ProxyRegistry`] to add -/// optional PolicyCheck-backed authorization for transfers and mints, privacy-gated -/// `balanceOf`/`allowance`, fixed gas for transfer-family calls and `approve`, -/// and operation-specific bridge auth for mint/burn selectors. -pub struct ZoneTip20Token

{ - /// Optional TIP-403 registry wrapper used for transfer and mint-recipient policy checks. - registry: Option>, +/// Zone-specific rules applied before forwarding to upstream `TIP20Token`. +#[derive(Clone)] +pub(crate) struct TIP20Rules { /// Sequencer-capable backend used to authorize private reads for the active sequencer. sequencer: Arc, } -impl ZoneTip20Token

{ - /// Create a new wrapper with the given registry. - pub fn new( - registry: Option>, - sequencer: Arc, - ) -> Self { - Self { - registry, - sequencer, - } - } - - /// Enforce the vanilla TIP-20 initialized-token check before zone policy logic. - fn ensure_initialized(tip20: &TIP20Token) -> TempoResult<()> { - if tip20.is_initialized()? { - Ok(()) - } else { - Err(TIP20Error::uninitialized().into()) - } +impl TIP20Rules { + pub(crate) fn new(sequencer: Arc) -> Self { + Self { sequencer } } +} - fn enforce_balance_of(&self, account: Address, caller: Address) -> Option { - if caller == account || self.is_sequencer(caller) { - None - } else { - Some(Ok(Self::unauthorized_output())) - } +impl CallRules for TIP20Rules { + fn fixed_gas(&self, selector: Option<[u8; 4]>) -> Option { + selector + .is_some_and(|selector| TIP20_FIXED_GAS_SELECTORS.contains(&selector)) + .then_some(TIP20_FIXED_TRANSFER_GAS) } - fn enforce_allowance( - &self, - owner: Address, - spender: Address, - caller: Address, - ) -> Option { - if caller == owner || caller == spender || self.is_sequencer(caller) { - None - } else { - Some(Ok(Self::unauthorized_output())) - } - } - - /// Check sender + recipient authorization for a transfer. - /// - /// Returns `Some(revert)` if forbidden, `None` if allowed. - fn enforce_transfer( - &self, - token: Address, - from: Address, - to: Address, - ) -> Option { - let registry = self.registry.as_ref()?; - let policy_id = match Self::resolve_transfer_policy_id(registry, token) { - Ok(id) => id, - Err(e) => { - warn!( - target: "zone::precompile", - %token, error = %e, - "failed to resolve transfer_policy_id, rejecting transfer" - ); - return Some(Err(e)); - } + /// Apply zone privacy and bridge-path checks before upstream execution. + fn admit(&self, data: &[u8], caller: Address) -> CallCheck { + let Some(selector) = selector_from_calldata(data) else { + return CallCheck::Continue; }; + let args = &data[4..]; - trace!( - target: "zone::precompile", - %token, %from, %to, policy_id, - "ZoneTip20Token: checking transfer authorization" - ); - - match registry.is_transfer_authorized(policy_id, from, to) { - Ok(true) => None, - Ok(false) => { - trace!( - target: "zone::precompile", - %from, %to, policy_id, "transfer not authorized" - ); - Some(Ok(Self::policy_forbids_output())) + match selector { + ITIP20::mintCall::SELECTOR | ITIP20::mintWithMemoCall::SELECTOR => { + self.check_mint_auth(caller) + } + ITIP20::burnCall::SELECTOR | ITIP20::burnWithMemoCall::SELECTOR => { + self.check_burn_auth(caller) } - Err(e) => Some(Err(e)), + ITIP20::balanceOfCall::SELECTOR => { + decode_and_check::(args, |decoded| { + self.check_balance_read(decoded.account, caller) + }) + } + ITIP20::allowanceCall::SELECTOR => { + decode_and_check::(args, |decoded| { + self.check_allowance_read(decoded.owner, decoded.spender, caller) + }) + } + IRolesAuth::hasRoleCall::SELECTOR => { + decode_and_check::(args, |decoded| { + self.check_balance_read(decoded.account, caller) + }) + } + _ => CallCheck::Continue, } } +} - /// Check mint recipient authorization. - /// - /// Returns `Some(revert)` if forbidden, `None` if allowed. - /// Resolution errors are treated as allow because mints are triggered by - /// deposit system transactions whose policy is already enforced on L1. - fn enforce_mint(&self, token: Address, to: Address) -> Option { - let registry = self.registry.as_ref()?; - let policy_id = match Self::resolve_transfer_policy_id(registry, token) { - Ok(id) => id, - Err(e) => { - warn!( - target: "zone::precompile", - %token, error = %e, - "failed to resolve transfer_policy_id for mint, deferring to L1 enforcement" - ); - return None; - } - }; - - trace!( - target: "zone::precompile", - %token, %to, policy_id, - "ZoneTip20Token: checking mint recipient authorization" - ); +fn unauthorized() -> CallCheck { + CallCheck::Revert(Unauthorized {}.abi_encode().into()) +} - match registry.is_authorized(policy_id, to, AuthRole::MintRecipient) { - Ok(true) => None, - Ok(false) => { - trace!(target: "zone::precompile", %to, policy_id, "mint recipient not authorized"); - Some(Ok(Self::policy_forbids_output())) - } - Err(e) => Some(Err(e)), +impl TIP20Rules { + fn check_balance_read(&self, owner: Address, caller: Address) -> CallCheck { + if caller == owner { + return CallCheck::Continue; } + self.check_sequencer(caller) } - /// Reject the system caller that is only allowed on the opposite bridge path. - fn reject_crossed_mint_caller(&self, caller: Address) -> Option { - if caller == ZONE_OUTBOX_ADDRESS { - Some(Ok(Self::roles_unauthorized_output())) - } else { - None + fn check_allowance_read(&self, owner: Address, spender: Address, caller: Address) -> CallCheck { + if caller == spender { + return CallCheck::Continue; } + self.check_balance_read(owner, caller) } - /// Reject the system caller that is only allowed on the opposite bridge path. - fn reject_crossed_burn_caller(&self, caller: Address) -> Option { + fn check_mint_auth(&self, caller: Address) -> CallCheck { if caller == ZONE_INBOX_ADDRESS { - Some(Ok(Self::roles_unauthorized_output())) + CallCheck::Continue } else { - None + unauthorized() } } - /// Resolve the `transfer_policy_id` for a token. - fn resolve_transfer_policy_id( - registry: &ZoneTip403ProxyRegistry

, - token: Address, - ) -> Result { - registry.resolve_transfer_policy_id(token) + fn check_burn_auth(&self, caller: Address) -> CallCheck { + if caller == ZONE_OUTBOX_ADDRESS { + CallCheck::Continue + } else { + unauthorized() + } } - fn is_sequencer(&self, caller: Address) -> bool { - self.sequencer + fn check_sequencer(&self, caller: Address) -> CallCheck { + if self + .sequencer .latest_sequencer() .is_some_and(|sequencer| caller == sequencer) - } - - fn unauthorized_output() -> PrecompileOutput { - StorageCtx::default().revert_output(Unauthorized {}.abi_encode().into()) - } - - fn roles_unauthorized_output() -> PrecompileOutput { - StorageCtx::default().revert_output(RolesAuthError::unauthorized().selector().into()) - } - - /// Build a reverted output with the `policyForbids()` error selector. - fn policy_forbids_output() -> PrecompileOutput { - PrecompileOutput::revert( - AUTH_CHECK_GAS, - tempo_contracts::precompiles::TIP20Error::policy_forbids() - .selector() - .into(), - StorageCtx::default().reservoir(), - ) + { + CallCheck::Continue + } else { + unauthorized() + } } } @@ -234,101 +154,21 @@ impl ZoneTip20Token

{ mod tests { use super::*; use alloy::primitives::{Address, Bytes, U256, address}; - use alloy_evm::{ - EvmInternals, - precompiles::{DynPrecompile, Precompile as AlloyEvmPrecompile, PrecompileInput}, - }; - use alloy_sol_types::SolCall; - use revm::{ - Context, - database::{CacheDB, EmptyDB}, - precompile::{PrecompileHalt, PrecompileResult}, - }; - use tempo_chainspec::hardfork::TempoHardfork; + use alloy_evm::precompiles::DynPrecompile; + use alloy_sol_types::{SolCall, SolInterface}; + use revm::precompile::PrecompileResult; + use tempo_contracts::precompiles::TIP20Error; use tempo_precompiles::{ PATH_USD_ADDRESS, - storage::evm::EvmPrecompileStorageProvider, + storage::StorageCtx, + test_util::TIP20Setup, tip20::{IRolesAuth, ISSUER_ROLE, ITIP20, TIP20Token}, }; + use tempo_zone_contracts::Unauthorized; - type TestResult = Result>; - type TestContext = Context< - revm::context::BlockEnv, - revm::context::TxEnv, - revm::context::CfgEnv, - CacheDB, - >; - - #[derive(Clone, Default)] - struct MockPolicyProvider { - transfer_authorized: bool, - mint_authorized: bool, - policy_id: u64, - fail_policy_id_resolution: bool, - } - - impl MockPolicyProvider { - fn allow_all() -> Self { - Self { - transfer_authorized: true, - mint_authorized: true, - policy_id: 1, - fail_policy_id_resolution: false, - } - } - - fn failing() -> Self { - Self { - fail_policy_id_resolution: true, - ..Default::default() - } - } - } - - impl PolicyCheck for MockPolicyProvider { - fn is_authorized( - &self, - _policy_id: u64, - _user: Address, - role: AuthRole, - ) -> Result { - let authorized = match role { - AuthRole::MintRecipient => self.mint_authorized, - _ => self.transfer_authorized, - }; - Ok(authorized) - } - - fn resolve_transfer_policy_id(&self, _token: Address) -> Result { - if self.fail_policy_id_resolution { - return Err(PrecompileError::Fatal("RPC unavailable".into())); - } - Ok(self.policy_id) - } - - fn policy_type_sync( - &self, - _policy_id: u64, - ) -> Result - { - Ok(tempo_contracts::precompiles::ITIP403Registry::PolicyType::BLACKLIST) - } - - fn compound_policy_data( - &self, - _policy_id: u64, - ) -> Result<(u64, u64, u64), PrecompileError> { - Ok((self.policy_id, self.policy_id, self.policy_id)) - } - - fn policy_exists(&self, _policy_id: u64) -> Result { - Ok(true) - } - - fn policy_id_counter(&self) -> u64 { - self.policy_id - } - } + use crate::test_utils::{ + TestContext, call_precompile, test_context, test_env, test_storage_provider, + }; #[derive(Clone, Copy)] struct MockSequencer { @@ -341,6 +181,26 @@ mod tests { } } + fn rules(sequencer: Address) -> TIP20Rules { + TIP20Rules::new(Arc::new(MockSequencer { + address: Some(sequencer), + })) + } + + fn assert_allowed(rules: &TIP20Rules, call: impl SolCall, caller: Address) { + assert!(matches!( + rules.admit(&call.abi_encode(), caller), + CallCheck::Continue + )); + } + + fn assert_unauthorized(rules: &TIP20Rules, call: impl SolCall, caller: Address) { + assert!(matches!( + rules.admit(&call.abi_encode(), caller), + CallCheck::Revert(data) if data == Unauthorized {}.abi_encode() + )); + } + struct PrecompileHarness { ctx: TestContext, token: Address, @@ -348,20 +208,11 @@ mod tests { bob: Address, spender: Address, sequencer: Address, - issuer: Address, precompile: DynPrecompile, } impl PrecompileHarness { - fn new(policy: MockPolicyProvider) -> TestResult { - Self::new_with_registry(Some(policy)) - } - - fn new_without_registry() -> TestResult { - Self::new_with_registry(None) - } - - fn new_with_registry(policy: Option) -> TestResult { + fn new() -> eyre::Result { let token = PATH_USD_ADDRESS; let admin = address!("0x00000000000000000000000000000000000000a1"); let alice = address!("0x00000000000000000000000000000000000000a2"); @@ -369,53 +220,33 @@ mod tests { let spender = address!("0x00000000000000000000000000000000000000a4"); let issuer = address!("0x00000000000000000000000000000000000000a5"); let sequencer = address!("0x00000000000000000000000000000000000000a6"); - let mut ctx = Context::new(CacheDB::new(EmptyDB::new()), TempoHardfork::default()); - - Self::with_storage(&mut ctx, u64::MAX, |storage| { - StorageCtx::enter(storage, || -> TestResult { - let mut token_contract = - TIP20Token::from_address(token).expect("PATH_USD must be valid"); - token_contract.initialize( - admin, - "Zone USD", - "zUSD", - "USD", - Address::ZERO, - admin, - )?; - token_contract.grant_role_internal(admin, *ISSUER_ROLE)?; - token_contract.grant_role_internal(issuer, *ISSUER_ROLE)?; - token_contract.grant_role_internal(ZONE_INBOX_ADDRESS, *ISSUER_ROLE)?; - token_contract.grant_role_internal(ZONE_OUTBOX_ADDRESS, *ISSUER_ROLE)?; - token_contract.mint( - admin, - ITIP20::mintCall { - to: alice, - amount: U256::from(1_000_000u64), - }, - )?; - token_contract.mint( - admin, - ITIP20::mintCall { - to: ZONE_OUTBOX_ADDRESS, - amount: U256::from(10_000u64), - }, - )?; - token_contract.approve( - alice, - ITIP20::approveCall { - spender, - amount: U256::from(300_000u64), - }, + let mut ctx = test_context(); + + { + let mut storage = test_storage_provider(&mut ctx, u64::MAX, false); + StorageCtx::enter(&mut storage, || -> eyre::Result<()> { + StorageCtx::default().sstore( + zone_primitives::constants::TEMPO_STATE_ADDRESS, + crate::tempo_state::slots::TEMPO_BLOCK_NUMBER, + U256::from(7u64), )?; + TIP20Setup::path_usd(admin) + .with_issuer(admin) + .with_issuer(issuer) + .with_issuer(ZONE_INBOX_ADDRESS) + .with_issuer(ZONE_OUTBOX_ADDRESS) + .with_mint(alice, U256::from(1_000_000u64)) + .with_mint(ZONE_OUTBOX_ADDRESS, U256::from(10_000u64)) + .with_approval(alice, spender, U256::from(300_000u64)) + .apply()?; Ok(()) - }) - })?; + })?; + } - let precompile = ZoneTip20Token::create( + let env = test_env(&ctx); + let precompile = crate::create_tip20_precompile( token, - &ctx.cfg, - policy.map(ZoneTip403ProxyRegistry::new), + &env, Arc::new(MockSequencer { address: Some(sequencer), }), @@ -428,32 +259,10 @@ mod tests { bob, spender, sequencer, - issuer, precompile, }) } - fn with_storage( - ctx: &mut TestContext, - gas_limit: u64, - f: impl FnOnce(&mut EvmPrecompileStorageProvider<'_>) -> TestResult, - ) -> TestResult { - let spec = ctx.cfg.spec; - let amsterdam_eip8037_enabled = ctx.cfg.enable_amsterdam_eip8037; - let gas_params = ctx.cfg.gas_params.clone(); - let internals = EvmInternals::from_context(ctx); - let mut storage = EvmPrecompileStorageProvider::new( - internals, - gas_limit, - 0, - spec, - amsterdam_eip8037_enabled, - false, - gas_params, - ); - f(&mut storage) - } - fn call( &mut self, caller: Address, @@ -461,107 +270,66 @@ mod tests { gas: u64, is_static: bool, ) -> PrecompileResult { - AlloyEvmPrecompile::call( + call_precompile( + &mut self.ctx, &self.precompile, - PrecompileInput { - data: &calldata, - caller, - internals: EvmInternals::from_context(&mut self.ctx), - gas, - reservoir: 0, - value: U256::ZERO, - is_static, - target_address: self.token, - bytecode_address: self.token, - }, + caller, + &calldata, + gas, + is_static, + self.token, + self.token, ) } - fn balance_of(&mut self, account: Address) -> TestResult { - Self::with_storage(&mut self.ctx, u64::MAX, |storage| { - StorageCtx::enter(storage, || { - let token = TIP20Token::from_address(self.token).expect("token must exist"); - Ok(token.balance_of(ITIP20::balanceOfCall { account })?) - }) + fn balance_of(&mut self, account: Address) -> eyre::Result { + let mut storage = test_storage_provider(&mut self.ctx, u64::MAX, false); + StorageCtx::enter(&mut storage, || { + let token = TIP20Token::from_address(self.token).expect("token must exist"); + Ok(token.balance_of(ITIP20::balanceOfCall { account })?) }) } - fn allowance(&mut self, owner: Address, spender: Address) -> TestResult { - Self::with_storage(&mut self.ctx, u64::MAX, |storage| { - StorageCtx::enter(storage, || { - let token = TIP20Token::from_address(self.token).expect("token must exist"); - Ok(token.allowance(ITIP20::allowanceCall { owner, spender })?) - }) + fn allowance(&mut self, owner: Address, spender: Address) -> eyre::Result { + let mut storage = test_storage_provider(&mut self.ctx, u64::MAX, false); + StorageCtx::enter(&mut storage, || { + let token = TIP20Token::from_address(self.token).expect("token must exist"); + Ok(token.allowance(ITIP20::allowanceCall { owner, spender })?) }) } } #[test] - fn balance_of_enforces_account_or_sequencer_access() -> TestResult { - let mut harness = PrecompileHarness::new(MockPolicyProvider::allow_all())?; - let calldata: Bytes = ITIP20::balanceOfCall { - account: harness.alice, - } - .abi_encode() - .into(); - - let owner = harness.call(harness.alice, calldata.clone(), 100_000, true)?; - assert_eq!( - ITIP20::balanceOfCall::abi_decode_returns(&owner.bytes)?, - U256::from(1_000_000u64) - ); - - let sequencer = harness.call(harness.sequencer, calldata.clone(), 100_000, true)?; - assert_eq!( - ITIP20::balanceOfCall::abi_decode_returns(&sequencer.bytes)?, - U256::from(1_000_000u64) - ); - - let outsider = harness.call(harness.bob, calldata, 100_000, true)?; - assert!(outsider.is_revert()); - assert_eq!(outsider.bytes, Bytes::from(Unauthorized {}.abi_encode())); - - Ok(()) - } - - #[test] - fn allowance_enforces_owner_spender_or_sequencer_access() -> TestResult { - let mut harness = PrecompileHarness::new(MockPolicyProvider::allow_all())?; - let calldata: Bytes = ITIP20::allowanceCall { - owner: harness.alice, - spender: harness.spender, - } - .abi_encode() - .into(); - - let owner = harness.call(harness.alice, calldata.clone(), 100_000, true)?; - assert_eq!( - ITIP20::allowanceCall::abi_decode_returns(&owner.bytes)?, - U256::from(300_000u64) - ); - - let spender = harness.call(harness.spender, calldata.clone(), 100_000, true)?; - assert_eq!( - ITIP20::allowanceCall::abi_decode_returns(&spender.bytes)?, - U256::from(300_000u64) - ); - - let sequencer = harness.call(harness.sequencer, calldata.clone(), 100_000, true)?; - assert_eq!( - ITIP20::allowanceCall::abi_decode_returns(&sequencer.bytes)?, - U256::from(300_000u64) - ); - - let outsider = harness.call(harness.bob, calldata, 100_000, true)?; - assert!(outsider.is_revert()); - assert_eq!(outsider.bytes, Bytes::from(Unauthorized {}.abi_encode())); - - Ok(()) + fn read_privacy_rules_allow_owner_spender_and_sequencer() { + let owner = Address::repeat_byte(0x11); + let spender = Address::repeat_byte(0x22); + let sequencer = Address::repeat_byte(0x33); + let outsider = Address::repeat_byte(0x44); + let rules = rules(sequencer); + + let balance = ITIP20::balanceOfCall { account: owner }; + assert_allowed(&rules, balance.clone(), owner); + assert_allowed(&rules, balance.clone(), sequencer); + assert_unauthorized(&rules, balance, outsider); + + let allowance = ITIP20::allowanceCall { owner, spender }; + for caller in [owner, spender, sequencer] { + assert_allowed(&rules, allowance.clone(), caller); + } + assert_unauthorized(&rules, allowance, outsider); + + let role = IRolesAuth::hasRoleCall { + account: owner, + role: *ISSUER_ROLE, + }; + assert_allowed(&rules, role.clone(), owner); + assert_allowed(&rules, role.clone(), sequencer); + assert_unauthorized(&rules, role, outsider); } #[test] - fn wrapper_without_policy_registry_still_enforces_privacy_and_fixed_gas() -> TestResult { - let mut harness = PrecompileHarness::new_without_registry()?; + fn wrapper_still_enforces_privacy_and_fixed_gas() -> eyre::Result<()> { + let mut harness = PrecompileHarness::new()?; let private_balance = harness.call( harness.bob, @@ -570,7 +338,7 @@ mod tests { } .abi_encode() .into(), - FIXED_TRANSFER_GAS, + TIP20_FIXED_TRANSFER_GAS, true, )?; assert!(private_balance.is_revert()); @@ -587,29 +355,25 @@ mod tests { } .abi_encode() .into(), - FIXED_TRANSFER_GAS, + TIP20_FIXED_TRANSFER_GAS, false, )?; assert!(transfer.is_success()); - assert_eq!(transfer.gas_used, FIXED_TRANSFER_GAS); + assert_eq!(transfer.gas_used, TIP20_FIXED_TRANSFER_GAS); assert_eq!(harness.balance_of(harness.bob)?, U256::from(12_345u64)); Ok(()) } #[test] - fn uninitialized_token_rejects_before_policy_precheck() -> TestResult { + fn uninitialized_token_rejects_before_policy_read() -> eyre::Result<()> { let token = address!("20C0000000000000000000000000000000000999"); let caller = address!("0x00000000000000000000000000000000000000a2"); let to = address!("0x00000000000000000000000000000000000000a3"); - let mut ctx: TestContext = - Context::new(CacheDB::new(EmptyDB::new()), TempoHardfork::default()); - let precompile = ZoneTip20Token::create( - token, - &ctx.cfg, - Some(ZoneTip403ProxyRegistry::new(MockPolicyProvider::failing())), - Arc::new(MockSequencer { address: None }), - ); + let mut ctx = test_context(); + let env = test_env(&ctx); + let precompile = + crate::create_tip20_precompile(token, &env, Arc::new(MockSequencer { address: None })); let calldata: Bytes = ITIP20::transferCall { to, amount: U256::from(1u64), @@ -617,19 +381,15 @@ mod tests { .abi_encode() .into(); - let result = AlloyEvmPrecompile::call( + let result = call_precompile( + &mut ctx, &precompile, - PrecompileInput { - data: &calldata, - caller, - internals: EvmInternals::from_context(&mut ctx), - gas: FIXED_TRANSFER_GAS, - reservoir: 0, - value: U256::ZERO, - is_static: false, - target_address: token, - bytecode_address: token, - }, + caller, + &calldata, + TIP20_FIXED_TRANSFER_GAS, + false, + token, + token, )?; assert!(result.is_revert()); @@ -642,259 +402,163 @@ mod tests { } #[test] - fn bridge_auth_rejects_crossed_system_calls_and_keeps_allowed_paths() -> TestResult { - let mut harness = PrecompileHarness::new(MockPolicyProvider::allow_all())?; + fn malformed_calldata_uses_upstream_dispatch() -> eyre::Result<()> { + let mut harness = PrecompileHarness::new()?; - let inbox_mint = harness.call( - ZONE_INBOX_ADDRESS, - ITIP20::mintCall { - to: harness.bob, - amount: U256::from(50_000u64), - } - .abi_encode() - .into(), + let balance_of = harness.call( + harness.alice, + Bytes::from(ITIP20::balanceOfCall::SELECTOR.to_vec()), 100_000, - false, + true, )?; - assert!(inbox_mint.is_success()); - assert_eq!(harness.balance_of(harness.bob)?, U256::from(50_000u64)); + assert!(balance_of.is_revert()); + assert_eq!(balance_of.bytes, Bytes::new()); - let outbox_burn = harness.call( - ZONE_OUTBOX_ADDRESS, - ITIP20::burnCall { - amount: U256::from(10_000u64), - } - .abi_encode() - .into(), - 100_000, + let transfer = harness.call( + harness.alice, + Bytes::from(ITIP20::transferCall::SELECTOR.to_vec()), + TIP20_FIXED_TRANSFER_GAS, false, )?; - assert!(outbox_burn.is_success()); - assert_eq!(harness.balance_of(ZONE_OUTBOX_ADDRESS)?, U256::ZERO); + assert!(transfer.is_revert()); + assert_eq!(transfer.bytes, Bytes::new()); + assert_eq!(transfer.gas_used, TIP20_FIXED_TRANSFER_GAS); - let crossed_mint = harness.call( - ZONE_OUTBOX_ADDRESS, + Ok(()) + } + + #[test] + fn bridge_auth_rules_and_allowed_paths() -> eyre::Result<()> { + let mut harness = PrecompileHarness::new()?; + let rules = rules(harness.sequencer); + assert_unauthorized( + &rules, ITIP20::mintCall { to: harness.bob, - amount: U256::from(1u64), - } - .abi_encode() - .into(), - 100_000, - false, - )?; - assert!(crossed_mint.is_revert()); - assert_eq!( - crossed_mint.bytes, - Bytes::from(RolesAuthError::unauthorized().selector().to_vec()) + amount: U256::ONE, + }, + ZONE_OUTBOX_ADDRESS, ); - - let crossed_burn = harness.call( + assert_unauthorized( + &rules, + ITIP20::burnCall { amount: U256::ONE }, ZONE_INBOX_ADDRESS, - ITIP20::burnCall { - amount: U256::from(1u64), - } - .abi_encode() - .into(), - 100_000, - false, - )?; - assert!(crossed_burn.is_revert()); - assert_eq!( - crossed_burn.bytes, - Bytes::from(RolesAuthError::unauthorized().selector().to_vec()) ); - let issuer_mint = harness.call( - harness.issuer, + let inbox_mint = harness.call( + ZONE_INBOX_ADDRESS, ITIP20::mintCall { - to: harness.issuer, - amount: U256::from(25_000u64), + to: harness.bob, + amount: U256::from(50_000u64), } .abi_encode() .into(), 100_000, false, )?; - assert!(issuer_mint.is_success()); + assert!(inbox_mint.is_success()); + assert_eq!(harness.balance_of(harness.bob)?, U256::from(50_000u64)); - let issuer_burn = harness.call( - harness.issuer, + let outbox_burn = harness.call( + ZONE_OUTBOX_ADDRESS, ITIP20::burnCall { - amount: U256::from(5_000u64), + amount: U256::from(10_000u64), } .abi_encode() .into(), 100_000, false, )?; - assert!(issuer_burn.is_success()); + assert!(outbox_burn.is_success()); + assert_eq!(harness.balance_of(ZONE_OUTBOX_ADDRESS)?, U256::ZERO); Ok(()) } #[test] - fn fixed_gas_selectors_charge_exactly_one_hundred_thousand_gas() -> TestResult { - let mut harness = PrecompileHarness::new(MockPolicyProvider::allow_all())?; - - let approve = harness.call( - harness.alice, - ITIP20::approveCall { - spender: harness.spender, - amount: U256::from(111_111u64), - } - .abi_encode() - .into(), - FIXED_TRANSFER_GAS, - false, - )?; - assert_eq!(approve.gas_used, FIXED_TRANSFER_GAS); - assert_eq!(approve.state_gas_used, 0); - - let approve_update = harness.call( - harness.alice, - ITIP20::approveCall { - spender: harness.spender, - amount: U256::from(222_222u64), - } - .abi_encode() - .into(), - FIXED_TRANSFER_GAS, - false, - )?; - assert_eq!(approve_update.gas_used, FIXED_TRANSFER_GAS); - assert_eq!(approve_update.state_gas_used, 0); - - let transfer_new = harness.call( - harness.alice, - ITIP20::transferCall { - to: harness.bob, - amount: U256::from(10_000u64), - } - .abi_encode() - .into(), - FIXED_TRANSFER_GAS, - false, - )?; - assert_eq!(transfer_new.gas_used, FIXED_TRANSFER_GAS); - assert_eq!(transfer_new.state_gas_used, 0); - - let transfer_existing = harness.call( - harness.alice, - ITIP20::transferCall { - to: harness.bob, - amount: U256::from(10_000u64), - } - .abi_encode() - .into(), - FIXED_TRANSFER_GAS, - false, - )?; - assert_eq!(transfer_existing.gas_used, FIXED_TRANSFER_GAS); - assert_eq!(transfer_existing.state_gas_used, 0); - - let transfer_with_memo = harness.call( - harness.alice, - ITIP20::transferWithMemoCall { - to: harness.bob, - amount: U256::from(10_000u64), - memo: Default::default(), - } - .abi_encode() - .into(), - FIXED_TRANSFER_GAS, - false, - )?; - assert_eq!(transfer_with_memo.gas_used, FIXED_TRANSFER_GAS); - assert_eq!(transfer_with_memo.state_gas_used, 0); - - let transfer_from = harness.call( - harness.spender, - ITIP20::transferFromCall { - from: harness.alice, - to: harness.bob, - amount: U256::from(10_000u64), - } - .abi_encode() - .into(), - FIXED_TRANSFER_GAS, - false, - )?; - assert_eq!(transfer_from.gas_used, FIXED_TRANSFER_GAS); - assert_eq!(transfer_from.state_gas_used, 0); - - let transfer_from_with_memo = harness.call( - harness.spender, - ITIP20::transferFromWithMemoCall { - from: harness.alice, - to: harness.bob, - amount: U256::from(10_000u64), - memo: Default::default(), - } - .abi_encode() - .into(), - FIXED_TRANSFER_GAS, - false, - )?; - assert_eq!(transfer_from_with_memo.gas_used, FIXED_TRANSFER_GAS); - assert_eq!(transfer_from_with_memo.state_gas_used, 0); + fn fixed_gas_selectors_charge_exactly_one_hundred_thousand_gas() -> eyre::Result<()> { + let mut harness = PrecompileHarness::new()?; + let calls: Vec<(Address, ITIP20::ITIP20Calls)> = vec![ + ( + harness.alice, + ITIP20::ITIP20Calls::approve(ITIP20::approveCall { + spender: harness.spender, + amount: U256::from(111_111u64), + }), + ), + ( + harness.alice, + ITIP20::ITIP20Calls::approve(ITIP20::approveCall { + spender: harness.spender, + amount: U256::from(222_222u64), + }), + ), + ( + harness.alice, + ITIP20::ITIP20Calls::transfer(ITIP20::transferCall { + to: harness.bob, + amount: U256::from(10_000u64), + }), + ), + ( + harness.alice, + ITIP20::ITIP20Calls::transfer(ITIP20::transferCall { + to: harness.bob, + amount: U256::from(10_000u64), + }), + ), + ( + harness.alice, + ITIP20::ITIP20Calls::transferWithMemo(ITIP20::transferWithMemoCall { + to: harness.bob, + amount: U256::from(10_000u64), + memo: Default::default(), + }), + ), + ( + harness.spender, + ITIP20::ITIP20Calls::transferFrom(ITIP20::transferFromCall { + from: harness.alice, + to: harness.bob, + amount: U256::from(10_000u64), + }), + ), + ( + harness.spender, + ITIP20::ITIP20Calls::transferFromWithMemo(ITIP20::transferFromWithMemoCall { + from: harness.alice, + to: harness.bob, + amount: U256::from(10_000u64), + memo: Default::default(), + }), + ), + ]; + for (caller, call) in calls { + let calldata = call.abi_encode().into(); + let output = harness.call(caller, calldata, TIP20_FIXED_TRANSFER_GAS, false)?; + assert_eq!(output.gas_used, TIP20_FIXED_TRANSFER_GAS); + assert_eq!(output.state_gas_used, 0); + } Ok(()) } #[test] - fn fixed_gas_selectors_fail_out_of_gas_below_threshold() -> TestResult { - let mut harness = PrecompileHarness::new(MockPolicyProvider::allow_all())?; - - for calldata in [ - ITIP20::transferCall { - to: harness.bob, - amount: U256::from(1u64), - } - .abi_encode() - .into(), - ITIP20::transferFromCall { - from: harness.alice, - to: harness.bob, - amount: U256::from(1u64), - } - .abi_encode() - .into(), - ITIP20::transferWithMemoCall { - to: harness.bob, - amount: U256::from(1u64), - memo: Default::default(), - } - .abi_encode() - .into(), - ITIP20::transferFromWithMemoCall { - from: harness.alice, - to: harness.bob, - amount: U256::from(1u64), - memo: Default::default(), - } - .abi_encode() - .into(), - ITIP20::approveCall { - spender: harness.spender, - amount: U256::from(1u64), - } - .abi_encode() - .into(), - ] { - let output = harness - .call(harness.alice, calldata, FIXED_TRANSFER_GAS - 1, false) - .expect("out of gas is returned as a halted precompile output"); - assert!(output.is_halt()); - assert_eq!(output.halt_reason(), Some(&PrecompileHalt::OutOfGas)); + fn fixed_gas_selector_mapping_is_complete() { + let rules = rules(Address::ZERO); + for selector in TIP20_FIXED_GAS_SELECTORS { + assert_eq!( + rules.fixed_gas(Some(*selector)), + Some(TIP20_FIXED_TRANSFER_GAS) + ); } - - Ok(()) + assert_eq!(rules.fixed_gas(Some([0xff; 4])), None); + assert_eq!(rules.fixed_gas(None), None); } #[test] - fn fixed_gas_keeps_allowance_and_balance_state_changes_intact() -> TestResult { - let mut harness = PrecompileHarness::new(MockPolicyProvider::allow_all())?; + fn fixed_gas_keeps_allowance_and_balance_state_changes_intact() -> eyre::Result<()> { + let mut harness = PrecompileHarness::new()?; let approve = harness.call( harness.alice, @@ -904,7 +568,7 @@ mod tests { } .abi_encode() .into(), - FIXED_TRANSFER_GAS, + TIP20_FIXED_TRANSFER_GAS, false, )?; assert!(approve.is_success()); @@ -921,7 +585,7 @@ mod tests { } .abi_encode() .into(), - FIXED_TRANSFER_GAS, + TIP20_FIXED_TRANSFER_GAS, false, )?; assert!(transfer.is_success()); @@ -929,120 +593,4 @@ mod tests { Ok(()) } - - #[test] - fn user_reward_info_enforces_account_or_sequencer_access() -> TestResult { - let mut harness = PrecompileHarness::new(MockPolicyProvider::allow_all())?; - let calldata: Bytes = ITIP20::userRewardInfoCall { - account: harness.alice, - } - .abi_encode() - .into(); - - // Owner can query their own reward info - let owner = harness.call(harness.alice, calldata.clone(), 100_000, true)?; - assert!(owner.is_success()); - - // Sequencer can query anyone's reward info - let sequencer = harness.call(harness.sequencer, calldata.clone(), 100_000, true)?; - assert!(sequencer.is_success()); - - // Outsider is rejected - let outsider = harness.call(harness.bob, calldata, 100_000, true)?; - assert!(outsider.is_revert()); - assert_eq!(outsider.bytes, Bytes::from(Unauthorized {}.abi_encode())); - - Ok(()) - } - - #[test] - fn get_pending_rewards_enforces_account_or_sequencer_access() -> TestResult { - let mut harness = PrecompileHarness::new(MockPolicyProvider::allow_all())?; - let calldata: Bytes = ITIP20::getPendingRewardsCall { - account: harness.alice, - } - .abi_encode() - .into(); - - // Owner can query their own pending rewards - let owner = harness.call(harness.alice, calldata.clone(), 100_000, true)?; - assert!(owner.is_success()); - - // Sequencer can query anyone's pending rewards - let sequencer = harness.call(harness.sequencer, calldata.clone(), 100_000, true)?; - assert!(sequencer.is_success()); - - // Outsider is rejected - let outsider = harness.call(harness.bob, calldata, 100_000, true)?; - assert!(outsider.is_revert()); - assert_eq!(outsider.bytes, Bytes::from(Unauthorized {}.abi_encode())); - - Ok(()) - } - - #[test] - fn transfer_fails_closed_on_policy_resolution_error() -> TestResult { - let mut harness = PrecompileHarness::new(MockPolicyProvider::failing())?; - - let calldata: Bytes = ITIP20::transferCall { - to: harness.bob, - amount: U256::from(100u64), - } - .abi_encode() - .into(); - - let result = harness.call(harness.alice, calldata, 100_000, false); - assert!( - result.is_err(), - "transfer must fail when policy resolution errors" - ); - - Ok(()) - } - - #[test] - fn mint_defers_to_l1_on_policy_resolution_error() -> TestResult { - let mut harness = PrecompileHarness::new(MockPolicyProvider::failing())?; - - let calldata: Bytes = ITIP20::mintCall { - to: harness.alice, - amount: U256::from(100u64), - } - .abi_encode() - .into(); - - let result = harness.call(harness.issuer, calldata, 100_000, false); - assert!( - result.is_ok(), - "mint must proceed when policy resolution errors (L1 enforces policy at deposit time)" - ); - - Ok(()) - } - - #[test] - fn has_role_enforces_account_or_sequencer_access() -> TestResult { - let mut harness = PrecompileHarness::new(MockPolicyProvider::allow_all())?; - let calldata: Bytes = IRolesAuth::hasRoleCall { - account: harness.alice, - role: *ISSUER_ROLE, - } - .abi_encode() - .into(); - - // Owner can query their own roles - let owner = harness.call(harness.alice, calldata.clone(), 100_000, true)?; - assert!(owner.is_success()); - - // Sequencer can query anyone's roles - let sequencer = harness.call(harness.sequencer, calldata.clone(), 100_000, true)?; - assert!(sequencer.is_success()); - - // Outsider is rejected - let outsider = harness.call(harness.bob, calldata, 100_000, true)?; - assert!(outsider.is_revert()); - assert_eq!(outsider.bytes, Bytes::from(Unauthorized {}.abi_encode())); - - Ok(()) - } } diff --git a/docs/ZONES.md b/docs/ZONES.md index 68c4fd5ca..7c7ea260d 100644 --- a/docs/ZONES.md +++ b/docs/ZONES.md @@ -527,9 +527,9 @@ Zones inherit the Tempo L1 EVM but replace, disable, or pass through each precom | Precompile | Address | Zone Behavior | |------------|---------|---------------| | Standard EVM (ecrecover, SHA-256, etc.) | `0x01`–`0x0a`, `0x0100` on T1C+ | **Unchanged** — standard Ethereum precompiles inherited from Tempo's active hardfork (Prague pre-T1C, Osaka at T1C+) are available as-is. | -| TIP-20 tokens | `0x20C0…` prefix | **Replaced** — routed through `ZoneTip20Token`, which adds privacy (caller-scoped reads), fixed gas for transfers, bridge-auth for mint/burn, and TIP-403 policy enforcement via the L1-synced cache. | +| TIP-20 tokens | `0x20C0…` prefix | **Adapted** — upstream Tempo TIP-20 business logic runs over zone-local token state and exact-block L1 policy state, with zone privacy (caller-scoped reads), fixed gas for transfers, and bridge authorization for mint/burn. | | TIP20Factory | `0x20FC…0000` | **Replaced** — `ZoneTokenFactory` exposes only `enableToken(address, name, symbol, currency)`, called by ZoneInbox during `advanceTempo` to initialize bridged tokens. | -| TIP403Registry | `0x403C…0000` | **Replaced** — read-only `ZoneTip403ProxyRegistry` serves authorization queries from a cache-first, L1-RPC-fallback provider. Mutating calls (`createPolicy`, `modifyPolicyWhitelist`, etc.) revert — policy state is managed on L1. | +| TIP403Registry | `0x403C…0000` | **Adapted** — the upstream Tempo registry executes read-only against raw L1 storage at the exact finalized block recorded in `TempoState`. Mutating calls (`createPolicy`, `modifyPolicyWhitelist`, etc.) revert because policy state is managed on L1. | | TipFeeManager | `0xfeec…0000` | **Present** — the precompile is still registered, but its liquidity pools are not used by transactions. The zone executor overrides `validatorTokens` to match each transaction's fee token, so the FeeAMM swap path is bypassed and fees are collected directly in the user's token. | | StablecoinDEX | `0xdec0…0000` | **Disabled** — not registered on zones, so the address behaves like an empty account. Users on zones can trade on the StablecoinDEX on Tempo via the bridge. | | NonceManager | `0x4E4F…0000` | **Unchanged** — same implementation as L1, runs locally on zone state. | diff --git a/specs/spec.md b/specs/spec.md index 0440fd35f..dff9fe2da 100644 --- a/specs/spec.md +++ b/specs/spec.md @@ -818,7 +818,7 @@ Current callers: - `ZoneInbox`: `currentDepositQueueHash` and encryption keys from the portal - `ZoneConfig`: sequencer address, token registry from the portal -TIP-403 policy authorization on the zone is handled by a dedicated read-only proxy precompile (at the same address as the L1 `TIP403Registry`), which resolves policy queries via the zone node's policy provider rather than calling `readTempoStorageSlot` directly. +TIP-403 policy authorization on the zone executes Tempo's registry precompile at the canonical address over raw L1 registry storage pinned to the current finalized `tempoBlockNumber`. ### Staleness and Finality @@ -834,7 +834,7 @@ Zones inherit compliance policies from Tempo automatically. Token issuers set tr ### Policy Enforcement on Zones -The zone has a `TIP403Registry` deployed at the same address as on Tempo. This contract is read-only and does not support writing policies. Its `isAuthorized` function reads policy state from Tempo via `TempoState.readTempoStorageSlot()`. +The zone has a `TIP403Registry` deployed at the same address as on Tempo. This contract is read-only and does not support writing policies. Its read methods execute Tempo's registry logic over raw L1 policy storage at the finalized `TempoState.tempoBlockNumber` anchor. Zone-side TIP-20 transfers check `isAuthorized(policyId, from)` and `isAuthorized(policyId, to)` before executing. If either check fails, the transfer reverts. @@ -2045,7 +2045,7 @@ Reads the sequencer address, token registry, and encryption key from the portal ### TIP-403 Registry -Deployed at the same address as on Tempo. Read-only on the zone. Its `isAuthorized(policyId, account)` function reads policy state from Tempo via `TempoState.readTempoStorageSlot()`. Zone-side TIP-20 transfers call this automatically. +Deployed at the same address as on Tempo. Read-only on the zone. Its read methods execute Tempo's registry logic over raw L1 policy storage at the finalized `TempoState.tempoBlockNumber` anchor. Zone-side TIP-20 transfers call this automatically.