From edaec52b7e329c277f713c86feccf7693ff95b3f Mon Sep 17 00:00:00 2001 From: wwared <541936+wwared@users.noreply.github.com> Date: Thu, 9 Apr 2026 03:32:27 +0000 Subject: [PATCH 01/14] feat(op-reth): align interop tx validity window to 86400s Change TRANSACTION_VALIDITY_WINDOW_SECS from 3600 (1 hour) to 86400 (24 hours) to match op-geth's ingressFilterTxValidityWindow spec. This constant is used for both the interop deadline set on validated transactions and the timeout passed to supervisor_checkAccessList. Co-Authored-By: Claude Opus 4.6 --- rust/op-reth/crates/txpool/src/validator.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/rust/op-reth/crates/txpool/src/validator.rs b/rust/op-reth/crates/txpool/src/validator.rs index c972cd95d6f..3998de62c01 100644 --- a/rust/op-reth/crates/txpool/src/validator.rs +++ b/rust/op-reth/crates/txpool/src/validator.rs @@ -20,8 +20,9 @@ use std::sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, }; -/// The interval for which we check transaction against supervisor, 1 hour. -const TRANSACTION_VALIDITY_WINDOW_SECS: u64 = 3600; +/// The timeout for cross-chain transaction validation against the supervisor/interop-filter. +/// Matches op-geth's `ingressFilterTxValidityWindow` (86400s = 24 hours). +const TRANSACTION_VALIDITY_WINDOW_SECS: u64 = 86400; /// Tracks additional infos for the current block. #[derive(Debug, Default)] From 52e280947044920634ead9af9127933e46b4e0af Mon Sep 17 00:00:00 2001 From: wwared <541936+wwared@users.noreply.github.com> Date: Thu, 9 Apr 2026 03:34:23 +0000 Subject: [PATCH 02/14] docs(op-reth): clarify revalidation window vs ingress timeout The revalidation window (600s) is intentionally shorter than the ingress timeout (86400s). Phase 3 evaluation confirmed the block-event-driven revalidation loop meets all 6 evaluation criteria: - Fires on every canonical block commit (~2s on OP chains) - Covers all pooled interop txs with deadlines - Evicts expired txs and revalidates stale ones - Failsafe polling (Phase 4) covers the block-gap edge case Co-Authored-By: Claude Opus 4.6 --- rust/op-reth/crates/txpool/src/maintain.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/rust/op-reth/crates/txpool/src/maintain.rs b/rust/op-reth/crates/txpool/src/maintain.rs index 8ac5bf4842d..832e1e54b0f 100644 --- a/rust/op-reth/crates/txpool/src/maintain.rs +++ b/rust/op-reth/crates/txpool/src/maintain.rs @@ -1,8 +1,11 @@ //! Support for maintaining the state of the transaction pool -/// The interval for which we check transaction against supervisor, 10 min. +/// Revalidation window: how long a successfully revalidated tx remains valid. +/// Intentionally shorter than the ingress TRANSACTION_VALIDITY_WINDOW_SECS (86400s) +/// because revalidation should be stricter — a tx valid now gets a 10-minute lease, +/// forcing periodic re-checks against the supervisor rather than a single 24-hour window. const TRANSACTION_VALIDITY_WINDOW: u64 = 600; -/// Interval in seconds at which the transaction should be revalidated. +/// Offset before deadline expiry at which a tx becomes "stale" and triggers revalidation. const OFFSET_TIME: u64 = 60; /// Maximum number of supervisor requests at the same time const MAX_SUPERVISOR_QUERIES: usize = 10; From 819f9abf86ffb83ecb07f16eea760c493827ee5b Mon Sep 17 00:00:00 2001 From: wwared <541936+wwared@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:06:49 +0000 Subject: [PATCH 03/14] feat(op-reth): implement block builder failsafe polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add background failsafe detection to the interop tx pool: - Add AtomicBool failsafe_enabled to SupervisorClientInner, shared via Arc across all clones - Add query_failsafe() method that calls admin_getFailsafeEnabled RPC and caches the result - Add is_failsafe_enabled() accessor for the cached state - Add poll_failsafe() background task that polls every 1s and evicts all interop txs from the pool on failsafe transition - Wire failsafe polling task in node.rs alongside the existing maintenance task, using ref+clone pattern to share the supervisor client - Narrow SupervisorClientInner visibility to pub(crate) — no external references exist - Remove Clone derive from SupervisorClientInner (AtomicBool is !Clone, inner is always behind Arc) The ingress filter already rejects new interop txs during failsafe via CheckAccessList at the source of truth. The polling task provides the eviction mechanism for already-pooled txs. No changes to the maintenance loop or payload builder are needed. Co-Authored-By: Claude Opus 4.6 --- rust/op-reth/crates/node/src/node.rs | 16 ++++- rust/op-reth/crates/txpool/src/maintain.rs | 65 ++++++++++++++++++- .../crates/txpool/src/supervisor/client.rs | 34 ++++++++-- 3 files changed, 105 insertions(+), 10 deletions(-) diff --git a/rust/op-reth/crates/node/src/node.rs b/rust/op-reth/crates/node/src/node.rs index 94d23f80f17..669b54422b7 100644 --- a/rust/op-reth/crates/node/src/node.rs +++ b/rust/op-reth/crates/node/src/node.rs @@ -1125,16 +1125,26 @@ where // The Op txpool maintenance task is only spawned when interop is scheduled/active and a // supervisor is configured if ctx.chain_spec().op_fork_activation(OpHardfork::Interop) != ForkCondition::Never && - let Some(supervisor) = supervisor_client + let Some(ref supervisor) = supervisor_client { - // spawn the Op txpool maintenance task + // Spawn failsafe polling task (shares supervisor client via clone) + ctx.task_executor().spawn_critical_task( + "Op txpool failsafe polling task", + reth_optimism_txpool::maintain::poll_failsafe_future( + supervisor.clone(), + transaction_pool.clone(), + ), + ); + debug!(target: "reth::cli", "Spawned failsafe polling task"); + + // Spawn the Op txpool maintenance task let chain_events = ctx.provider().canonical_state_stream(); ctx.task_executor().spawn_critical_task( "Op txpool interop maintenance task", reth_optimism_txpool::maintain::maintain_transaction_pool_interop_future( transaction_pool.clone(), chain_events, - supervisor, + supervisor.clone(), ), ); debug!(target: "reth::cli", "Spawned Op interop txpool maintenance task"); diff --git a/rust/op-reth/crates/txpool/src/maintain.rs b/rust/op-reth/crates/txpool/src/maintain.rs index 832e1e54b0f..df39336acb6 100644 --- a/rust/op-reth/crates/txpool/src/maintain.rs +++ b/rust/op-reth/crates/txpool/src/maintain.rs @@ -1,7 +1,7 @@ //! Support for maintaining the state of the transaction pool /// Revalidation window: how long a successfully revalidated tx remains valid. -/// Intentionally shorter than the ingress TRANSACTION_VALIDITY_WINDOW_SECS (86400s) +/// Intentionally shorter than the ingress `TRANSACTION_VALIDITY_WINDOW_SECS` (86400s) /// because revalidation should be stricter — a tx valid now gets a 10-minute lease, /// forcing periodic re-checks against the supervisor rather than a single 24-hour window. const TRANSACTION_VALIDITY_WINDOW: u64 = 600; @@ -22,8 +22,8 @@ use reth_chain_state::CanonStateNotification; use reth_metrics::{Metrics, metrics::Counter}; use reth_primitives_traits::NodePrimitives; use reth_transaction_pool::{PoolTransaction, TransactionPool, error::PoolTransactionError}; -use std::time::Instant; -use tracing::warn; +use std::time::{Duration, Instant}; +use tracing::{info, warn}; /// Transaction pool maintenance metrics #[derive(Metrics)] @@ -234,3 +234,62 @@ pub async fn maintain_transaction_pool_interop( } } } + +/// Background task that polls the supervisor for failsafe state every second. +/// When failsafe transitions from disabled to enabled, evicts all interop txs +/// from the pool immediately (does not wait for the next block event). +/// Matches op-geth's `startBackgroundInteropFailsafeDetection` (miner/miner.go:140-165). +pub async fn poll_failsafe(supervisor_client: SupervisorClient, pool: Pool) +where + Pool: TransactionPool, + Pool::Transaction: MaybeInteropTransaction, +{ + let metrics = MaintainPoolInteropMetrics::default(); + let mut interval = tokio::time::interval(Duration::from_secs(1)); + let mut was_enabled = false; + loop { + interval.tick().await; + match supervisor_client.query_failsafe().await { + Ok(enabled) => { + // On transition to enabled: evict all interop txs immediately + if enabled && !was_enabled { + let interop_hashes: Vec<_> = pool + .pooled_transactions() + .iter() + .filter(|tx| tx.transaction.interop_deadline().is_some()) + .map(|tx| *tx.hash()) + .collect(); + if !interop_hashes.is_empty() { + info!( + target: "txpool::interop", + count = interop_hashes.len(), + "failsafe enabled: evicting all interop transactions" + ); + let removed = pool.remove_transactions(interop_hashes); + metrics.inc_removed_tx_interop(removed.len()); + } + } + was_enabled = enabled; + } + Err(err) => { + warn!( + target: "txpool::interop", + %err, + "failed to query failsafe state" + ); + } + } + } +} + +/// Creates a boxed future for the failsafe polling task. +pub fn poll_failsafe_future( + supervisor_client: SupervisorClient, + pool: Pool, +) -> BoxFuture<'static, ()> +where + Pool: TransactionPool + 'static, + Pool::Transaction: MaybeInteropTransaction, +{ + Box::pin(poll_failsafe(supervisor_client, pool)) +} diff --git a/rust/op-reth/crates/txpool/src/supervisor/client.rs b/rust/op-reth/crates/txpool/src/supervisor/client.rs index 3d860dcdbfc..cd6f00202ed 100644 --- a/rust/op-reth/crates/txpool/src/supervisor/client.rs +++ b/rust/op-reth/crates/txpool/src/supervisor/client.rs @@ -21,7 +21,10 @@ use reth_transaction_pool::PoolTransaction; use std::{ borrow::Cow, future::IntoFuture, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, time::{Duration, Instant}, }; use tracing::trace; @@ -49,7 +52,7 @@ impl SupervisorClient { SupervisorClientBuilder::new(supervisor_endpoint, chain_id) } - /// Returns configured timeout. See [`SupervisorClientInner`]. + /// Returns the configured request timeout. pub fn timeout(&self) -> Duration { self.inner.timeout } @@ -122,6 +125,26 @@ impl SupervisorClient { Some(Ok(())) } + /// Returns the cached failsafe state. + pub fn is_failsafe_enabled(&self) -> bool { + self.inner.failsafe_enabled.load(Ordering::Acquire) + } + + /// Queries the interop filter for failsafe state and caches the result. + /// Calls `admin_getFailsafeEnabled` RPC. + pub async fn query_failsafe(&self) -> Result { + let result = tokio::time::timeout( + self.inner.timeout, + self.inner.client.request::<_, bool>("admin_getFailsafeEnabled", ()), + ) + .await + .map_err(|_| InteropTxValidatorError::Timeout(self.inner.timeout.as_secs()))? + .map_err(InteropTxValidatorError::from_json_rpc)?; + + self.inner.failsafe_enabled.store(result, Ordering::Release); + Ok(result) + } + /// Creates a stream that revalidates interop transactions against the supervisor. /// Returns /// An implementation of `Stream` that is `Send`-able and tied to the lifetime `'a` of `self`. @@ -166,8 +189,8 @@ impl SupervisorClient { } /// Holds supervisor data. Inner type of [`SupervisorClient`]. -#[derive(Debug, Clone)] -pub struct SupervisorClientInner { +#[derive(Debug)] +pub(crate) struct SupervisorClientInner { client: ReqwestClient, /// The chain ID of the executing chain chain_id: u64, @@ -177,6 +200,8 @@ pub struct SupervisorClientInner { timeout: Duration, /// Metrics for tracking supervisor operations metrics: SupervisorMetrics, + /// Cached failsafe state, polled by the background failsafe task. + failsafe_enabled: AtomicBool, } /// Builds [`SupervisorClient`]. @@ -234,6 +259,7 @@ impl SupervisorClientBuilder { safety, timeout, metrics: SupervisorMetrics::default(), + failsafe_enabled: AtomicBool::new(false), }), } } From c8a94d3b4f268b1c3231a0f56e993f5a7f11cf39 Mon Sep 17 00:00:00 2001 From: wwared <541936+wwared@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:20:15 +0000 Subject: [PATCH 04/14] feat(op-devstack): integrate op-interop-filter for op-reth tx validation Add in-process op-interop-filter service to devstack for supernode interop presets: - Refactor startMixedOpRethNode into build+start pattern to allow injecting --rollup.supervisor-http before starting - Add startSupernodeELWithSupervisorURL for both op-geth and op-reth - Create InteropFilter wrapper (sysgo/interop_filter.go) running the filter service in-process with direct Go struct access - Add proxy pattern in supernode runtime to break circular dependency between EL nodes and filter service - Add WithInteropFilter() preset option (supernode presets only) - Add UseInteropFilter to PresetConfig - Expose InteropFilter on MultiChainRuntime and TwoL2SupernodeInterop for direct test access (SetFailsafeEnabled, Ready, FailsafeEnabled) - Add Ready(), SetFailsafeEnabled(), FailsafeEnabled() pass-through methods to filter.Service No changes to supervisor-based presets or multichain_supervisor_runtime. Co-Authored-By: Claude Opus 4.6 --- op-devstack/presets/option_validation.go | 8 +- op-devstack/presets/options.go | 11 ++ op-devstack/presets/twol2.go | 4 + op-devstack/presets/twol2_from_runtime.go | 1 + op-devstack/sysgo/interop_filter.go | 129 ++++++++++++++++++ op-devstack/sysgo/mixed_runtime.go | 47 ++++++- .../sysgo/multichain_supernode_runtime.go | 76 ++++++++++- op-devstack/sysgo/preset_config.go | 1 + op-devstack/sysgo/runtime_state.go | 1 + op-interop-filter/filter/service.go | 16 +++ 10 files changed, 281 insertions(+), 13 deletions(-) create mode 100644 op-devstack/sysgo/interop_filter.go diff --git a/op-devstack/presets/option_validation.go b/op-devstack/presets/option_validation.go index 2c62760eb48..482115c27f7 100644 --- a/op-devstack/presets/option_validation.go +++ b/op-devstack/presets/option_validation.go @@ -26,6 +26,7 @@ const ( optionKindRequireInteropNotAtGen optionKindAfterBuild optionKindProofValidation + optionKindInteropFilter ) const allOptionKinds = optionKindDeployer | @@ -42,7 +43,8 @@ const allOptionKinds = optionKindDeployer | optionKindMaxSequencingWindow | optionKindRequireInteropNotAtGen | optionKindAfterBuild | - optionKindProofValidation + optionKindProofValidation | + optionKindInteropFilter var optionKindLabels = []struct { kind optionKinds @@ -63,6 +65,7 @@ var optionKindLabels = []struct { {kind: optionKindRequireInteropNotAtGen, label: "interop-not-at-genesis"}, {kind: optionKindAfterBuild, label: "after-build hooks"}, {kind: optionKindProofValidation, label: "proof-validation hooks"}, + {kind: optionKindInteropFilter, label: "interop filter"}, } func (k optionKinds) String() string { @@ -157,7 +160,8 @@ const twoL2SupernodePresetSupportedOptionKinds = optionKindDeployer | const twoL2SupernodeInteropPresetSupportedOptionKinds = optionKindDeployer | optionKindTimeTravel | - optionKindL1EL + optionKindL1EL | + optionKindInteropFilter const singleChainWithFlashblocksPresetSupportedOptionKinds = optionKindDeployer | optionKindOPRBuilder diff --git a/op-devstack/presets/options.go b/op-devstack/presets/options.go index 37dceaaacac..df5b07cf7ef 100644 --- a/op-devstack/presets/options.go +++ b/op-devstack/presets/options.go @@ -266,6 +266,17 @@ func WithMaxSequencingWindow(max uint64) Option { } } +// WithInteropFilter enables the in-process op-interop-filter for EL transaction +// validation. Only supported on supernode interop presets. +func WithInteropFilter() Option { + return option{ + kinds: optionKindInteropFilter, + applyFn: func(cfg *sysgo.PresetConfig) { + cfg.UseInteropFilter = true + }, + } +} + func WithRequireInteropNotAtGenesis() Option { return option{ kinds: optionKindRequireInteropNotAtGen, diff --git a/op-devstack/presets/twol2.go b/op-devstack/presets/twol2.go index f383289fa14..a3ed94366f2 100644 --- a/op-devstack/presets/twol2.go +++ b/op-devstack/presets/twol2.go @@ -82,6 +82,10 @@ type TwoL2SupernodeInterop struct { // DelaySeconds is the delay from genesis to interop activation DelaySeconds uint64 + // InteropFilter provides direct access to the in-process interop filter. + // nil if not using interop filter (WithInteropFilter() not set). + InteropFilter *sysgo.InteropFilter + timeTravel *clock.AdvancingClock } diff --git a/op-devstack/presets/twol2_from_runtime.go b/op-devstack/presets/twol2_from_runtime.go index 9770b3b2f05..68865b2ee3d 100644 --- a/op-devstack/presets/twol2_from_runtime.go +++ b/op-devstack/presets/twol2_from_runtime.go @@ -143,6 +143,7 @@ func twoL2SupernodeInteropFromRuntime(t devtest.T, runtime *sysgo.MultiChainRunt GenesisTime: genesisTime, InteropActivationTime: genesisTime + runtime.DelaySeconds, DelaySeconds: runtime.DelaySeconds, + InteropFilter: runtime.InteropFilter, timeTravel: runtime.TimeTravel, } preset.FunderA = dsl.NewFunder(preset.Wallet, preset.FaucetA, preset.L2ELA) diff --git a/op-devstack/sysgo/interop_filter.go b/op-devstack/sysgo/interop_filter.go new file mode 100644 index 00000000000..c64e8d45b80 --- /dev/null +++ b/op-devstack/sysgo/interop_filter.go @@ -0,0 +1,129 @@ +package sysgo + +import ( + "context" + "sync" + "time" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-interop-filter/filter" + "github.com/ethereum-optimism/optimism/op-node/rollup" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum/go-ethereum/log" +) + +// InteropFilter wraps an in-process op-interop-filter service for devstack. +// Follows the same pattern as OpSupervisor (supervisor_op.go). +type InteropFilter struct { + mu sync.Mutex + name string + logger log.Logger + service *filter.Service +} + +// HTTPEndpoint returns the service's actual RPC endpoint (e.g. "http://127.0.0.1:12345"). +// The caller is responsible for proxying if needed. +func (f *InteropFilter) HTTPEndpoint() string { + f.mu.Lock() + defer f.mu.Unlock() + return f.service.HTTPEndpoint() +} + +// Ready returns true once all chain ingesters have backfilled. +func (f *InteropFilter) Ready() bool { + f.mu.Lock() + defer f.mu.Unlock() + if f.service == nil { + return false + } + return f.service.Ready() +} + +// SetFailsafeEnabled toggles failsafe mode directly on the backend. +// No admin RPC or JWT needed — this is the in-process advantage. +func (f *InteropFilter) SetFailsafeEnabled(enabled bool) { + f.mu.Lock() + defer f.mu.Unlock() + f.service.SetFailsafeEnabled(enabled) +} + +// FailsafeEnabled returns the current failsafe state. +func (f *InteropFilter) FailsafeEnabled() bool { + f.mu.Lock() + defer f.mu.Unlock() + return f.service.FailsafeEnabled() +} + +// Stop gracefully shuts down the interop filter service. +func (f *InteropFilter) Stop() { + f.mu.Lock() + defer f.mu.Unlock() + if f.service == nil { + f.logger.Warn("InteropFilter already stopped") + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + f.logger.Info("Closing interop filter") + closeErr := f.service.Stop(ctx) + f.logger.Info("Closed interop filter", "err", closeErr) + f.service = nil +} + +// startInteropFilter creates and starts an in-process interop filter. +// Rollup configs are passed as Go structs — no file serialization needed. +// The filter connects to EL nodes via the provided RPC URLs. +func startInteropFilter( + t devtest.T, + name string, + l2RPCs []string, + rollupConfigs map[eth.ChainID]*rollup.Config, +) *InteropFilter { + logger := t.Logger().New("component", name) + + cfg := &filter.Config{ + L2RPCs: l2RPCs, + RollupConfigs: rollupConfigs, + DataDir: t.TempDir(), + BackfillDuration: 30 * time.Second, + MessageExpiryWindow: 7 * 24 * 3600, // 7 days in seconds + PollInterval: 500 * time.Millisecond, + ValidationInterval: 200 * time.Millisecond, + RPCAddr: "127.0.0.1", + RPCPort: 0, // Auto-assign + Version: "devstack", + } + + t.Require().NoError(cfg.Check(), "invalid interop filter config") + + service, err := filter.NewService(context.Background(), cfg, logger) + t.Require().NoError(err, "failed to create interop filter service") + + f := &InteropFilter{ + name: name, + logger: logger, + service: service, + } + + logger.Info("Starting interop filter") + err = service.Start(context.Background()) + t.Require().NoError(err, "failed to start interop filter") + t.Cleanup(func() { f.Stop() }) + logger.Info("Started interop filter", "endpoint", service.HTTPEndpoint()) + + // Wait for readiness (all chain ingesters backfilled) + waitCtx, waitCancel := context.WithTimeout(context.Background(), 60*time.Second) + defer waitCancel() + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for !f.service.Ready() { + select { + case <-waitCtx.Done(): + t.Require().Fail("interop filter did not become ready within 60s") + case <-ticker.C: + } + } + logger.Info("Interop filter ready") + + return f +} diff --git a/op-devstack/sysgo/mixed_runtime.go b/op-devstack/sysgo/mixed_runtime.go index 614cbdb0fd0..4b8e8706fa6 100644 --- a/op-devstack/sysgo/mixed_runtime.go +++ b/op-devstack/sysgo/mixed_runtime.go @@ -264,7 +264,9 @@ func mixedNodeRefs(nodes []mixedSingleChainNode) []MixedSingleChainNodeRefs { return out } -func startMixedOpRethNode( +// buildMixedOpRethNode constructs an OpReth node without starting it. +// Use this when you need to customize args (e.g. --rollup.supervisor-http) before starting. +func buildMixedOpRethNode( t devtest.T, l2Net *L2Network, key string, @@ -358,7 +360,7 @@ func startMixedOpRethNode( "--proofs-history.storage-path="+proofHistoryDir, ) - l2EL := &OpReth{ + return &OpReth{ name: key, chainID: l2Net.ChainID(), jwtPath: jwtPath, @@ -371,12 +373,45 @@ func startMixedOpRethNode( p: t, l2MetricsRegistrar: metricsRegistrar, } +} +func startMixedOpRethNode( + t devtest.T, + l2Net *L2Network, + key string, + jwtPath string, + jwtSecret [32]byte, + metricsRegistrar L2MetricsRegistrar, +) *OpReth { + node := buildMixedOpRethNode(t, l2Net, key, jwtPath, jwtSecret, metricsRegistrar) t.Logger().Info("Starting op-reth", "name", key, "chain", l2Net.ChainID()) - l2EL.Start() - t.Cleanup(l2EL.Stop) - t.Logger().Info("op-reth is ready", "name", key, "chain", l2Net.ChainID(), "userRPC", l2EL.userRPC, "authRPC", l2EL.authRPC) - return l2EL + node.Start() + t.Cleanup(node.Stop) + t.Logger().Info("op-reth is ready", "name", key, "chain", l2Net.ChainID(), "userRPC", node.userRPC, "authRPC", node.authRPC) + return node +} + +// startMixedOpRethNodeWithSupervisorURL builds and starts an OpReth node +// with --rollup.supervisor-http pointing at the given URL. +func startMixedOpRethNodeWithSupervisorURL( + t devtest.T, + l2Net *L2Network, + key string, + jwtPath string, + jwtSecret [32]byte, + metricsRegistrar L2MetricsRegistrar, + supervisorURL string, +) *OpReth { + node := buildMixedOpRethNode(t, l2Net, key, jwtPath, jwtSecret, metricsRegistrar) + if supervisorURL != "" { + node.args = append(node.args, "--rollup.supervisor-http="+supervisorURL) + } + t.Logger().Info("Starting op-reth with supervisor URL", + "name", key, "chain", l2Net.ChainID(), "supervisorURL", supervisorURL) + node.Start() + t.Cleanup(node.Stop) + t.Logger().Info("op-reth is ready", "name", key, "chain", l2Net.ChainID(), "userRPC", node.userRPC, "authRPC", node.authRPC) + return node } func startMixedKonaNode( diff --git a/op-devstack/sysgo/multichain_supernode_runtime.go b/op-devstack/sysgo/multichain_supernode_runtime.go index 012cb4ff827..94986d1345b 100644 --- a/op-devstack/sysgo/multichain_supernode_runtime.go +++ b/op-devstack/sysgo/multichain_supernode_runtime.go @@ -27,9 +27,11 @@ import ( "github.com/ethereum-optimism/optimism/op-node/rollup/interop" nodeSync "github.com/ethereum-optimism/optimism/op-node/rollup/sync" "github.com/ethereum-optimism/optimism/op-service/client" + "github.com/ethereum-optimism/optimism/op-node/rollup" "github.com/ethereum-optimism/optimism/op-service/clock" "github.com/ethereum-optimism/optimism/op-service/endpoint" "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/testutils/tcpproxy" oplog "github.com/ethereum-optimism/optimism/op-service/log" opmetrics "github.com/ethereum-optimism/optimism/op-service/metrics" "github.com/ethereum-optimism/optimism/op-service/oppprof" @@ -103,6 +105,39 @@ func startSupernodeEL(t devtest.T, l2Net *L2Network, jwtPath string, jwtSecret [ return startL2ELForKey(t, l2Net, jwtPath, jwtSecret, "sequencer", NewELNodeIdentity(0)) } +// startSupernodeELWithSupervisorURL starts an L2 EL node with --rollup.supervisor-http +// pointing at the given URL. Used by supernode interop presets to connect ELs +// to the interop filter for tx pool validation. +func startSupernodeELWithSupervisorURL( + t devtest.T, + l2Net *L2Network, + key string, + jwtPath string, + jwtSecret [32]byte, + supervisorURL string, +) L2ELNode { + switch devstackL2ELKind() { + case MixedL2ELOpGeth: + cfg := DefaultL2ELConfig() + l2EL := &OpGeth{ + name: key, + p: t, + logger: t.Logger().New("component", "l2el-"+key), + l2Net: l2Net, + jwtPath: jwtPath, + jwtSecret: jwtSecret, + supervisorRPC: supervisorURL, + cfg: cfg, + } + l2EL.Start() + t.Cleanup(l2EL.Stop) + return l2EL + default: // op-reth + return startMixedOpRethNodeWithSupervisorURL( + t, l2Net, key, jwtPath, jwtSecret, nil, supervisorURL) + } +} + func newSingleChainSupernodeRuntimeWithConfig(t devtest.T, interopAtGenesis bool, cfg PresetConfig) *MultiChainRuntime { require := t.Require() @@ -173,8 +208,38 @@ func newTwoL2SupernodeRuntimeWithConfig(t devtest.T, enableInterop bool, delaySe } l1EL, l1CL := startInProcessL1WithClockConfig(t, l1Net, jwtPath, l1Clock, cfg) - l2AEL := startSupernodeEL(t, l2ANet, jwtPath, jwtSecret) - l2BEL := startSupernodeEL(t, l2BNet, jwtPath, jwtSecret) + var l2AEL, l2BEL L2ELNode + var interopFilter *InteropFilter + + if cfg.UseInteropFilter { + // Proxy pattern: allocate stable address before ELs start + filterProxy := tcpproxy.New(t.Logger().New("proxy", "interop-filter")) + require.NoError(filterProxy.Start()) + t.Cleanup(func() { filterProxy.Close() }) + filterRPC := "http://" + filterProxy.Addr() + + // Start ELs with filter proxy URL + l2AEL = startSupernodeELWithSupervisorURL(t, l2ANet, "sequencer", jwtPath, jwtSecret, filterRPC) + l2BEL = startSupernodeELWithSupervisorURL(t, l2BNet, "sequencer", jwtPath, jwtSecret, filterRPC) + + // Build rollup config map from L2 networks (Go structs, no file I/O) + rollupConfigs := map[eth.ChainID]*rollup.Config{ + eth.ChainIDFromBig(l2ANet.RollupConfig().L2ChainID): l2ANet.RollupConfig(), + eth.ChainIDFromBig(l2BNet.RollupConfig().L2ChainID): l2BNet.RollupConfig(), + } + + // Create and start interop filter in-process + interopFilter = startInteropFilter(t, "interop-filter", + []string{l2AEL.UserRPC(), l2BEL.UserRPC()}, + rollupConfigs) + + // Connect proxy to the filter's actual RPC endpoint + filterProxy.SetUpstream(ProxyAddr(require, interopFilter.HTTPEndpoint())) + } else { + // No interop filter — ELs start without supervisor/filter URL (existing behavior) + l2AEL = startSupernodeEL(t, l2ANet, jwtPath, jwtSecret) + l2BEL = startSupernodeEL(t, l2BNet, jwtPath, jwtSecret) + } var activationTime uint64 var interopActivationTimestamp *uint64 @@ -240,9 +305,10 @@ func newTwoL2SupernodeRuntimeWithConfig(t devtest.T, enableInterop bool, delaySe Proposer: l2BProposer, }, }, - Supernode: supernode, - FaucetService: faucetService, - TimeTravel: timeTravelClock, + Supernode: supernode, + FaucetService: faucetService, + TimeTravel: timeTravelClock, + InteropFilter: interopFilter, }, activationTime } diff --git a/op-devstack/sysgo/preset_config.go b/op-devstack/sysgo/preset_config.go index 7cacaa963b2..ac789a8e7d2 100644 --- a/op-devstack/sysgo/preset_config.go +++ b/op-devstack/sysgo/preset_config.go @@ -20,6 +20,7 @@ type PresetConfig struct { EnableTimeTravel bool MaxSequencingWindow *uint64 RequireInteropNotAtGen bool + UseInteropFilter bool } func NewPresetConfig() PresetConfig { diff --git a/op-devstack/sysgo/runtime_state.go b/op-devstack/sysgo/runtime_state.go index b5c4e440a6f..5714b7637ca 100644 --- a/op-devstack/sysgo/runtime_state.go +++ b/op-devstack/sysgo/runtime_state.go @@ -117,4 +117,5 @@ type MultiChainRuntime struct { TestSequencer *TestSequencerRuntime L2ChallengerConfig *challengerconfig.Config DelaySeconds uint64 + InteropFilter *InteropFilter // nil if not using interop filter } diff --git a/op-interop-filter/filter/service.go b/op-interop-filter/filter/service.go index 55b815f81f6..6880b7ab82f 100644 --- a/op-interop-filter/filter/service.go +++ b/op-interop-filter/filter/service.go @@ -379,3 +379,19 @@ func (s *Service) AdminHTTPEndpoint() string { } return "http://" + s.adminRPCServer.Endpoint() } + +// Ready returns true if all chain ingesters have completed backfill. +func (s *Service) Ready() bool { + return s.backend.Ready() +} + +// SetFailsafeEnabled sets the manual failsafe override on the backend. +// Used by tests to toggle failsafe mode without admin RPC/JWT. +func (s *Service) SetFailsafeEnabled(enabled bool) { + s.backend.SetFailsafeEnabled(enabled) +} + +// FailsafeEnabled returns whether failsafe is currently active. +func (s *Service) FailsafeEnabled() bool { + return s.backend.FailsafeEnabled() +} From 22095071016aa85d880f404c7b92eac2d8fe9c96 Mon Sep 17 00:00:00 2001 From: wwared <541936+wwared@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:25:55 +0000 Subject: [PATCH 05/14] test(op-acceptance-tests): add interop filter acceptance tests Add acceptance tests exercising the op-reth + op-interop-filter topology with the supernode interop preset: - TestInteropFilter_IngressAcceptsValid: valid interop tx with correct cross-chain references passes through the filter - TestInteropFilter_IngressRejectsInvalid: fabricated CrossL2Inbox access list entries are rejected by the filter - TestInteropFilter_FailsafeBlocksInterop: failsafe toggle blocks new interop txs, recovery works after disabling - TestInteropFilter_NonInteropUnaffected: regular transfers succeed regardless of failsafe state - TestInteropFilter_FailsafeEvictsPooled: failsafe transition evicts existing interop txs and rejects new ones All tests use presets.WithInteropFilter() and direct InteropFilter.SetFailsafeEnabled() for in-process failsafe control. Tests are skipped by default (standard interop test pattern). Co-Authored-By: Claude Opus 4.6 --- .../interop/filter/interop_filter_test.go | 270 ++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 op-acceptance-tests/tests/interop/filter/interop_filter_test.go diff --git a/op-acceptance-tests/tests/interop/filter/interop_filter_test.go b/op-acceptance-tests/tests/interop/filter/interop_filter_test.go new file mode 100644 index 00000000000..7dbacc80679 --- /dev/null +++ b/op-acceptance-tests/tests/interop/filter/interop_filter_test.go @@ -0,0 +1,270 @@ +package filter + +import ( + "context" + "math/rand" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/ethereum-optimism/optimism/op-acceptance-tests/tests/interop" + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-devstack/presets" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-core/predeploys" + "github.com/ethereum-optimism/optimism/op-service/retry" + "github.com/ethereum-optimism/optimism/op-service/txplan" + suptypes "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types" +) + +func setupInteropFilterTest(t devtest.T) *presets.TwoL2SupernodeInterop { + return presets.NewTwoL2SupernodeInterop(t, 0, presets.WithInteropFilter()) +} + +// TestInteropFilter_IngressAcceptsValid verifies that a valid interop transaction +// with correct cross-chain references passes through the interop filter. +func TestInteropFilter_IngressAcceptsValid(gt *testing.T) { + gt.Skip("Skipping Interop Acceptance Test") + t := devtest.ParallelT(gt) + sys := setupInteropFilterTest(t) + + alice := sys.FunderA.NewFundedEOA(eth.OneHundredthEther) + bob := sys.FunderB.NewFundedEOA(eth.OneHundredthEther) + + eventLoggerAddress := alice.DeployEventLogger() + + sys.L2B.CatchUpTo(sys.L2A) + + // Send init message on chain A + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + initMsg := alice.SendInitMessage(interop.RandomInitTrigger(rng, eventLoggerAddress, 2, 10)) + + // Wait for at least one block between init and exec + sys.L2B.WaitForBlock() + + // Send exec message on chain B — the interop filter validates the access list + execMsg := bob.SendExecMessage(initMsg) + + // Verify cross-safe safety passes for both messages + dsl.CheckAll(t, + sys.L2ACL.ReachedRefFn(suptypes.CrossSafe, initMsg.BlockID(), 500), + sys.L2BCL.ReachedRefFn(suptypes.CrossSafe, execMsg.BlockID(), 500), + ) +} + +// TestInteropFilter_IngressRejectsInvalid verifies that a transaction with fabricated +// CrossL2Inbox access list entries is rejected by the interop filter. +func TestInteropFilter_IngressRejectsInvalid(gt *testing.T) { + gt.Skip("Skipping Interop Acceptance Test") + t := devtest.ParallelT(gt) + sys := setupInteropFilterTest(t) + require := t.Require() + + bob := sys.FunderB.NewFundedEOA(eth.OneHundredthEther) + + // Construct a fabricated access list entry with a random storage key + // that the filter won't recognize as a valid cross-chain message + fakeStorageKey := crypto.Keccak256Hash([]byte("fabricated-inbox-entry")) + accessList := types.AccessList{{ + Address: predeploys.CrossL2InboxAddr, + StorageKeys: []common.Hash{fakeStorageKey}, + }} + + // Send a transaction with the fabricated access list. + // The interop filter should reject this because the inbox entry doesn't + // correspond to any real cross-chain message. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + bobAddr := bob.Address() + tx := txplan.NewPlannedTx( + bob.Plan(), + txplan.WithTo(&bobAddr), + txplan.WithValue(eth.GWei(1)), + txplan.WithAccessList(accessList), + txplan.WithGasLimit(100_000), + ) + + // The transaction should fail — the filter rejects invalid interop entries. + // This may manifest as a submission error or the tx never being included. + _, err := tx.Included.Eval(ctx) + require.Error(err, "transaction with fabricated access list should not be included") +} + +// TestInteropFilter_FailsafeBlocksInterop verifies that enabling failsafe +// prevents new interop transactions from being accepted. +func TestInteropFilter_FailsafeBlocksInterop(gt *testing.T) { + gt.Skip("Skipping Interop Acceptance Test") + t := devtest.ParallelT(gt) + sys := setupInteropFilterTest(t) + require := t.Require() + + alice := sys.FunderA.NewFundedEOA(eth.OneHundredthEther) + bob := sys.FunderB.NewFundedEOA(eth.OneHundredthEther) + + eventLoggerAddress := alice.DeployEventLogger() + sys.L2B.CatchUpTo(sys.L2A) + + // Step 1: Send a valid interop tx — should succeed before failsafe + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + initMsg := alice.SendInitMessage(interop.RandomInitTrigger(rng, eventLoggerAddress, 2, 10)) + sys.L2B.WaitForBlock() + _ = bob.SendExecMessage(initMsg) + + // Step 2: Enable failsafe + require.NotNil(sys.InteropFilter, "interop filter must be configured") + sys.InteropFilter.SetFailsafeEnabled(true) + + // Step 3: Wait for failsafe to propagate to op-reth (polls every 1s) + time.Sleep(2 * time.Second) + + // Step 4: Send another init message and try exec — should fail + initMsg2 := alice.SendInitMessage(interop.RandomInitTrigger(rng, eventLoggerAddress, 1, 5)) + sys.L2B.WaitForBlock() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + // The exec message should be rejected by the filter during failsafe + execTrigger := interop.RandomInitTrigger(rng, eventLoggerAddress, 1, 5) + _ = execTrigger // We need to construct the exec message manually to catch the error + + // Try to submit interop tx - construct with fabricated valid-looking access list + // During failsafe, even valid access lists should be rejected + result, err := initMsg2.Tx.Result.Eval(ctx) + if err == nil && len(result.Entries) > 0 { + msg := result.Entries[0] + accessList := types.AccessList{{ + Address: predeploys.CrossL2InboxAddr, + StorageKeys: suptypes.EncodeAccessList([]suptypes.Access{msg.Access()}), + }} + + bobAddr := bob.Address() + tx := txplan.NewPlannedTx( + bob.Plan(), + txplan.WithTo(&bobAddr), + txplan.WithValue(eth.GWei(1)), + txplan.WithAccessList(accessList), + txplan.WithGasLimit(100_000), + ) + + _, err = tx.Included.Eval(ctx) + require.Error(err, "interop tx should be rejected during failsafe") + } + + // Step 5: Disable failsafe + sys.InteropFilter.SetFailsafeEnabled(false) + + // Step 6: Wait for failsafe to clear + time.Sleep(2 * time.Second) + + // Step 7: Verify interop txs work again + initMsg3 := alice.SendInitMessage(interop.RandomInitTrigger(rng, eventLoggerAddress, 1, 5)) + sys.L2B.WaitForBlock() + _ = bob.SendExecMessage(initMsg3) +} + +// TestInteropFilter_NonInteropUnaffected verifies that regular (non-interop) +// transactions are accepted regardless of failsafe state. +func TestInteropFilter_NonInteropUnaffected(gt *testing.T) { + gt.Skip("Skipping Interop Acceptance Test") + t := devtest.ParallelT(gt) + sys := setupInteropFilterTest(t) + require := t.Require() + + alice := sys.FunderA.NewFundedEOA(eth.OneHundredthEther) + bob := sys.FunderA.NewFundedEOA(eth.OneHundredthEther) + + // Enable failsafe + require.NotNil(sys.InteropFilter, "interop filter must be configured") + sys.InteropFilter.SetFailsafeEnabled(true) + time.Sleep(2 * time.Second) + + // Send a regular (non-interop) transfer — should succeed even during failsafe + tx := alice.Transfer(bob.Address(), eth.GWei(1000)) + receipt, err := tx.Included.Eval(context.Background()) + require.NoError(err, "regular transfer should succeed during failsafe") + require.Equal(types.ReceiptStatusSuccessful, receipt.Status, "regular transfer should succeed") + + // Disable failsafe + sys.InteropFilter.SetFailsafeEnabled(false) +} + +// TestInteropFilter_FailsafeEvictsPooled verifies that when failsafe transitions +// from disabled to enabled, existing interop transactions in the pool are evicted. +func TestInteropFilter_FailsafeEvictsPooled(gt *testing.T) { + gt.Skip("Skipping Interop Acceptance Test") + t := devtest.ParallelT(gt) + sys := setupInteropFilterTest(t) + require := t.Require() + + alice := sys.FunderA.NewFundedEOA(eth.OneHundredthEther) + bob := sys.FunderB.NewFundedEOA(eth.OneHundredthEther) + + eventLoggerAddress := alice.DeployEventLogger() + sys.L2B.CatchUpTo(sys.L2A) + + // Send init message — this creates an interop tx that goes into chain A's pool + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + initMsg := alice.SendInitMessage(interop.RandomInitTrigger(rng, eventLoggerAddress, 2, 10)) + sys.L2B.WaitForBlock() + + // Verify the exec message works normally + execMsg := bob.SendExecMessage(initMsg) + require.Equal(types.ReceiptStatusSuccessful, execMsg.Receipt.Status) + + // Enable failsafe — this should evict interop txs from the pool within 1s + sys.InteropFilter.SetFailsafeEnabled(true) + + // Wait for failsafe polling to detect and evict (polls every 1s) + time.Sleep(3 * time.Second) + + // Verify failsafe is active + require.True(sys.InteropFilter.FailsafeEnabled()) + + // New interop exec message should fail + initMsg2 := alice.SendInitMessage(interop.RandomInitTrigger(rng, eventLoggerAddress, 1, 5)) + sys.L2B.WaitForBlock() + + // Attempt the exec — should be rejected + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + result, err := initMsg2.Tx.Result.Eval(ctx) + if err == nil && len(result.Entries) > 0 { + msg := result.Entries[0] + accessList := types.AccessList{{ + Address: predeploys.CrossL2InboxAddr, + StorageKeys: suptypes.EncodeAccessList([]suptypes.Access{msg.Access()}), + }} + + bobAddr := bob.Address() + tx := txplan.NewPlannedTx( + bob.Plan(), + txplan.WithTo(&bobAddr), + txplan.WithValue(eth.GWei(1)), + txplan.WithAccessList(accessList), + txplan.WithGasLimit(100_000), + ) + + _, err = tx.Included.Eval(ctx) + require.Error(err, "interop tx should be rejected during failsafe") + } + + // Disable failsafe and verify recovery + sys.InteropFilter.SetFailsafeEnabled(false) + time.Sleep(2 * time.Second) + + // Wait for state to settle, then retry interop flow (non-mandatory, verify recovery) + err = retry.Do0(context.Background(), 5, &retry.FixedStrategy{Dur: 2 * time.Second}, func() error { + initMsg3 := alice.SendInitMessage(interop.RandomInitTrigger(rng, eventLoggerAddress, 1, 3)) + sys.L2B.WaitForBlock() + _ = bob.SendExecMessage(initMsg3) + return nil + }) + require.NoError(err, "interop flow should recover after failsafe disabled") +} From 26b1d27b93dc99f19196ae096acbd67f7c844fae Mon Sep 17 00:00:00 2001 From: wwared <541936+wwared@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:31:10 +0000 Subject: [PATCH 06/14] fix(op-devstack): defer interop filter readiness wait until after supernode starts The interop filter's chain ingesters need blocks to backfill, but the supernode (which drives block production) starts after the filter. Split the readiness wait out of startInteropFilter and call WaitForReady after the supernode and batchers are running. Co-Authored-By: Claude Opus 4.6 --- op-devstack/sysgo/interop_filter.go | 31 ++++++++++--------- .../sysgo/multichain_supernode_runtime.go | 6 ++++ 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/op-devstack/sysgo/interop_filter.go b/op-devstack/sysgo/interop_filter.go index c64e8d45b80..e0b4077a595 100644 --- a/op-devstack/sysgo/interop_filter.go +++ b/op-devstack/sysgo/interop_filter.go @@ -39,6 +39,23 @@ func (f *InteropFilter) Ready() bool { return f.service.Ready() } +// WaitForReady blocks until all chain ingesters have backfilled or the timeout expires. +// Call this after the supernode/CL layer has started so that blocks are being produced. +func (f *InteropFilter) WaitForReady(t devtest.T, timeout time.Duration) { + waitCtx, waitCancel := context.WithTimeout(context.Background(), timeout) + defer waitCancel() + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for !f.Ready() { + select { + case <-waitCtx.Done(): + t.Require().Fail("interop filter did not become ready within timeout") + case <-ticker.C: + } + } + f.logger.Info("Interop filter ready") +} + // SetFailsafeEnabled toggles failsafe mode directly on the backend. // No admin RPC or JWT needed — this is the in-process advantage. func (f *InteropFilter) SetFailsafeEnabled(enabled bool) { @@ -111,19 +128,5 @@ func startInteropFilter( t.Cleanup(func() { f.Stop() }) logger.Info("Started interop filter", "endpoint", service.HTTPEndpoint()) - // Wait for readiness (all chain ingesters backfilled) - waitCtx, waitCancel := context.WithTimeout(context.Background(), 60*time.Second) - defer waitCancel() - ticker := time.NewTicker(500 * time.Millisecond) - defer ticker.Stop() - for !f.service.Ready() { - select { - case <-waitCtx.Done(): - t.Require().Fail("interop filter did not become ready within 60s") - case <-ticker.C: - } - } - logger.Info("Interop filter ready") - return f } diff --git a/op-devstack/sysgo/multichain_supernode_runtime.go b/op-devstack/sysgo/multichain_supernode_runtime.go index 94986d1345b..1bbb07ce954 100644 --- a/op-devstack/sysgo/multichain_supernode_runtime.go +++ b/op-devstack/sysgo/multichain_supernode_runtime.go @@ -280,6 +280,12 @@ func newTwoL2SupernodeRuntimeWithConfig(t devtest.T, enableInterop bool, delaySe l2BNet.ChainID(): l2BEL.UserRPC(), }) + // Wait for interop filter readiness now that the supernode and batchers are running. + // The filter needs blocks to be produced before its chain ingesters can backfill. + if interopFilter != nil { + interopFilter.WaitForReady(t, 120*time.Second) + } + return &MultiChainRuntime{ Keys: keys, Migration: newInteropMigrationState(wb), From 50b012beda7648a88f1a73754603109723672d28 Mon Sep 17 00:00:00 2001 From: wwared <541936+wwared@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:36:56 +0000 Subject: [PATCH 07/14] test(op-acceptance-tests): keep interop filter tests unskipped All 5 tests pass with RUST_JIT_BUILD=1 DEVSTACK_L2EL_KIND=op-reth. Co-Authored-By: Claude Opus 4.6 --- .../tests/interop/filter/interop_filter_test.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/op-acceptance-tests/tests/interop/filter/interop_filter_test.go b/op-acceptance-tests/tests/interop/filter/interop_filter_test.go index 7dbacc80679..aa178ea6477 100644 --- a/op-acceptance-tests/tests/interop/filter/interop_filter_test.go +++ b/op-acceptance-tests/tests/interop/filter/interop_filter_test.go @@ -28,7 +28,6 @@ func setupInteropFilterTest(t devtest.T) *presets.TwoL2SupernodeInterop { // TestInteropFilter_IngressAcceptsValid verifies that a valid interop transaction // with correct cross-chain references passes through the interop filter. func TestInteropFilter_IngressAcceptsValid(gt *testing.T) { - gt.Skip("Skipping Interop Acceptance Test") t := devtest.ParallelT(gt) sys := setupInteropFilterTest(t) @@ -59,7 +58,6 @@ func TestInteropFilter_IngressAcceptsValid(gt *testing.T) { // TestInteropFilter_IngressRejectsInvalid verifies that a transaction with fabricated // CrossL2Inbox access list entries is rejected by the interop filter. func TestInteropFilter_IngressRejectsInvalid(gt *testing.T) { - gt.Skip("Skipping Interop Acceptance Test") t := devtest.ParallelT(gt) sys := setupInteropFilterTest(t) require := t.Require() @@ -98,7 +96,6 @@ func TestInteropFilter_IngressRejectsInvalid(gt *testing.T) { // TestInteropFilter_FailsafeBlocksInterop verifies that enabling failsafe // prevents new interop transactions from being accepted. func TestInteropFilter_FailsafeBlocksInterop(gt *testing.T) { - gt.Skip("Skipping Interop Acceptance Test") t := devtest.ParallelT(gt) sys := setupInteropFilterTest(t) require := t.Require() @@ -171,7 +168,6 @@ func TestInteropFilter_FailsafeBlocksInterop(gt *testing.T) { // TestInteropFilter_NonInteropUnaffected verifies that regular (non-interop) // transactions are accepted regardless of failsafe state. func TestInteropFilter_NonInteropUnaffected(gt *testing.T) { - gt.Skip("Skipping Interop Acceptance Test") t := devtest.ParallelT(gt) sys := setupInteropFilterTest(t) require := t.Require() @@ -197,7 +193,6 @@ func TestInteropFilter_NonInteropUnaffected(gt *testing.T) { // TestInteropFilter_FailsafeEvictsPooled verifies that when failsafe transitions // from disabled to enabled, existing interop transactions in the pool are evicted. func TestInteropFilter_FailsafeEvictsPooled(gt *testing.T) { - gt.Skip("Skipping Interop Acceptance Test") t := devtest.ParallelT(gt) sys := setupInteropFilterTest(t) require := t.Require() From 0f4f0957e44b6f2b0d9594a9f684d99533543bc1 Mon Sep 17 00:00:00 2001 From: wwared <541936+wwared@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:58:27 +0000 Subject: [PATCH 08/14] fix(op-devstack): address review findings in interop filter integration Convention fixes: - Sort imports alphabetically in multichain_supernode_runtime.go (rollup with op-node group, tcpproxy after sources) - Sort imports alphabetically in interop_filter_test.go (op-core/predeploys before op-devstack) - Remove dead execTrigger variable in FailsafeBlocksInterop test - Revert struct literal alignment to match surrounding style Test robustness: - Replace all time.Sleep waits with waitForFailsafeState() helper that confirms filter-side state then waits for 2 L2 blocks (guarantees op-reth's 1s polling task has had at least 2 poll cycles) - Make initMsg.Tx.Result.Eval() failures hard errors with require.NoError instead of silently skipping the failsafe assertion Co-Authored-By: Claude Opus 4.6 --- .../interop/filter/interop_filter_test.go | 130 +++++++++--------- .../sysgo/multichain_supernode_runtime.go | 12 +- 2 files changed, 71 insertions(+), 71 deletions(-) diff --git a/op-acceptance-tests/tests/interop/filter/interop_filter_test.go b/op-acceptance-tests/tests/interop/filter/interop_filter_test.go index aa178ea6477..25a1cab8e76 100644 --- a/op-acceptance-tests/tests/interop/filter/interop_filter_test.go +++ b/op-acceptance-tests/tests/interop/filter/interop_filter_test.go @@ -11,11 +11,11 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum-optimism/optimism/op-acceptance-tests/tests/interop" + "github.com/ethereum-optimism/optimism/op-core/predeploys" "github.com/ethereum-optimism/optimism/op-devstack/devtest" "github.com/ethereum-optimism/optimism/op-devstack/dsl" "github.com/ethereum-optimism/optimism/op-devstack/presets" "github.com/ethereum-optimism/optimism/op-service/eth" - "github.com/ethereum-optimism/optimism/op-core/predeploys" "github.com/ethereum-optimism/optimism/op-service/retry" "github.com/ethereum-optimism/optimism/op-service/txplan" suptypes "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types" @@ -25,6 +25,18 @@ func setupInteropFilterTest(t devtest.T) *presets.TwoL2SupernodeInterop { return presets.NewTwoL2SupernodeInterop(t, 0, presets.WithInteropFilter()) } +// waitForFailsafeState confirms the interop filter's state matches expected, +// then waits for two L2 blocks to ensure op-reth's 1s polling task has had +// time to pick up the change. This replaces time.Sleep-based waits. +func waitForFailsafeState(t devtest.T, sys *presets.TwoL2SupernodeInterop, expected bool) { + t.Require().Equal(expected, sys.InteropFilter.FailsafeEnabled(), + "interop filter failsafe state should already be %v after SetFailsafeEnabled", expected) + // Op-reth polls admin_getFailsafeEnabled every 1s. With 2s L2 block times, + // waiting for 2 blocks guarantees at least 2 poll cycles have elapsed. + sys.L2B.WaitForBlock() + sys.L2B.WaitForBlock() +} + // TestInteropFilter_IngressAcceptsValid verifies that a valid interop transaction // with correct cross-chain references passes through the interop filter. func TestInteropFilter_IngressAcceptsValid(gt *testing.T) { @@ -112,54 +124,46 @@ func TestInteropFilter_FailsafeBlocksInterop(gt *testing.T) { sys.L2B.WaitForBlock() _ = bob.SendExecMessage(initMsg) - // Step 2: Enable failsafe + // Step 2: Enable failsafe and wait for propagation to op-reth require.NotNil(sys.InteropFilter, "interop filter must be configured") sys.InteropFilter.SetFailsafeEnabled(true) + waitForFailsafeState(t, sys, true) - // Step 3: Wait for failsafe to propagate to op-reth (polls every 1s) - time.Sleep(2 * time.Second) - - // Step 4: Send another init message and try exec — should fail + // Step 3: Send another init message and try exec — should fail initMsg2 := alice.SendInitMessage(interop.RandomInitTrigger(rng, eventLoggerAddress, 1, 5)) sys.L2B.WaitForBlock() ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - // The exec message should be rejected by the filter during failsafe - execTrigger := interop.RandomInitTrigger(rng, eventLoggerAddress, 1, 5) - _ = execTrigger // We need to construct the exec message manually to catch the error - - // Try to submit interop tx - construct with fabricated valid-looking access list // During failsafe, even valid access lists should be rejected result, err := initMsg2.Tx.Result.Eval(ctx) - if err == nil && len(result.Entries) > 0 { - msg := result.Entries[0] - accessList := types.AccessList{{ - Address: predeploys.CrossL2InboxAddr, - StorageKeys: suptypes.EncodeAccessList([]suptypes.Access{msg.Access()}), - }} - - bobAddr := bob.Address() - tx := txplan.NewPlannedTx( - bob.Plan(), - txplan.WithTo(&bobAddr), - txplan.WithValue(eth.GWei(1)), - txplan.WithAccessList(accessList), - txplan.WithGasLimit(100_000), - ) - - _, err = tx.Included.Eval(ctx) - require.Error(err, "interop tx should be rejected during failsafe") - } - - // Step 5: Disable failsafe - sys.InteropFilter.SetFailsafeEnabled(false) + require.NoError(err, "init message result must be available") + require.Greater(len(result.Entries), 0, "init message must have entries") + + msg := result.Entries[0] + accessList := types.AccessList{{ + Address: predeploys.CrossL2InboxAddr, + StorageKeys: suptypes.EncodeAccessList([]suptypes.Access{msg.Access()}), + }} + + bobAddr := bob.Address() + tx := txplan.NewPlannedTx( + bob.Plan(), + txplan.WithTo(&bobAddr), + txplan.WithValue(eth.GWei(1)), + txplan.WithAccessList(accessList), + txplan.WithGasLimit(100_000), + ) - // Step 6: Wait for failsafe to clear - time.Sleep(2 * time.Second) + _, err = tx.Included.Eval(ctx) + require.Error(err, "interop tx should be rejected during failsafe") - // Step 7: Verify interop txs work again + // Step 4: Disable failsafe and wait for propagation + sys.InteropFilter.SetFailsafeEnabled(false) + waitForFailsafeState(t, sys, false) + + // Step 5: Verify interop txs work again initMsg3 := alice.SendInitMessage(interop.RandomInitTrigger(rng, eventLoggerAddress, 1, 5)) sys.L2B.WaitForBlock() _ = bob.SendExecMessage(initMsg3) @@ -175,10 +179,10 @@ func TestInteropFilter_NonInteropUnaffected(gt *testing.T) { alice := sys.FunderA.NewFundedEOA(eth.OneHundredthEther) bob := sys.FunderA.NewFundedEOA(eth.OneHundredthEther) - // Enable failsafe + // Enable failsafe and wait for propagation require.NotNil(sys.InteropFilter, "interop filter must be configured") sys.InteropFilter.SetFailsafeEnabled(true) - time.Sleep(2 * time.Second) + waitForFailsafeState(t, sys, true) // Send a regular (non-interop) transfer — should succeed even during failsafe tx := alice.Transfer(bob.Address(), eth.GWei(1000)) @@ -214,12 +218,7 @@ func TestInteropFilter_FailsafeEvictsPooled(gt *testing.T) { // Enable failsafe — this should evict interop txs from the pool within 1s sys.InteropFilter.SetFailsafeEnabled(true) - - // Wait for failsafe polling to detect and evict (polls every 1s) - time.Sleep(3 * time.Second) - - // Verify failsafe is active - require.True(sys.InteropFilter.FailsafeEnabled()) + waitForFailsafeState(t, sys, true) // New interop exec message should fail initMsg2 := alice.SendInitMessage(interop.RandomInitTrigger(rng, eventLoggerAddress, 1, 5)) @@ -230,31 +229,32 @@ func TestInteropFilter_FailsafeEvictsPooled(gt *testing.T) { defer cancel() result, err := initMsg2.Tx.Result.Eval(ctx) - if err == nil && len(result.Entries) > 0 { - msg := result.Entries[0] - accessList := types.AccessList{{ - Address: predeploys.CrossL2InboxAddr, - StorageKeys: suptypes.EncodeAccessList([]suptypes.Access{msg.Access()}), - }} - - bobAddr := bob.Address() - tx := txplan.NewPlannedTx( - bob.Plan(), - txplan.WithTo(&bobAddr), - txplan.WithValue(eth.GWei(1)), - txplan.WithAccessList(accessList), - txplan.WithGasLimit(100_000), - ) - - _, err = tx.Included.Eval(ctx) - require.Error(err, "interop tx should be rejected during failsafe") - } + require.NoError(err, "init message result must be available") + require.Greater(len(result.Entries), 0, "init message must have entries") + + msg := result.Entries[0] + accessList := types.AccessList{{ + Address: predeploys.CrossL2InboxAddr, + StorageKeys: suptypes.EncodeAccessList([]suptypes.Access{msg.Access()}), + }} + + bobAddr := bob.Address() + tx := txplan.NewPlannedTx( + bob.Plan(), + txplan.WithTo(&bobAddr), + txplan.WithValue(eth.GWei(1)), + txplan.WithAccessList(accessList), + txplan.WithGasLimit(100_000), + ) + + _, err = tx.Included.Eval(ctx) + require.Error(err, "interop tx should be rejected during failsafe") // Disable failsafe and verify recovery sys.InteropFilter.SetFailsafeEnabled(false) - time.Sleep(2 * time.Second) + waitForFailsafeState(t, sys, false) - // Wait for state to settle, then retry interop flow (non-mandatory, verify recovery) + // Retry interop flow to verify recovery err = retry.Do0(context.Background(), 5, &retry.FixedStrategy{Dur: 2 * time.Second}, func() error { initMsg3 := alice.SendInitMessage(interop.RandomInitTrigger(rng, eventLoggerAddress, 1, 3)) sys.L2B.WaitForBlock() diff --git a/op-devstack/sysgo/multichain_supernode_runtime.go b/op-devstack/sysgo/multichain_supernode_runtime.go index 1bbb07ce954..32cf87d21aa 100644 --- a/op-devstack/sysgo/multichain_supernode_runtime.go +++ b/op-devstack/sysgo/multichain_supernode_runtime.go @@ -25,18 +25,18 @@ import ( opnodeconfig "github.com/ethereum-optimism/optimism/op-node/config" "github.com/ethereum-optimism/optimism/op-node/rollup/driver" "github.com/ethereum-optimism/optimism/op-node/rollup/interop" + "github.com/ethereum-optimism/optimism/op-node/rollup" nodeSync "github.com/ethereum-optimism/optimism/op-node/rollup/sync" "github.com/ethereum-optimism/optimism/op-service/client" - "github.com/ethereum-optimism/optimism/op-node/rollup" "github.com/ethereum-optimism/optimism/op-service/clock" "github.com/ethereum-optimism/optimism/op-service/endpoint" "github.com/ethereum-optimism/optimism/op-service/eth" - "github.com/ethereum-optimism/optimism/op-service/testutils/tcpproxy" oplog "github.com/ethereum-optimism/optimism/op-service/log" opmetrics "github.com/ethereum-optimism/optimism/op-service/metrics" "github.com/ethereum-optimism/optimism/op-service/oppprof" oprpc "github.com/ethereum-optimism/optimism/op-service/rpc" "github.com/ethereum-optimism/optimism/op-service/sources" + "github.com/ethereum-optimism/optimism/op-service/testutils/tcpproxy" snconfig "github.com/ethereum-optimism/optimism/op-supernode/config" "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/backend/depset" sequencerConfig "github.com/ethereum-optimism/optimism/op-test-sequencer/config" @@ -311,10 +311,10 @@ func newTwoL2SupernodeRuntimeWithConfig(t devtest.T, enableInterop bool, delaySe Proposer: l2BProposer, }, }, - Supernode: supernode, - FaucetService: faucetService, - TimeTravel: timeTravelClock, - InteropFilter: interopFilter, + Supernode: supernode, + FaucetService: faucetService, + TimeTravel: timeTravelClock, + InteropFilter: interopFilter, }, activationTime } From ad976f31776fe35837b91814872d231663d0d5c4 Mon Sep 17 00:00:00 2001 From: wwared <541936+wwared@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:36:07 +0000 Subject: [PATCH 09/14] fix(op-reth): use cached failsafe state for ingress rejection and maintenance eviction Add InvalidCrossTx::FailsafeEnabled variant for fast-path ingress rejection without RPC round-trip. Add failsafe eviction to the block-event maintenance loop as belt-and-suspenders with poll_failsafe. Fix gofmt import ordering in multichain_supernode_runtime.go. Tighten IngressRejectsInvalid test (10s timeout + explicit rejection assert). Merge FailsafeBlocksInterop and FailsafeEvictsPooled into FailsafeLifecycle. Extend NonInteropUnaffected to test both chains. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../interop/filter/interop_filter_test.go | 124 +++++------------- .../sysgo/multichain_supernode_runtime.go | 2 +- rust/op-reth/crates/txpool/src/error.rs | 5 +- rust/op-reth/crates/txpool/src/maintain.rs | 22 ++++ .../crates/txpool/src/supervisor/client.rs | 5 + 5 files changed, 67 insertions(+), 91 deletions(-) diff --git a/op-acceptance-tests/tests/interop/filter/interop_filter_test.go b/op-acceptance-tests/tests/interop/filter/interop_filter_test.go index 25a1cab8e76..2f2a1b377c2 100644 --- a/op-acceptance-tests/tests/interop/filter/interop_filter_test.go +++ b/op-acceptance-tests/tests/interop/filter/interop_filter_test.go @@ -2,6 +2,7 @@ package filter import ( "context" + "errors" "math/rand" "testing" "time" @@ -87,7 +88,7 @@ func TestInteropFilter_IngressRejectsInvalid(gt *testing.T) { // Send a transaction with the fabricated access list. // The interop filter should reject this because the inbox entry doesn't // correspond to any real cross-chain message. - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() bobAddr := bob.Address() @@ -99,15 +100,17 @@ func TestInteropFilter_IngressRejectsInvalid(gt *testing.T) { txplan.WithGasLimit(100_000), ) - // The transaction should fail — the filter rejects invalid interop entries. - // This may manifest as a submission error or the tx never being included. + // The transaction should be explicitly rejected by the filter, not just time out. _, err := tx.Included.Eval(ctx) require.Error(err, "transaction with fabricated access list should not be included") + require.False(errors.Is(err, context.DeadlineExceeded), + "expected explicit rejection, not timeout: %v", err) } -// TestInteropFilter_FailsafeBlocksInterop verifies that enabling failsafe -// prevents new interop transactions from being accepted. -func TestInteropFilter_FailsafeBlocksInterop(gt *testing.T) { +// TestInteropFilter_FailsafeLifecycle verifies the full failsafe lifecycle: +// interop txs succeed normally, are blocked when failsafe is enabled, +// and recover after failsafe is disabled. +func TestInteropFilter_FailsafeLifecycle(gt *testing.T) { t := devtest.ParallelT(gt) sys := setupInteropFilterTest(t) require := t.Require() @@ -122,7 +125,9 @@ func TestInteropFilter_FailsafeBlocksInterop(gt *testing.T) { rng := rand.New(rand.NewSource(time.Now().UnixNano())) initMsg := alice.SendInitMessage(interop.RandomInitTrigger(rng, eventLoggerAddress, 2, 10)) sys.L2B.WaitForBlock() - _ = bob.SendExecMessage(initMsg) + execMsg := bob.SendExecMessage(initMsg) + require.Equal(types.ReceiptStatusSuccessful, execMsg.Receipt.Status, + "interop tx should succeed before failsafe") // Step 2: Enable failsafe and wait for propagation to op-reth require.NotNil(sys.InteropFilter, "interop filter must be configured") @@ -163,103 +168,44 @@ func TestInteropFilter_FailsafeBlocksInterop(gt *testing.T) { sys.InteropFilter.SetFailsafeEnabled(false) waitForFailsafeState(t, sys, false) - // Step 5: Verify interop txs work again - initMsg3 := alice.SendInitMessage(interop.RandomInitTrigger(rng, eventLoggerAddress, 1, 5)) - sys.L2B.WaitForBlock() - _ = bob.SendExecMessage(initMsg3) + // Step 5: Verify interop txs recover — retry to tolerate propagation lag + err = retry.Do0(context.Background(), 5, &retry.FixedStrategy{Dur: 2 * time.Second}, func() error { + initMsg3 := alice.SendInitMessage(interop.RandomInitTrigger(rng, eventLoggerAddress, 1, 3)) + sys.L2B.WaitForBlock() + _ = bob.SendExecMessage(initMsg3) + return nil + }) + require.NoError(err, "interop flow should recover after failsafe disabled") } // TestInteropFilter_NonInteropUnaffected verifies that regular (non-interop) -// transactions are accepted regardless of failsafe state. +// transactions are accepted on both chains regardless of failsafe state. func TestInteropFilter_NonInteropUnaffected(gt *testing.T) { t := devtest.ParallelT(gt) sys := setupInteropFilterTest(t) require := t.Require() - alice := sys.FunderA.NewFundedEOA(eth.OneHundredthEther) - bob := sys.FunderA.NewFundedEOA(eth.OneHundredthEther) + aliceA := sys.FunderA.NewFundedEOA(eth.OneHundredthEther) + bobA := sys.FunderA.NewFundedEOA(eth.OneHundredthEther) + aliceB := sys.FunderB.NewFundedEOA(eth.OneHundredthEther) + bobB := sys.FunderB.NewFundedEOA(eth.OneHundredthEther) // Enable failsafe and wait for propagation require.NotNil(sys.InteropFilter, "interop filter must be configured") sys.InteropFilter.SetFailsafeEnabled(true) waitForFailsafeState(t, sys, true) - // Send a regular (non-interop) transfer — should succeed even during failsafe - tx := alice.Transfer(bob.Address(), eth.GWei(1000)) - receipt, err := tx.Included.Eval(context.Background()) - require.NoError(err, "regular transfer should succeed during failsafe") - require.Equal(types.ReceiptStatusSuccessful, receipt.Status, "regular transfer should succeed") - - // Disable failsafe - sys.InteropFilter.SetFailsafeEnabled(false) -} - -// TestInteropFilter_FailsafeEvictsPooled verifies that when failsafe transitions -// from disabled to enabled, existing interop transactions in the pool are evicted. -func TestInteropFilter_FailsafeEvictsPooled(gt *testing.T) { - t := devtest.ParallelT(gt) - sys := setupInteropFilterTest(t) - require := t.Require() - - alice := sys.FunderA.NewFundedEOA(eth.OneHundredthEther) - bob := sys.FunderB.NewFundedEOA(eth.OneHundredthEther) - - eventLoggerAddress := alice.DeployEventLogger() - sys.L2B.CatchUpTo(sys.L2A) - - // Send init message — this creates an interop tx that goes into chain A's pool - rng := rand.New(rand.NewSource(time.Now().UnixNano())) - initMsg := alice.SendInitMessage(interop.RandomInitTrigger(rng, eventLoggerAddress, 2, 10)) - sys.L2B.WaitForBlock() - - // Verify the exec message works normally - execMsg := bob.SendExecMessage(initMsg) - require.Equal(types.ReceiptStatusSuccessful, execMsg.Receipt.Status) - - // Enable failsafe — this should evict interop txs from the pool within 1s - sys.InteropFilter.SetFailsafeEnabled(true) - waitForFailsafeState(t, sys, true) - - // New interop exec message should fail - initMsg2 := alice.SendInitMessage(interop.RandomInitTrigger(rng, eventLoggerAddress, 1, 5)) - sys.L2B.WaitForBlock() - - // Attempt the exec — should be rejected - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() + // Send regular (non-interop) transfers on both chains — should succeed even during failsafe + txA := aliceA.Transfer(bobA.Address(), eth.GWei(1000)) + receiptA, err := txA.Included.Eval(context.Background()) + require.NoError(err, "regular transfer on chain A should succeed during failsafe") + require.Equal(types.ReceiptStatusSuccessful, receiptA.Status, "regular transfer on chain A should succeed") - result, err := initMsg2.Tx.Result.Eval(ctx) - require.NoError(err, "init message result must be available") - require.Greater(len(result.Entries), 0, "init message must have entries") - - msg := result.Entries[0] - accessList := types.AccessList{{ - Address: predeploys.CrossL2InboxAddr, - StorageKeys: suptypes.EncodeAccessList([]suptypes.Access{msg.Access()}), - }} + txB := aliceB.Transfer(bobB.Address(), eth.GWei(1000)) + receiptB, err := txB.Included.Eval(context.Background()) + require.NoError(err, "regular transfer on chain B should succeed during failsafe") + require.Equal(types.ReceiptStatusSuccessful, receiptB.Status, "regular transfer on chain B should succeed") - bobAddr := bob.Address() - tx := txplan.NewPlannedTx( - bob.Plan(), - txplan.WithTo(&bobAddr), - txplan.WithValue(eth.GWei(1)), - txplan.WithAccessList(accessList), - txplan.WithGasLimit(100_000), - ) - - _, err = tx.Included.Eval(ctx) - require.Error(err, "interop tx should be rejected during failsafe") - - // Disable failsafe and verify recovery + // Disable failsafe sys.InteropFilter.SetFailsafeEnabled(false) - waitForFailsafeState(t, sys, false) - - // Retry interop flow to verify recovery - err = retry.Do0(context.Background(), 5, &retry.FixedStrategy{Dur: 2 * time.Second}, func() error { - initMsg3 := alice.SendInitMessage(interop.RandomInitTrigger(rng, eventLoggerAddress, 1, 3)) - sys.L2B.WaitForBlock() - _ = bob.SendExecMessage(initMsg3) - return nil - }) - require.NoError(err, "interop flow should recover after failsafe disabled") } diff --git a/op-devstack/sysgo/multichain_supernode_runtime.go b/op-devstack/sysgo/multichain_supernode_runtime.go index 32cf87d21aa..c6fa612e98e 100644 --- a/op-devstack/sysgo/multichain_supernode_runtime.go +++ b/op-devstack/sysgo/multichain_supernode_runtime.go @@ -23,9 +23,9 @@ import ( fconf "github.com/ethereum-optimism/optimism/op-faucet/faucet/backend/config" ftypes "github.com/ethereum-optimism/optimism/op-faucet/faucet/backend/types" opnodeconfig "github.com/ethereum-optimism/optimism/op-node/config" + "github.com/ethereum-optimism/optimism/op-node/rollup" "github.com/ethereum-optimism/optimism/op-node/rollup/driver" "github.com/ethereum-optimism/optimism/op-node/rollup/interop" - "github.com/ethereum-optimism/optimism/op-node/rollup" nodeSync "github.com/ethereum-optimism/optimism/op-node/rollup/sync" "github.com/ethereum-optimism/optimism/op-service/client" "github.com/ethereum-optimism/optimism/op-service/clock" diff --git a/rust/op-reth/crates/txpool/src/error.rs b/rust/op-reth/crates/txpool/src/error.rs index 564a6055a32..a2af7fc2115 100644 --- a/rust/op-reth/crates/txpool/src/error.rs +++ b/rust/op-reth/crates/txpool/src/error.rs @@ -11,13 +11,16 @@ pub enum InvalidCrossTx { /// Error cause by cross chain tx during not active interop hardfork #[error("cross chain tx is invalid before interop")] CrossChainTxPreInterop, + /// Rejected because failsafe mode is active — all interop txs are blocked. + #[error("interop failsafe is active")] + FailsafeEnabled, } impl PoolTransactionError for InvalidCrossTx { fn is_bad_transaction(&self) -> bool { match self { - Self::ValidationError(_) => false, Self::CrossChainTxPreInterop => true, + Self::ValidationError(_) | Self::FailsafeEnabled => false, } } diff --git a/rust/op-reth/crates/txpool/src/maintain.rs b/rust/op-reth/crates/txpool/src/maintain.rs index df39336acb6..f54e7799188 100644 --- a/rust/op-reth/crates/txpool/src/maintain.rs +++ b/rust/op-reth/crates/txpool/src/maintain.rs @@ -173,6 +173,28 @@ pub async fn maintain_transaction_pool_interop( let mut to_revalidate = Vec::new(); let mut interop_count = 0; + // If failsafe is active, evict ALL interop txs and skip revalidation. + // Belt-and-suspenders with poll_failsafe: catches any tx that raced past + // the ingress check or was added between poll_failsafe transition ticks. + if supervisor_client.is_failsafe_enabled() { + let interop_hashes: Vec<_> = pool + .pooled_transactions() + .iter() + .filter(|tx| tx.transaction.interop_deadline().is_some()) + .map(|tx| *tx.hash()) + .collect(); + if !interop_hashes.is_empty() { + info!( + target: "txpool::interop", + count = interop_hashes.len(), + "failsafe active on block event: evicting all interop transactions" + ); + let removed = pool.remove_transactions(interop_hashes); + metrics.inc_removed_tx_interop(removed.len()); + } + continue; + } + // scan all pooled interop transactions for pooled_tx in pool.pooled_transactions() { if let Some(interop_deadline_val) = pooled_tx.transaction.interop_deadline() { diff --git a/rust/op-reth/crates/txpool/src/supervisor/client.rs b/rust/op-reth/crates/txpool/src/supervisor/client.rs index cd6f00202ed..adc57b9a560 100644 --- a/rust/op-reth/crates/txpool/src/supervisor/client.rs +++ b/rust/op-reth/crates/txpool/src/supervisor/client.rs @@ -111,6 +111,11 @@ impl SupervisorClient { return Some(Err(InvalidCrossTx::CrossChainTxPreInterop)); } + // Fast-path: reject immediately if failsafe is active (no RPC round-trip) + if self.is_failsafe_enabled() { + return Some(Err(InvalidCrossTx::FailsafeEnabled)); + } + if let Err(err) = self .check_access_list( inbox_entries.as_slice(), From 2a6017cc67a640e47683831f6f703281921fe69d Mon Sep 17 00:00:00 2001 From: wwared <541936+wwared@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:28:10 +0000 Subject: [PATCH 10/14] refactor(op-reth): unify interop tx identification under is_interop_tx Move is_interop_tx to interop.rs as the single pub(crate) helper for identifying interop transactions by access list. Use it in maintain and poll_failsafe eviction paths instead of interop_deadline().is_some(), ensuring all codepaths use the same structural check. Co-Authored-By: Claude Opus 4.6 (1M context) --- rust/op-reth/crates/txpool/src/interop.rs | 19 +++++++++++++++++++ rust/op-reth/crates/txpool/src/maintain.rs | 6 +++--- rust/op-reth/crates/txpool/src/pool.rs | 17 ++--------------- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/rust/op-reth/crates/txpool/src/interop.rs b/rust/op-reth/crates/txpool/src/interop.rs index f225ff478e3..ec430c4a4c2 100644 --- a/rust/op-reth/crates/txpool/src/interop.rs +++ b/rust/op-reth/crates/txpool/src/interop.rs @@ -1,5 +1,24 @@ //! Additional support for pooled interop transactions. +use alloy_consensus::Transaction; +use reth_transaction_pool::PoolTransaction; + +use crate::supervisor::CROSS_L2_INBOX_ADDRESS; + +/// Returns true if the transaction's access list targets `CROSS_L2_INBOX_ADDRESS` +/// with at least one storage key. +pub(crate) fn is_interop_tx(tx: &T) -> bool +where + T: PoolTransaction + Transaction, +{ + tx.access_list() + .map(|al| { + al.iter() + .any(|item| item.address == CROSS_L2_INBOX_ADDRESS && !item.storage_keys.is_empty()) + }) + .unwrap_or(false) +} + /// Helper trait that allows attaching an interop deadline. pub trait MaybeInteropTransaction { /// Attach an interop deadline diff --git a/rust/op-reth/crates/txpool/src/maintain.rs b/rust/op-reth/crates/txpool/src/maintain.rs index f54e7799188..4c5a958090c 100644 --- a/rust/op-reth/crates/txpool/src/maintain.rs +++ b/rust/op-reth/crates/txpool/src/maintain.rs @@ -12,7 +12,7 @@ const MAX_SUPERVISOR_QUERIES: usize = 10; use crate::{ conditional::MaybeConditionalTransaction, - interop::{MaybeInteropTransaction, is_stale_interop, is_valid_interop}, + interop::{MaybeInteropTransaction, is_interop_tx, is_stale_interop, is_valid_interop}, supervisor::SupervisorClient, }; use alloy_consensus::{BlockHeader, conditional::BlockConditionalAttributes}; @@ -180,7 +180,7 @@ pub async fn maintain_transaction_pool_interop( let interop_hashes: Vec<_> = pool .pooled_transactions() .iter() - .filter(|tx| tx.transaction.interop_deadline().is_some()) + .filter(|tx| is_interop_tx(&tx.transaction)) .map(|tx| *tx.hash()) .collect(); if !interop_hashes.is_empty() { @@ -278,7 +278,7 @@ where let interop_hashes: Vec<_> = pool .pooled_transactions() .iter() - .filter(|tx| tx.transaction.interop_deadline().is_some()) + .filter(|tx| is_interop_tx(&tx.transaction)) .map(|tx| *tx.hash()) .collect(); if !interop_hashes.is_empty() { diff --git a/rust/op-reth/crates/txpool/src/pool.rs b/rust/op-reth/crates/txpool/src/pool.rs index 598c77b0354..5983af659a4 100644 --- a/rust/op-reth/crates/txpool/src/pool.rs +++ b/rust/op-reth/crates/txpool/src/pool.rs @@ -26,7 +26,7 @@ use reth_transaction_pool::{ use tokio::sync::mpsc::Receiver; use tracing::debug; -use crate::supervisor::CROSS_L2_INBOX_ADDRESS; +use crate::interop::is_interop_tx; /// Duration after a reorg during which all interop transactions are filtered /// from `add_external_transactions`. @@ -120,20 +120,6 @@ where } } -/// Returns true if the transaction's access list targets `CROSS_L2_INBOX_ADDRESS` -/// with at least one storage key. -fn is_interop_tx(tx: &T) -> bool -where - T: PoolTransaction + Transaction, -{ - tx.access_list() - .map(|al| { - al.iter() - .any(|item| item.address == CROSS_L2_INBOX_ADDRESS && !item.storage_keys.is_empty()) - }) - .unwrap_or(false) -} - impl

OpPool

where P: TransactionPool, @@ -393,6 +379,7 @@ where #[cfg(test)] mod tests { use super::*; + use crate::supervisor::CROSS_L2_INBOX_ADDRESS; use alloy_eips::eip2930::{AccessList, AccessListItem}; use alloy_primitives::address; use reth_transaction_pool::test_utils::MockTransaction; From 28c6f45cade6d8510e2aa470449d2549f25f43af Mon Sep 17 00:00:00 2001 From: wwared <541936+wwared@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:52:42 +0000 Subject: [PATCH 11/14] fix(op-acceptance-tests): use single-attempt submit in IngressRejectsInvalid The test used bob.Plan() which includes WithRetrySubmission (5 retries, exponential backoff). Under CI load, retrying the rejected tx exhausted the 10s context timeout, causing the test to see DeadlineExceeded instead of the filter's explicit rejection error. Fix: override with WithTransactionSubmitter (no retries) and evaluate tx.Submitted instead of tx.Included, so the filter rejection propagates on the first attempt. Also unifies interop tx identification under is_interop_tx in interop.rs so all codepaths (ingress, maintenance, failsafe eviction, reorg) use the same structural access-list check. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../interop/filter/interop_filter_test.go | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/op-acceptance-tests/tests/interop/filter/interop_filter_test.go b/op-acceptance-tests/tests/interop/filter/interop_filter_test.go index 2f2a1b377c2..04828ae3ed0 100644 --- a/op-acceptance-tests/tests/interop/filter/interop_filter_test.go +++ b/op-acceptance-tests/tests/interop/filter/interop_filter_test.go @@ -2,7 +2,6 @@ package filter import ( "context" - "errors" "math/rand" "testing" "time" @@ -92,19 +91,24 @@ func TestInteropFilter_IngressRejectsInvalid(gt *testing.T) { defer cancel() bobAddr := bob.Address() + elClient := sys.L2ELB.EthClient() tx := txplan.NewPlannedTx( bob.Plan(), + // Override retry submission with single-attempt submitter so the + // filter's rejection propagates immediately instead of retrying + // until the context expires. + txplan.WithTransactionSubmitter(elClient), txplan.WithTo(&bobAddr), txplan.WithValue(eth.GWei(1)), txplan.WithAccessList(accessList), txplan.WithGasLimit(100_000), ) - // The transaction should be explicitly rejected by the filter, not just time out. - _, err := tx.Included.Eval(ctx) + // The transaction should be explicitly rejected by the interop filter. + _, err := tx.Submitted.Eval(ctx) require.Error(err, "transaction with fabricated access list should not be included") - require.False(errors.Is(err, context.DeadlineExceeded), - "expected explicit rejection, not timeout: %v", err) + require.Contains(err.Error(), "failed to parse access entry", + "expected interop filter rejection, got: %v", err) } // TestInteropFilter_FailsafeLifecycle verifies the full failsafe lifecycle: @@ -153,16 +157,20 @@ func TestInteropFilter_FailsafeLifecycle(gt *testing.T) { }} bobAddr := bob.Address() + elClient := sys.L2ELB.EthClient() tx := txplan.NewPlannedTx( bob.Plan(), + txplan.WithTransactionSubmitter(elClient), txplan.WithTo(&bobAddr), txplan.WithValue(eth.GWei(1)), txplan.WithAccessList(accessList), txplan.WithGasLimit(100_000), ) - _, err = tx.Included.Eval(ctx) + _, err = tx.Submitted.Eval(ctx) require.Error(err, "interop tx should be rejected during failsafe") + require.Contains(err.Error(), "interop failsafe is active", + "expected failsafe rejection, got: %v", err) // Step 4: Disable failsafe and wait for propagation sys.InteropFilter.SetFailsafeEnabled(false) From 9ac16ec27b2d7f570a9034b03fba4d5ae7372143 Mon Sep 17 00:00:00 2001 From: wwared <541936+wwared@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:32:59 +0000 Subject: [PATCH 12/14] fix(op-acceptance-tests): address review feedback on interop filter tests - Remove TestInteropFilter_IngressAcceptsValid (redundant with existing message tests; enable WithInteropFilter() on TestInteropHappyTx instead) - Remove waitForFailsafeState helper and its 2-block wait heuristic - Relax failsafe error assertion to accept any rejection (both op-reth fast-path and filter HTTP path correctly reject during failsafe) - Replace retry.Do0 recovery loop with single init/exec attempt - Simplify failsafe propagation to single WaitForBlock() calls Co-Authored-By: Claude Opus 4.6 (1M context) --- .../interop/filter/interop_filter_test.go | 73 +++---------------- .../interop/message/interop_happy_tx_test.go | 2 +- 2 files changed, 13 insertions(+), 62 deletions(-) diff --git a/op-acceptance-tests/tests/interop/filter/interop_filter_test.go b/op-acceptance-tests/tests/interop/filter/interop_filter_test.go index 04828ae3ed0..36e9cf38b9d 100644 --- a/op-acceptance-tests/tests/interop/filter/interop_filter_test.go +++ b/op-acceptance-tests/tests/interop/filter/interop_filter_test.go @@ -13,10 +13,8 @@ import ( "github.com/ethereum-optimism/optimism/op-acceptance-tests/tests/interop" "github.com/ethereum-optimism/optimism/op-core/predeploys" "github.com/ethereum-optimism/optimism/op-devstack/devtest" - "github.com/ethereum-optimism/optimism/op-devstack/dsl" "github.com/ethereum-optimism/optimism/op-devstack/presets" "github.com/ethereum-optimism/optimism/op-service/eth" - "github.com/ethereum-optimism/optimism/op-service/retry" "github.com/ethereum-optimism/optimism/op-service/txplan" suptypes "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types" ) @@ -25,48 +23,6 @@ func setupInteropFilterTest(t devtest.T) *presets.TwoL2SupernodeInterop { return presets.NewTwoL2SupernodeInterop(t, 0, presets.WithInteropFilter()) } -// waitForFailsafeState confirms the interop filter's state matches expected, -// then waits for two L2 blocks to ensure op-reth's 1s polling task has had -// time to pick up the change. This replaces time.Sleep-based waits. -func waitForFailsafeState(t devtest.T, sys *presets.TwoL2SupernodeInterop, expected bool) { - t.Require().Equal(expected, sys.InteropFilter.FailsafeEnabled(), - "interop filter failsafe state should already be %v after SetFailsafeEnabled", expected) - // Op-reth polls admin_getFailsafeEnabled every 1s. With 2s L2 block times, - // waiting for 2 blocks guarantees at least 2 poll cycles have elapsed. - sys.L2B.WaitForBlock() - sys.L2B.WaitForBlock() -} - -// TestInteropFilter_IngressAcceptsValid verifies that a valid interop transaction -// with correct cross-chain references passes through the interop filter. -func TestInteropFilter_IngressAcceptsValid(gt *testing.T) { - t := devtest.ParallelT(gt) - sys := setupInteropFilterTest(t) - - alice := sys.FunderA.NewFundedEOA(eth.OneHundredthEther) - bob := sys.FunderB.NewFundedEOA(eth.OneHundredthEther) - - eventLoggerAddress := alice.DeployEventLogger() - - sys.L2B.CatchUpTo(sys.L2A) - - // Send init message on chain A - rng := rand.New(rand.NewSource(time.Now().UnixNano())) - initMsg := alice.SendInitMessage(interop.RandomInitTrigger(rng, eventLoggerAddress, 2, 10)) - - // Wait for at least one block between init and exec - sys.L2B.WaitForBlock() - - // Send exec message on chain B — the interop filter validates the access list - execMsg := bob.SendExecMessage(initMsg) - - // Verify cross-safe safety passes for both messages - dsl.CheckAll(t, - sys.L2ACL.ReachedRefFn(suptypes.CrossSafe, initMsg.BlockID(), 500), - sys.L2BCL.ReachedRefFn(suptypes.CrossSafe, execMsg.BlockID(), 500), - ) -} - // TestInteropFilter_IngressRejectsInvalid verifies that a transaction with fabricated // CrossL2Inbox access list entries is rejected by the interop filter. func TestInteropFilter_IngressRejectsInvalid(gt *testing.T) { @@ -133,10 +89,10 @@ func TestInteropFilter_FailsafeLifecycle(gt *testing.T) { require.Equal(types.ReceiptStatusSuccessful, execMsg.Receipt.Status, "interop tx should succeed before failsafe") - // Step 2: Enable failsafe and wait for propagation to op-reth + // Step 2: Enable failsafe — no wait needed; even if op-reth hasn't polled + // the change yet, checkAccessList will reject interop txs on the filter side. require.NotNil(sys.InteropFilter, "interop filter must be configured") sys.InteropFilter.SetFailsafeEnabled(true) - waitForFailsafeState(t, sys, true) // Step 3: Send another init message and try exec — should fail initMsg2 := alice.SendInitMessage(interop.RandomInitTrigger(rng, eventLoggerAddress, 1, 5)) @@ -169,21 +125,17 @@ func TestInteropFilter_FailsafeLifecycle(gt *testing.T) { _, err = tx.Submitted.Eval(ctx) require.Error(err, "interop tx should be rejected during failsafe") - require.Contains(err.Error(), "interop failsafe is active", - "expected failsafe rejection, got: %v", err) - // Step 4: Disable failsafe and wait for propagation + // Step 4: Disable failsafe and wait one block for op-reth's poller to pick up the change sys.InteropFilter.SetFailsafeEnabled(false) - waitForFailsafeState(t, sys, false) - - // Step 5: Verify interop txs recover — retry to tolerate propagation lag - err = retry.Do0(context.Background(), 5, &retry.FixedStrategy{Dur: 2 * time.Second}, func() error { - initMsg3 := alice.SendInitMessage(interop.RandomInitTrigger(rng, eventLoggerAddress, 1, 3)) - sys.L2B.WaitForBlock() - _ = bob.SendExecMessage(initMsg3) - return nil - }) - require.NoError(err, "interop flow should recover after failsafe disabled") + sys.L2B.WaitForBlock() + + // Step 5: Verify interop txs recover after failsafe is disabled + initMsg3 := alice.SendInitMessage(interop.RandomInitTrigger(rng, eventLoggerAddress, 1, 3)) + sys.L2B.WaitForBlock() + execMsg2 := bob.SendExecMessage(initMsg3) + require.Equal(types.ReceiptStatusSuccessful, execMsg2.Receipt.Status, + "interop tx should succeed after failsafe disabled") } // TestInteropFilter_NonInteropUnaffected verifies that regular (non-interop) @@ -198,10 +150,9 @@ func TestInteropFilter_NonInteropUnaffected(gt *testing.T) { aliceB := sys.FunderB.NewFundedEOA(eth.OneHundredthEther) bobB := sys.FunderB.NewFundedEOA(eth.OneHundredthEther) - // Enable failsafe and wait for propagation + // Enable failsafe — takes effect immediately on the filter side require.NotNil(sys.InteropFilter, "interop filter must be configured") sys.InteropFilter.SetFailsafeEnabled(true) - waitForFailsafeState(t, sys, true) // Send regular (non-interop) transfers on both chains — should succeed even during failsafe txA := aliceA.Transfer(bobA.Address(), eth.GWei(1000)) diff --git a/op-acceptance-tests/tests/interop/message/interop_happy_tx_test.go b/op-acceptance-tests/tests/interop/message/interop_happy_tx_test.go index f3e361f6996..d24b2325a97 100644 --- a/op-acceptance-tests/tests/interop/message/interop_happy_tx_test.go +++ b/op-acceptance-tests/tests/interop/message/interop_happy_tx_test.go @@ -19,7 +19,7 @@ import ( func TestInteropHappyTx(gt *testing.T) { gt.Skip("Skipping Interop Acceptance Test") t := devtest.ParallelT(gt) - sys := presets.NewTwoL2SupernodeInterop(t, 0) + sys := presets.NewTwoL2SupernodeInterop(t, 0, presets.WithInteropFilter()) // two EOAs for triggering the init and exec interop txs alice := sys.FunderA.NewFundedEOA(eth.OneHundredthEther) From 3f035ea38462b75d33a4867a141b680b9f48f570 Mon Sep 17 00:00:00 2001 From: wwared <541936+wwared@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:49:33 +0000 Subject: [PATCH 13/14] fix(op-acceptance-tests): accept interop rejection errors from any EL backend Add interopTxRejectedError helper that matches rejection messages from op-geth ("transaction filtered out"), op-reth ("interop failsafe is active"), and op-interop-filter ("failed to parse access entry", "failsafe is enabled"). Use it in both IngressRejectsInvalid and FailsafeLifecycle tests so they pass regardless of which component rejects the transaction. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../interop/filter/interop_filter_test.go | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/op-acceptance-tests/tests/interop/filter/interop_filter_test.go b/op-acceptance-tests/tests/interop/filter/interop_filter_test.go index 36e9cf38b9d..c031365e4f1 100644 --- a/op-acceptance-tests/tests/interop/filter/interop_filter_test.go +++ b/op-acceptance-tests/tests/interop/filter/interop_filter_test.go @@ -3,6 +3,7 @@ package filter import ( "context" "math/rand" + "strings" "testing" "time" @@ -23,6 +24,29 @@ func setupInteropFilterTest(t devtest.T) *presets.TwoL2SupernodeInterop { return presets.NewTwoL2SupernodeInterop(t, 0, presets.WithInteropFilter()) } +// interopTxRejectedError returns true if err matches any known interop +// transaction rejection from op-geth, op-reth, or the interop filter. +func interopTxRejectedError(err error) bool { + msg := err.Error() + // op-geth: generic filter rejection wrapping all causes + if strings.Contains(msg, "transaction filtered out") { + return true + } + // op-interop-filter: malformed or unrecognized access list entry + if strings.Contains(msg, "failed to parse access entry") { + return true + } + // op-reth fast-path: cached failsafe state rejects before calling filter + if strings.Contains(msg, "interop failsafe is active") { + return true + } + // op-interop-filter: failsafe enabled at the filter level + if strings.Contains(msg, "failsafe is enabled") { + return true + } + return false +} + // TestInteropFilter_IngressRejectsInvalid verifies that a transaction with fabricated // CrossL2Inbox access list entries is rejected by the interop filter. func TestInteropFilter_IngressRejectsInvalid(gt *testing.T) { @@ -63,7 +87,7 @@ func TestInteropFilter_IngressRejectsInvalid(gt *testing.T) { // The transaction should be explicitly rejected by the interop filter. _, err := tx.Submitted.Eval(ctx) require.Error(err, "transaction with fabricated access list should not be included") - require.Contains(err.Error(), "failed to parse access entry", + require.True(interopTxRejectedError(err), "expected interop filter rejection, got: %v", err) } @@ -125,6 +149,8 @@ func TestInteropFilter_FailsafeLifecycle(gt *testing.T) { _, err = tx.Submitted.Eval(ctx) require.Error(err, "interop tx should be rejected during failsafe") + require.True(interopTxRejectedError(err), + "expected interop filter rejection, got: %v", err) // Step 4: Disable failsafe and wait one block for op-reth's poller to pick up the change sys.InteropFilter.SetFailsafeEnabled(false) From 1be5f73e2588d3e87d7f1e0f0b8e3e93aa48c8ed Mon Sep 17 00:00:00 2001 From: wwared Date: Tue, 14 Apr 2026 17:11:53 -0300 Subject: [PATCH 14/14] Update multichain_supernode_runtime.go Fix formatting --- op-devstack/sysgo/multichain_supernode_runtime.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/op-devstack/sysgo/multichain_supernode_runtime.go b/op-devstack/sysgo/multichain_supernode_runtime.go index c17b6ca0d68..ced50e0570d 100644 --- a/op-devstack/sysgo/multichain_supernode_runtime.go +++ b/op-devstack/sysgo/multichain_supernode_runtime.go @@ -306,8 +306,8 @@ func newTwoL2SupernodeRuntimeWithConfig(t devtest.T, enableInterop bool, delaySe // The filter needs blocks to be produced before its chain ingesters can backfill. if interopFilter != nil { interopFilter.WaitForReady(t, 120*time.Second) - } - + } + // Use the potentially-overridden depSet (e.g. with custom message expiry window) // if available; otherwise fall back to the original from the world builder. var runtimeDepSet depset.DependencySet