diff --git a/Cargo.lock b/Cargo.lock index 4832f7ac44b..ba865e6d032 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9007,6 +9007,7 @@ dependencies = [ "alloy-consensus", "alloy-eips", "alloy-primitives", + "alloy-rpc-client", "alloy-rpc-types-eth", "c-kzg", "derive_more 2.0.1", @@ -9027,6 +9028,10 @@ dependencies = [ "reth-provider", "reth-storage-api", "reth-transaction-pool", + "serde", + "thiserror 2.0.12", + "tokio", + "tracing", ] [[package]] diff --git a/crates/optimism/node/src/args.rs b/crates/optimism/node/src/args.rs index 1926f74dab1..037e7311286 100644 --- a/crates/optimism/node/src/args.rs +++ b/crates/optimism/node/src/args.rs @@ -2,6 +2,9 @@ //! clap [Args](clap::Args) for optimism rollup configuration +use op_alloy_consensus::interop::SafetyLevel; +use reth_optimism_txpool::supervisor::DEFAULT_SUPERVISOR_URL; + /// Parameters for rollup configuration #[derive(Debug, Clone, PartialEq, Eq, clap::Args)] #[command(next_help_heading = "Rollup")] @@ -37,9 +40,23 @@ pub struct RollupArgs { /// Enable transaction conditional support on sequencer #[arg(long = "rollup.enable-tx-conditional", default_value = "false")] pub enable_tx_conditional: bool, + + /// HTTP endpoint for the supervisor + #[arg( + long = "rollup.supervisor-http", + value_name = "SUPERVISOR_HTTP_URL", + default_value = DEFAULT_SUPERVISOR_URL + )] + pub supervisor_http: String, + + /// Safety level for the supervisor + #[arg( + long = "rollup.supervisor-safety-level", + default_value_t = SafetyLevel::CrossUnsafe, + )] + pub supervisor_safety_level: SafetyLevel, } -#[expect(clippy::derivable_impls)] impl Default for RollupArgs { fn default() -> Self { Self { @@ -49,6 +66,8 @@ impl Default for RollupArgs { compute_pending_block: false, discovery_v4: false, enable_tx_conditional: false, + supervisor_http: DEFAULT_SUPERVISOR_URL.to_string(), + supervisor_safety_level: SafetyLevel::CrossUnsafe, } } } diff --git a/crates/optimism/node/src/node.rs b/crates/optimism/node/src/node.rs index 4ebe6547053..b64c36d3eb9 100644 --- a/crates/optimism/node/src/node.rs +++ b/crates/optimism/node/src/node.rs @@ -6,7 +6,7 @@ use crate::{ txpool::{OpTransactionPool, OpTransactionValidator}, OpEngineApiBuilder, OpEngineTypes, }; -use op_alloy_consensus::OpPooledTransaction; +use op_alloy_consensus::{interop::SafetyLevel, OpPooledTransaction}; use reth_chainspec::{EthChainSpec, Hardforks}; use reth_evm::{execute::BasicBlockExecutorProvider, ConfigureEvm, EvmFactory, EvmFactoryFor}; use reth_network::{NetworkConfig, NetworkHandle, NetworkManager, NetworkPrimitives, PeersInfo}; @@ -41,7 +41,10 @@ use reth_optimism_rpc::{ OpEthApi, OpEthApiError, SequencerClient, }; use reth_optimism_txpool::{ - conditional::MaybeConditionalTransaction, interop::MaybeInteropTransaction, OpPooledTx, + conditional::MaybeConditionalTransaction, + interop::MaybeInteropTransaction, + supervisor::{SupervisorClient, DEFAULT_SUPERVISOR_URL}, + OpPooledTx, }; use reth_provider::{providers::ProviderFactoryBuilder, CanonStateSubscriptions, EthStorage}; use reth_rpc_api::DebugApiServer; @@ -113,7 +116,11 @@ impl OpNode { .node_types::() .pool( OpPoolBuilder::default() - .with_enable_tx_conditional(self.args.enable_tx_conditional), + .with_enable_tx_conditional(self.args.enable_tx_conditional) + .with_supervisor( + self.args.supervisor_http.clone(), + self.args.supervisor_safety_level, + ), ) .payload(BasicPayloadServiceBuilder::new( OpPayloadBuilder::new(compute_pending_block).with_da_config(self.da_config.clone()), @@ -479,6 +486,10 @@ pub struct OpPoolBuilder { pub pool_config_overrides: PoolBuilderConfigOverrides, /// Enable transaction conditionals. pub enable_tx_conditional: bool, + /// Supervisor client url + pub supervisor_http: String, + /// Supervisor safety level + pub supervisor_safety_level: SafetyLevel, /// Marker for the pooled transaction type. _pd: core::marker::PhantomData, } @@ -488,6 +499,8 @@ impl Default for OpPoolBuilder { Self { pool_config_overrides: Default::default(), enable_tx_conditional: false, + supervisor_http: DEFAULT_SUPERVISOR_URL.to_string(), + supervisor_safety_level: SafetyLevel::CrossUnsafe, _pd: Default::default(), } } @@ -508,6 +521,17 @@ impl OpPoolBuilder { self.pool_config_overrides = pool_config_overrides; self } + + /// Sets the supervisor client + pub fn with_supervisor( + mut self, + supervisor_client: String, + supervisor_safety_level: SafetyLevel, + ) -> Self { + self.supervisor_http = supervisor_client; + self.supervisor_safety_level = supervisor_safety_level; + self + } } impl PoolBuilder for OpPoolBuilder @@ -523,6 +547,17 @@ where let Self { pool_config_overrides, .. } = self; let data_dir = ctx.config().datadir(); let blob_store = DiskFileBlobStore::open(data_dir.blobstore(), Default::default())?; + // supervisor used for interop + if ctx.chain_spec().is_interop_active_at_timestamp(ctx.head().timestamp) && + self.supervisor_http == DEFAULT_SUPERVISOR_URL + { + info!(target: "reth::cli", + url=%DEFAULT_SUPERVISOR_URL, + "Default supervisor url is used, consider changing --rollup.supervisor-http." + ); + } + let supervisor_client = + SupervisorClient::new(self.supervisor_http.clone(), self.supervisor_safety_level).await; let validator = TransactionValidationTaskExecutor::eth_builder(ctx.provider().clone()) .no_eip4844() @@ -539,6 +574,7 @@ where // In --dev mode we can't require gas fees because we're unable to decode // the L1 block info .require_l1_data_gas_fee(!ctx.config().dev.dev) + .with_supervisor(supervisor_client.clone()) }); let transaction_pool = reth_transaction_pool::Pool::new( diff --git a/crates/optimism/txpool/Cargo.toml b/crates/optimism/txpool/Cargo.toml index c6ab3443b19..63f5108fe7c 100644 --- a/crates/optimism/txpool/Cargo.toml +++ b/crates/optimism/txpool/Cargo.toml @@ -17,6 +17,7 @@ alloy-consensus.workspace = true alloy-eips.workspace = true alloy-primitives.workspace = true alloy-rpc-types-eth.workspace = true +alloy-rpc-client = { workspace = true, features = ["reqwest", "default"] } # reth reth-chainspec.workspace = true @@ -44,6 +45,10 @@ c-kzg.workspace = true derive_more.workspace = true futures-util.workspace = true parking_lot.workspace = true +serde.workspace = true +tracing.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["time"] } [dev-dependencies] reth-optimism-chainspec.workspace = true diff --git a/crates/optimism/txpool/src/error.rs b/crates/optimism/txpool/src/error.rs new file mode 100644 index 00000000000..4dc51243210 --- /dev/null +++ b/crates/optimism/txpool/src/error.rs @@ -0,0 +1,47 @@ +use crate::supervisor::{InteropTxValidatorError, InvalidInboxEntry}; +use op_alloy_consensus::interop::SafetyLevel; +use reth_transaction_pool::error::PoolTransactionError; +use std::any::Any; + +/// Wrapper for [`InteropTxValidatorError`] to implement [`PoolTransactionError`] for it. +#[derive(thiserror::Error, Debug)] +pub enum InvalidCrossTx { + /// Errors produced by supervisor validation + #[error(transparent)] + ValidationError(#[from] InteropTxValidatorError), + /// Error cause by cross chain tx during not active interop hardfork + #[error("cross chain tx is invalid before interop")] + CrossChainTxPreInterop, +} + +impl PoolTransactionError for InvalidCrossTx { + fn is_bad_transaction(&self) -> bool { + match self { + Self::ValidationError(err) => { + match err { + InteropTxValidatorError::InvalidInboxEntry(err) => match err { + // This transaction could become valid after a while + InvalidInboxEntry::MinimumSafety { got, .. } => match got { + // This transaction will never become valid + SafetyLevel::Invalid => true, + // This transaction will become valid when origin chain progress + _ => false, + }, + // This tx will not become valid unless supervisor is reconfigured + InvalidInboxEntry::UnknownChain(_) => true, + }, + // Rpc error or supervisor haven't responded in time + InteropTxValidatorError::RpcClientError(_) | + InteropTxValidatorError::ValidationTimeout(_) => false, + // Transaction caused unknown (for parsing) error in supervisor + InteropTxValidatorError::SupervisorServerError(_) => true, + } + } + Self::CrossChainTxPreInterop => true, + } + } + + fn as_any(&self) -> &dyn Any { + self + } +} diff --git a/crates/optimism/txpool/src/lib.rs b/crates/optimism/txpool/src/lib.rs index cb3ea3f110b..d5de8774fd9 100644 --- a/crates/optimism/txpool/src/lib.rs +++ b/crates/optimism/txpool/src/lib.rs @@ -12,10 +12,13 @@ mod validator; pub use validator::{OpL1BlockInfo, OpTransactionValidator}; pub mod conditional; +pub mod supervisor; mod transaction; pub use transaction::{OpPooledTransaction, OpPooledTx}; +mod error; pub mod interop; pub mod maintain; +pub use error::InvalidCrossTx; use reth_transaction_pool::{CoinbaseTipOrdering, Pool, TransactionValidationTaskExecutor}; diff --git a/crates/optimism/txpool/src/supervisor/access_list.rs b/crates/optimism/txpool/src/supervisor/access_list.rs new file mode 100644 index 00000000000..9b3e4b0f2b4 --- /dev/null +++ b/crates/optimism/txpool/src/supervisor/access_list.rs @@ -0,0 +1,41 @@ +// Source: https://github.com/op-rs/kona +// Copyright © 2023 kona contributors Copyright © 2024 Optimism +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +// associated documentation files (the “Software”), to deal in the Software without restriction, +// including without limitation the rights to use, copy, modify, merge, publish, distribute, +// sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT +// NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +use crate::supervisor::CROSS_L2_INBOX_ADDRESS; +use alloy_eips::eip2930::AccessListItem; +use alloy_primitives::B256; + +/// Parses [`AccessListItem`]s to inbox entries. +/// +/// Return flattened iterator with all inbox entries. +pub fn parse_access_list_items_to_inbox_entries<'a>( + access_list_items: impl Iterator, +) -> impl Iterator { + access_list_items.filter_map(parse_access_list_item_to_inbox_entries).flatten() +} + +/// Parse [`AccessListItem`] to inbox entries, if any. +/// Max 3 inbox entries can exist per [`AccessListItem`] that points to [`CROSS_L2_INBOX_ADDRESS`]. +/// +/// Returns `Vec::new()` if [`AccessListItem`] address doesn't point to [`CROSS_L2_INBOX_ADDRESS`]. +// TODO: add url to spec once [pr](https://github.com/ethereum-optimism/specs/pull/612) is merged +fn parse_access_list_item_to_inbox_entries( + access_list_item: &AccessListItem, +) -> Option> { + (access_list_item.address == CROSS_L2_INBOX_ADDRESS) + .then(|| access_list_item.storage_keys.iter()) +} diff --git a/crates/optimism/txpool/src/supervisor/client.rs b/crates/optimism/txpool/src/supervisor/client.rs new file mode 100644 index 00000000000..f3a100ec2f2 --- /dev/null +++ b/crates/optimism/txpool/src/supervisor/client.rs @@ -0,0 +1,107 @@ +//! This is our custom implementation of validator struct + +use crate::supervisor::{ExecutingDescriptor, InteropTxValidatorError}; +use alloy_primitives::B256; +use alloy_rpc_client::ReqwestClient; +use futures_util::future::BoxFuture; +use op_alloy_consensus::interop::SafetyLevel; +use std::{borrow::Cow, future::IntoFuture, time::Duration}; + +/// Supervisor hosted by op-labs +// TODO: This should be changes to actual supervisor url +pub const DEFAULT_SUPERVISOR_URL: &str = "http://localhost:1337/"; + +/// The default request timeout to use +const DEFAULT_REQUEST_TIMOUT: Duration = Duration::from_millis(100); + +/// Implementation of the supervisor trait for the interop. +#[derive(Debug, Clone)] +pub struct SupervisorClient { + client: ReqwestClient, + /// The default + safety: SafetyLevel, + /// The default request timeout + timeout: Duration, +} + +impl SupervisorClient { + /// Creates a new supervisor validator. + pub async fn new(supervisor_endpoint: impl Into, safety: SafetyLevel) -> Self { + let client = ReqwestClient::builder() + .connect(supervisor_endpoint.into().as_str()) + .await + .expect("building supervisor client"); + Self { client, safety, timeout: DEFAULT_REQUEST_TIMOUT } + } + + /// Configures a custom timeout + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Returns safely level + pub fn safety(&self) -> SafetyLevel { + self.safety + } + + /// Executes a `supervisor_checkAccessList` with the configured safety level. + pub fn check_access_list<'a>( + &self, + inbox_entries: &'a [B256], + executing_descriptor: ExecutingDescriptor, + ) -> CheckAccessListRequest<'a> { + CheckAccessListRequest { + client: self.client.clone(), + inbox_entries: Cow::Borrowed(inbox_entries), + executing_descriptor, + timeout: self.timeout, + safety: self.safety, + } + } +} + +/// A Request future that issues a `supervisor_checkAccessList` request. +#[derive(Debug, Clone)] +pub struct CheckAccessListRequest<'a> { + client: ReqwestClient, + inbox_entries: Cow<'a, [B256]>, + executing_descriptor: ExecutingDescriptor, + timeout: Duration, + safety: SafetyLevel, +} + +impl CheckAccessListRequest<'_> { + /// Configures the timeout to use for the request if any. + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Configures the [`SafetyLevel`] for this request + pub fn with_safety(mut self, safety: SafetyLevel) -> Self { + self.safety = safety; + self + } +} + +impl<'a> IntoFuture for CheckAccessListRequest<'a> { + type Output = Result<(), InteropTxValidatorError>; + type IntoFuture = BoxFuture<'a, Self::Output>; + + fn into_future(self) -> Self::IntoFuture { + let Self { client, inbox_entries, executing_descriptor, timeout, safety } = self; + Box::pin(async move { + tokio::time::timeout( + timeout, + client.request( + "supervisor_checkAccessList", + (inbox_entries, safety, executing_descriptor), + ), + ) + .await + .map_err(|_| InteropTxValidatorError::ValidationTimeout(timeout.as_secs()))? + .map_err(InteropTxValidatorError::client) + }) + } +} diff --git a/crates/optimism/txpool/src/supervisor/errors.rs b/crates/optimism/txpool/src/supervisor/errors.rs new file mode 100644 index 00000000000..e2c492e3d96 --- /dev/null +++ b/crates/optimism/txpool/src/supervisor/errors.rs @@ -0,0 +1,173 @@ +//! Error types for the `kona-interop` crate. +// Source: https://github.com/op-rs/kona +// Copyright © 2023 kona contributors Copyright © 2024 Optimism +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +// associated documentation files (the “Software”), to deal in the Software without restriction, +// including without limitation the rights to use, copy, modify, merge, publish, distribute, +// sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT +// NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +use core::error; +use op_alloy_consensus::interop::SafetyLevel; + +/// Derived from op-supervisor +// todo: rm once resolved +const UNKNOWN_CHAIN_MSG: &str = "unknown chain: "; +/// Derived from [op-supervisor](https://github.com/ethereum-optimism/optimism/blob/4ba2eb00eafc3d7de2c8ceb6fd83913a8c0a2c0d/op-supervisor/supervisor/backend/backend.go#L479) +// todo: rm once resolved +const MINIMUM_SAFETY_MSG: &str = "does not meet the minimum safety"; + +/// Invalid inbox entry +#[derive(thiserror::Error, Debug)] +pub enum InvalidInboxEntry { + /// Message does not meet minimum safety level + #[error("message does not meet min safety level, got: {got}, expected: {expected}")] + MinimumSafety { + /// Actual level of the message + got: SafetyLevel, + /// Minimum acceptable level that was passed to supervisor + expected: SafetyLevel, + }, + /// Invalid chain + #[error("unsupported chain id: {0}")] + UnknownChain(u64), +} + +impl InvalidInboxEntry { + /// Parses error message. Returns `None`, if message is not recognized. + // todo: match on error code instead of message string once resolved + pub fn parse_err_msg(err_msg: &str) -> Option { + // Check if it's invalid message call, message example: + // `failed to check message: failed to check log: unknown chain: 14417` + if err_msg.contains(UNKNOWN_CHAIN_MSG) { + if let Ok(chain_id) = + err_msg.split(' ').next_back().expect("message contains chain id").parse::() + { + return Some(Self::UnknownChain(chain_id)) + } + // Check if it's `does not meet the minimum safety` error, message example: + // `message {0x4200000000000000000000000000000000000023 4 1 1728507701 901} + // (safety level: unsafe) does not meet the minimum safety cross-unsafe"` + } else if err_msg.contains(MINIMUM_SAFETY_MSG) { + let message_safety = if err_msg.contains("safety level: safe") { + SafetyLevel::Safe + } else if err_msg.contains("safety level: local-safe") { + SafetyLevel::LocalSafe + } else if err_msg.contains("safety level: cross-unsafe") { + SafetyLevel::CrossUnsafe + } else if err_msg.contains("safety level: unsafe") { + SafetyLevel::Unsafe + } else if err_msg.contains("safety level: invalid") { + SafetyLevel::Invalid + } else { + // Unexpected level name + return None + }; + let expected_safety = if err_msg.contains("safety finalized") { + SafetyLevel::Finalized + } else if err_msg.contains("safety safe") { + SafetyLevel::Safe + } else if err_msg.contains("safety local-safe") { + SafetyLevel::LocalSafe + } else if err_msg.contains("safety cross-unsafe") { + SafetyLevel::CrossUnsafe + } else if err_msg.contains("safety unsafe") { + SafetyLevel::Unsafe + } else { + // Unexpected level name + return None + }; + + return Some(Self::MinimumSafety { expected: expected_safety, got: message_safety }) + } + + None + } +} + +/// Failures occurring during validation of inbox entries. +#[derive(thiserror::Error, Debug)] +pub enum InteropTxValidatorError { + /// Error validating interop event. + #[error(transparent)] + InvalidInboxEntry(#[from] InvalidInboxEntry), + + /// RPC client failure. + #[error("supervisor rpc client failure: {0}")] + RpcClientError(Box), + + /// Message validation against the Supervisor took longer than allowed. + #[error("message validation timed out, timeout: {0} secs")] + ValidationTimeout(u64), + + /// Catch-all variant for other supervisor server errors. + #[error("unexpected error from supervisor: {0}")] + SupervisorServerError(Box), +} + +impl InteropTxValidatorError { + /// Returns a new instance of [`RpcClientError`](Self::RpcClientError) variant. + pub fn client(err: impl error::Error + Send + Sync + 'static) -> Self { + Self::RpcClientError(Box::new(err)) + } + + /// Returns a new instance of [`RpcClientError`](Self::RpcClientError) variant. + pub fn server_unexpected(err: impl error::Error + Send + Sync + 'static) -> Self { + Self::SupervisorServerError(Box::new(err)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const MIN_SAFETY_CROSS_UNSAFE_ERROR: &str = "message {0x4200000000000000000000000000000000000023 4 1 1728507701 901} (safety level: unsafe) does not meet the minimum safety cross-unsafe"; + const MIN_SAFETY_UNSAFE_ERROR: &str = "message {0x4200000000000000000000000000000000000023 1091637521 4369 0 901} (safety level: invalid) does not meet the minimum safety unsafe"; + const MIN_SAFETY_FINALIZED_ERROR: &str = "message {0x4200000000000000000000000000000000000023 1091600001 215 1170 901} (safety level: safe) does not meet the minimum safety finalized"; + const INVALID_CHAIN: &str = + "failed to check message: failed to check log: unknown chain: 14417"; + const RANDOM_ERROR: &str = "gibberish error"; + + #[test] + fn test_op_supervisor_error_parsing() { + assert!(matches!( + InvalidInboxEntry::parse_err_msg(MIN_SAFETY_CROSS_UNSAFE_ERROR).unwrap(), + InvalidInboxEntry::MinimumSafety { + expected: SafetyLevel::CrossUnsafe, + got: SafetyLevel::Unsafe + } + )); + + assert!(matches!( + InvalidInboxEntry::parse_err_msg(MIN_SAFETY_UNSAFE_ERROR).unwrap(), + InvalidInboxEntry::MinimumSafety { + expected: SafetyLevel::Unsafe, + got: SafetyLevel::Invalid + } + )); + + assert!(matches!( + InvalidInboxEntry::parse_err_msg(MIN_SAFETY_FINALIZED_ERROR).unwrap(), + InvalidInboxEntry::MinimumSafety { + expected: SafetyLevel::Finalized, + got: SafetyLevel::Safe, + } + )); + + assert!(matches!( + InvalidInboxEntry::parse_err_msg(INVALID_CHAIN).unwrap(), + InvalidInboxEntry::UnknownChain(14417) + )); + + assert!(InvalidInboxEntry::parse_err_msg(RANDOM_ERROR).is_none()); + } +} diff --git a/crates/optimism/txpool/src/supervisor/message.rs b/crates/optimism/txpool/src/supervisor/message.rs new file mode 100644 index 00000000000..84e86c39f63 --- /dev/null +++ b/crates/optimism/txpool/src/supervisor/message.rs @@ -0,0 +1,36 @@ +//! Interop message primitives. +// Source: https://github.com/op-rs/kona +// Copyright © 2023 kona contributors Copyright © 2024 Optimism +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +// associated documentation files (the “Software”), to deal in the Software without restriction, +// including without limitation the rights to use, copy, modify, merge, publish, distribute, +// sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT +// NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +/// An [`ExecutingDescriptor`] is a part of the payload to `supervisor_checkAccessList` +/// Spec: +#[derive(Default, Debug, PartialEq, Eq, Clone, serde::Serialize, serde::Deserialize)] +pub struct ExecutingDescriptor { + /// The timestamp used to enforce timestamp [invariant](https://github.com/ethereum-optimism/specs/blob/main/specs/interop/derivation.md#invariants) + timestamp: u64, + /// The timeout that requests verification to still hold at `timestamp+timeout` + /// (message expiry may drop previously valid messages). + #[serde(skip_serializing_if = "Option::is_none")] + timeout: Option, +} + +impl ExecutingDescriptor { + /// Create a new [`ExecutingDescriptor`] from the timestamp and timeout + pub const fn new(timestamp: u64, timeout: Option) -> Self { + Self { timestamp, timeout } + } +} diff --git a/crates/optimism/txpool/src/supervisor/mod.rs b/crates/optimism/txpool/src/supervisor/mod.rs new file mode 100644 index 00000000000..28e9f83749c --- /dev/null +++ b/crates/optimism/txpool/src/supervisor/mod.rs @@ -0,0 +1,11 @@ +//! Supervisor support for interop +mod access_list; +pub use access_list::parse_access_list_items_to_inbox_entries; +pub use op_alloy_consensus::interop::*; + +mod client; +pub use client::{SupervisorClient, DEFAULT_SUPERVISOR_URL}; +mod errors; +pub use errors::{InteropTxValidatorError, InvalidInboxEntry}; +mod message; +pub use message::ExecutingDescriptor; diff --git a/crates/optimism/txpool/src/transaction.rs b/crates/optimism/txpool/src/transaction.rs index 5fe0c71f2f1..51334ae135c 100644 --- a/crates/optimism/txpool/src/transaction.rs +++ b/crates/optimism/txpool/src/transaction.rs @@ -287,8 +287,8 @@ mod tests { blobstore::InMemoryBlobStore, validate::EthTransactionValidatorBuilder, TransactionOrigin, TransactionValidationOutcome, }; - #[test] - fn validate_optimism_transaction() { + #[tokio::test] + async fn validate_optimism_transaction() { let client = MockEthProvider::default().with_chain_spec(OP_MAINNET.clone()); let validator = EthTransactionValidatorBuilder::new(client) .no_shanghai() @@ -313,7 +313,7 @@ mod tests { let signed_recovered = Recovered::new_unchecked(signed_tx, signer); let len = signed_recovered.encode_2718_len(); let pooled_tx: OpPooledTransaction = OpPooledTransaction::new(signed_recovered, len); - let outcome = validator.validate_one(origin, pooled_tx); + let outcome = validator.validate_one(origin, pooled_tx).await; let err = match outcome { TransactionValidationOutcome::Invalid(_, err) => err, diff --git a/crates/optimism/txpool/src/validator.rs b/crates/optimism/txpool/src/validator.rs index d97b529cacd..031f8f5d1ee 100644 --- a/crates/optimism/txpool/src/validator.rs +++ b/crates/optimism/txpool/src/validator.rs @@ -1,3 +1,7 @@ +use crate::{ + supervisor::{parse_access_list_items_to_inbox_entries, ExecutingDescriptor, SupervisorClient}, + InvalidCrossTx, +}; use alloy_consensus::{BlockHeader, Transaction}; use alloy_eips::Encodable2718; use op_revm::L1BlockInfo; @@ -10,13 +14,17 @@ use reth_primitives_traits::{ }; use reth_storage_api::{BlockReaderIdExt, StateProviderFactory}; use reth_transaction_pool::{ - EthPoolTransaction, EthTransactionValidator, TransactionOrigin, TransactionValidationOutcome, - TransactionValidator, + error::InvalidPoolTransactionError, EthPoolTransaction, EthTransactionValidator, + TransactionOrigin, TransactionValidationOutcome, TransactionValidator, }; use std::sync::{ - atomic::{AtomicU64, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, Arc, }; +use tracing::trace; + +/// The interval for which we check transaction against supervisor, 1 day. +const TRANSACTION_VALIDITY_WINDOW_SECS: u64 = 86400; /// Tracks additional infos for the current block. #[derive(Debug, Default)] @@ -29,6 +37,13 @@ pub struct OpL1BlockInfo { number: AtomicU64, } +impl OpL1BlockInfo { + /// Returns the most recent timestamp + pub fn timestamp(&self) -> u64 { + self.timestamp.load(Ordering::Relaxed) + } +} + /// Validator for Optimism transactions. #[derive(Debug, Clone)] pub struct OpTransactionValidator { @@ -40,6 +55,10 @@ pub struct OpTransactionValidator { /// derived from the tracked L1 block info that is extracted from the first transaction in the /// L2 block. require_l1_data_gas_fee: bool, + /// Client used to check transaction validity with op-supervisor + supervisor_client: Option, + /// tracks activated forks relevant for transaction validation + fork_tracker: Arc, } impl OpTransactionValidator { @@ -108,7 +127,19 @@ where inner: EthTransactionValidator, block_info: OpL1BlockInfo, ) -> Self { - Self { inner, block_info: Arc::new(block_info), require_l1_data_gas_fee: true } + Self { + inner, + block_info: Arc::new(block_info), + require_l1_data_gas_fee: true, + supervisor_client: None, + fork_tracker: Arc::new(OpForkTracker { interop: AtomicBool::from(false) }), + } + } + + /// Set the supervisor client and safety level + pub fn with_supervisor(mut self, supervisor_client: SupervisorClient) -> Self { + self.supervisor_client = Some(supervisor_client); + self } /// Update the L1 block info for the given header and system transaction, if any. @@ -125,6 +156,10 @@ where if let Some(Ok(cost_addition)) = tx.map(reth_optimism_evm::extract_l1_info_from_tx) { *self.block_info.l1_block_info.write() = cost_addition; } + + if self.chain_spec().is_interop_active_at_timestamp(header.timestamp()) { + self.fork_tracker.interop.store(true, Ordering::Relaxed); + } } /// Validates a single transaction. @@ -133,7 +168,7 @@ where /// /// This behaves the same as [`EthTransactionValidator::validate_one`], but in addition, ensures /// that the account has enough balance to cover the L1 gas cost. - pub fn validate_one( + pub async fn validate_one( &self, origin: TransactionOrigin, transaction: Tx, @@ -145,6 +180,17 @@ where ) } + // Interop cross tx validation + if let Some(Err(err)) = self.is_valid_cross_tx(&transaction).await { + let err = match err { + InvalidCrossTx::CrossChainTxPreInterop => { + InvalidTransactionError::TxTypeNotSupported.into() + } + err => InvalidPoolTransactionError::Other(Box::new(err)), + }; + return TransactionValidationOutcome::Invalid(transaction, err) + } + let outcome = self.inner.validate_one(origin, transaction); self.apply_op_checks(outcome) @@ -155,11 +201,14 @@ where /// Returns all outcomes for the given transactions in the same order. /// /// See also [`Self::validate_one`] - pub fn validate_all( + pub async fn validate_all( &self, transactions: Vec<(TransactionOrigin, Tx)>, ) -> Vec> { - transactions.into_iter().map(|(origin, tx)| self.validate_one(origin, tx)).collect() + futures_util::future::join_all( + transactions.into_iter().map(|(origin, tx)| self.validate_one(origin, tx)), + ) + .await } /// Performs the necessary opstack specific checks based on top of the regular eth outcome. @@ -219,6 +268,53 @@ where } outcome } + + /// Extracts commitment from access list entries, pointing to 0x420..022 and validates them + /// against supervisor. + /// + /// If commitment present pre-interop tx rejected. + /// + /// Returns: + /// None - if tx is not cross chain, + /// Some(Ok(()) - if tx is valid cross chain, + /// Some(Err(e)) - if tx is not valid or interop is not active + pub async fn is_valid_cross_tx(&self, tx: &Tx) -> Option> { + // We don't need to check for deposit transaction in here, because they won't come from + // txpool + let access_list = tx.access_list()?; + let inbox_entries = parse_access_list_items_to_inbox_entries(access_list.iter()) + .copied() + .collect::>(); + if inbox_entries.is_empty() { + return None; + } + + // Ensure interop is activated + if !self.fork_tracker.is_interop_activated() { + // No cross chain tx allowed before interop + return Some(Err(InvalidCrossTx::CrossChainTxPreInterop)) + } + + let client = self + .supervisor_client + .as_ref() + .expect("supervisor client should be always set after interop is active"); + + if let Err(err) = client + .check_access_list( + inbox_entries.as_slice(), + ExecutingDescriptor::new( + self.block_info.timestamp(), + Some(TRANSACTION_VALIDITY_WINDOW_SECS), + ), + ) + .await + { + trace!(target: "txpool", hash=%tx.hash(), err=%err, "Cross chain transaction invalid"); + return Some(Err(InvalidCrossTx::ValidationError(err))); + } + Some(Ok(())) + } } impl TransactionValidator for OpTransactionValidator @@ -233,14 +329,14 @@ where origin: TransactionOrigin, transaction: Self::Transaction, ) -> TransactionValidationOutcome { - self.validate_one(origin, transaction) + self.validate_one(origin, transaction).await } async fn validate_transactions( &self, transactions: Vec<(TransactionOrigin, Self::Transaction)>, ) -> Vec> { - self.validate_all(transactions) + self.validate_all(transactions).await } fn on_new_head_block(&self, new_tip_block: &SealedBlock) @@ -254,3 +350,17 @@ where ); } } + +/// Keeps track of whether certain forks are activated +#[derive(Debug)] +pub(crate) struct OpForkTracker { + /// Tracks if interop is activated at the block's timestamp. + interop: AtomicBool, +} + +impl OpForkTracker { + /// Returns `true` if Interop fork is activated. + pub(crate) fn is_interop_activated(&self) -> bool { + self.interop.load(Ordering::Relaxed) + } +}