@@ -15,9 +15,14 @@ import (
1515
1616 "github.com/ethereum/go-ethereum/common"
1717 "github.com/ethereum/go-ethereum/ethclient"
18+ "github.com/rs/zerolog"
1819 "github.com/stretchr/testify/require"
1920
21+ "github.com/evstack/ev-node/block"
2022 "github.com/evstack/ev-node/execution/evm"
23+ "github.com/evstack/ev-node/pkg/config"
24+ blobrpc "github.com/evstack/ev-node/pkg/da/jsonrpc"
25+ da "github.com/evstack/ev-node/pkg/da/types"
2126)
2227
2328// enableForceInclusionInGenesis modifies the genesis file to set the force inclusion epoch
@@ -119,6 +124,8 @@ func setupSequencerWithForceInclusion(t *testing.T, sut *SystemUnderTest, nodeHo
119124}
120125
121126func TestEvmSequencerForceInclusionE2E (t * testing.T ) {
127+ t .Skip () // To re-enable after https://github.com/evstack/ev-node/issues/2965
128+
122129 sut := NewSystemUnderTest (t )
123130 workDir := t .TempDir ()
124131 sequencerHome := filepath .Join (workDir , "sequencer" )
@@ -170,6 +177,8 @@ func TestEvmSequencerForceInclusionE2E(t *testing.T) {
170177}
171178
172179func TestEvmFullNodeForceInclusionE2E (t * testing.T ) {
180+ t .Skip () // To re-enable after https://github.com/evstack/ev-node/issues/2965
181+
173182 sut := NewSystemUnderTest (t )
174183 workDir := t .TempDir ()
175184 sequencerHome := filepath .Join (workDir , "sequencer" )
@@ -268,3 +277,287 @@ func TestEvmFullNodeForceInclusionE2E(t *testing.T) {
268277
269278 t .Log ("Forced inclusion tx synced to full node successfully" )
270279}
280+
281+ // setupMaliciousSequencer sets up a sequencer that listens to the WRONG forced inclusion namespace.
282+ // This simulates a malicious sequencer that doesn't retrieve forced inclusion txs from the correct namespace.
283+ func setupMaliciousSequencer (t * testing.T , sut * SystemUnderTest , nodeHome string ) (string , string , * TestEndpoints ) {
284+ t .Helper ()
285+
286+ // Use common setup with full node support
287+ jwtSecret , _ , genesisHash , endpoints := setupCommonEVMTest (t , sut , true )
288+
289+ passphraseFile := createPassphraseFile (t , nodeHome )
290+ jwtSecretFile := createJWTSecretFile (t , nodeHome , jwtSecret )
291+
292+ output , err := sut .RunCmd (evmSingleBinaryPath ,
293+ "init" ,
294+ "--evnode.node.aggregator=true" ,
295+ "--evnode.signer.passphrase_file" , passphraseFile ,
296+ "--home" , nodeHome ,
297+ )
298+ require .NoError (t , err , "failed to init sequencer" , output )
299+
300+ // Set epoch to 2 for fast testing (force inclusion must happen within 2 DA blocks)
301+ enableForceInclusionInGenesis (t , nodeHome , 2 )
302+
303+ seqArgs := []string {
304+ "start" ,
305+ "--evm.jwt-secret-file" , jwtSecretFile ,
306+ "--evm.genesis-hash" , genesisHash ,
307+ "--evnode.node.block_time" , DefaultBlockTime ,
308+ "--evnode.node.aggregator=true" ,
309+ "--evnode.signer.passphrase_file" , passphraseFile ,
310+ "--home" , nodeHome ,
311+ "--evnode.da.block_time" , DefaultDABlockTime ,
312+ "--evnode.da.address" , endpoints .GetDAAddress (),
313+ "--evnode.da.namespace" , DefaultDANamespace ,
314+ // CRITICAL: Set sequencer to listen to WRONG namespace - it won't see forced txs
315+ "--evnode.da.forced_inclusion_namespace" , "wrong-namespace" ,
316+ "--evnode.rpc.address" , endpoints .GetRollkitRPCListen (),
317+ "--evnode.p2p.listen_address" , endpoints .GetRollkitP2PAddress (),
318+ "--evm.engine-url" , endpoints .GetSequencerEngineURL (),
319+ "--evm.eth-url" , endpoints .GetSequencerEthURL (),
320+ }
321+ sut .ExecCmd (evmSingleBinaryPath , seqArgs ... )
322+ sut .AwaitNodeUp (t , endpoints .GetRollkitRPCAddress (), NodeStartupTimeout )
323+
324+ return genesisHash , jwtSecret , endpoints
325+ }
326+
327+ // setupFullNodeWithForceInclusionCheck sets up a full node that WILL verify forced inclusion txs
328+ // by reading from DA. This node will detect when the sequencer maliciously skips forced txs.
329+ // The key difference from standard setupFullNode is that we explicitly add the forced_inclusion_namespace flag.
330+ func setupFullNodeWithForceInclusionCheck (t * testing.T , sut * SystemUnderTest , fullNodeHome , sequencerHome , jwtSecret , genesisHash , sequencerP2PAddr string , endpoints * TestEndpoints ) {
331+ t .Helper ()
332+
333+ // Initialize full node
334+ output , err := sut .RunCmd (evmSingleBinaryPath ,
335+ "init" ,
336+ "--home" , fullNodeHome ,
337+ )
338+ require .NoError (t , err , "failed to init full node" , output )
339+
340+ // Copy genesis file from sequencer to full node
341+ MustCopyFile (t , filepath .Join (sequencerHome , "config" , "genesis.json" ), filepath .Join (fullNodeHome , "config" , "genesis.json" ))
342+
343+ // Create JWT secret file for full node
344+ jwtSecretFile := createJWTSecretFile (t , fullNodeHome , jwtSecret )
345+
346+ // Get sequencer's peer ID for P2P connection
347+ sequencerID := NodeID (t , sequencerHome )
348+
349+ // Start full node WITH forced_inclusion_namespace configured
350+ // This allows it to retrieve forced txs from DA and detect when they're missing from blocks
351+ fnArgs := []string {
352+ "start" ,
353+ "--evm.jwt-secret-file" , jwtSecretFile ,
354+ "--evm.genesis-hash" , genesisHash ,
355+ "--evnode.node.block_time" , DefaultBlockTime ,
356+ "--home" , fullNodeHome ,
357+ "--evnode.da.block_time" , DefaultDABlockTime ,
358+ "--evnode.da.address" , endpoints .GetDAAddress (),
359+ "--evnode.da.namespace" , DefaultDANamespace ,
360+ "--evnode.da.forced_inclusion_namespace" , "forced-inc" , // Enables forced inclusion verification
361+ "--evnode.rpc.address" , endpoints .GetFullNodeRPCListen (),
362+ "--rollkit.p2p.listen_address" , endpoints .GetFullNodeP2PAddress (),
363+ "--rollkit.p2p.seeds" , fmt .Sprintf ("%s@%s" , sequencerID , sequencerP2PAddr ),
364+ "--evm.engine-url" , endpoints .GetFullNodeEngineURL (),
365+ "--evm.eth-url" , endpoints .GetFullNodeEthURL (),
366+ }
367+ sut .ExecCmd (evmSingleBinaryPath , fnArgs ... )
368+ sut .AwaitNodeLive (t , endpoints .GetFullNodeRPCAddress (), NodeStartupTimeout )
369+ }
370+
371+ // TestEvmSyncerMaliciousSequencerForceInclusionE2E tests that a sync node gracefully stops
372+ // when it detects that the sequencer maliciously failed to include a forced inclusion transaction.
373+ //
374+ // This test validates the critical security property that sync nodes can detect and respond to
375+ // malicious/misconfigured sequencer behavior regarding forced inclusion transactions.
376+ //
377+ // Test Architecture:
378+ // - Malicious Sequencer: Configured with WRONG forced_inclusion_namespace ("wrong-namespace")
379+ // - Does NOT retrieve forced inclusion txs from correct namespace
380+ // - Simulates a censoring sequencer ignoring forced inclusion
381+ //
382+ // - Honest Sync Node: Configured with CORRECT forced_inclusion_namespace ("forced-inc")
383+ // - Retrieves forced inclusion txs from DA
384+ // - Compares them against blocks received from sequencer
385+ // - Detects when forced txs are missing beyond the grace period
386+ //
387+ // Test Flow:
388+ // 1. Start malicious sequencer (listening to wrong namespace)
389+ // 2. Start sync node that validates forced inclusion (correct namespace)
390+ // 3. Submit forced inclusion tx directly to DA on correct namespace
391+ // 4. Sequencer produces blocks WITHOUT the forced tx (doesn't see it)
392+ // 5. Sync node detects violation after grace period expires
393+ // 6. Sync node stops syncing (in production, would halt with error)
394+ //
395+ // Key Configuration:
396+ // - da_epoch_forced_inclusion: 2 (forced txs must be included within 2 DA blocks)
397+ // - Grace period: Additional buffer for network delays and block fullness
398+ //
399+ // Expected Outcome:
400+ // - Forced tx appears in DA but NOT in sequencer's blocks
401+ // - Sync node stops advancing its block height
402+ // - In production: sync node logs "SEQUENCER IS MALICIOUS" and exits gracefully
403+ //
404+ // Note: This test simulates the scenario by having the sequencer configured to
405+ // listen to the wrong namespace, while we submit directly to the correct namespace.
406+ func TestEvmSyncerMaliciousSequencerForceInclusionE2E (t * testing.T ) {
407+ t .Skip () // To re-enable after https://github.com/evstack/ev-node/issues/2965
408+
409+ sut := NewSystemUnderTest (t )
410+ workDir := t .TempDir ()
411+ sequencerHome := filepath .Join (workDir , "sequencer" )
412+ fullNodeHome := filepath .Join (workDir , "fullnode" )
413+
414+ // Setup malicious sequencer (listening to wrong forced inclusion namespace)
415+ genesisHash , fullNodeJwtSecret , endpoints := setupMaliciousSequencer (t , sut , sequencerHome )
416+ t .Log ("Malicious sequencer started listening to WRONG forced inclusion namespace" )
417+ t .Log ("NOTE: Sequencer listens to 'wrong-namespace', won't see txs on 'forced-inc'" )
418+
419+ // Setup full node that will sync from the sequencer and verify forced inclusion
420+ setupFullNodeWithForceInclusionCheck (t , sut , fullNodeHome , sequencerHome , fullNodeJwtSecret , genesisHash , endpoints .GetRollkitP2PAddress (), endpoints )
421+ t .Log ("Full node (syncer) is up and will verify forced inclusion from DA" )
422+
423+ // Connect to clients
424+ seqClient , err := ethclient .Dial (endpoints .GetSequencerEthURL ())
425+ require .NoError (t , err )
426+ defer seqClient .Close ()
427+
428+ fnClient , err := ethclient .Dial (endpoints .GetFullNodeEthURL ())
429+ require .NoError (t , err )
430+ defer fnClient .Close ()
431+
432+ var nonce uint64 = 0
433+
434+ // 1. Send a normal transaction first to ensure chain is moving
435+ t .Log ("Sending normal transaction to establish baseline..." )
436+ txNormal := evm .GetRandomTransaction (t , TestPrivateKey , TestToAddress , DefaultChainID , DefaultGasLimit , & nonce )
437+ err = seqClient .SendTransaction (context .Background (), txNormal )
438+ require .NoError (t , err )
439+
440+ // Wait for full node to sync it
441+ require .Eventually (t , func () bool {
442+ return evm .CheckTxIncluded (fnClient , txNormal .Hash ())
443+ }, 20 * time .Second , 500 * time .Millisecond , "Normal tx not synced to full node" )
444+ t .Log ("Normal tx synced successfully" )
445+
446+ // 2. Submit forced inclusion transaction directly to DA (correct namespace)
447+ t .Log ("Submitting forced inclusion transaction directly to DA on correct namespace..." )
448+ txForce := evm .GetRandomTransaction (t , TestPrivateKey , TestToAddress , DefaultChainID , DefaultGasLimit , & nonce )
449+ txBytes , err := txForce .MarshalBinary ()
450+ require .NoError (t , err )
451+
452+ // Create a blobrpc client and DA client to submit directly to the correct forced inclusion namespace
453+ // The sequencer is listening to "wrong-namespace" so it won't see this
454+ ctx := context .Background ()
455+ blobClient , err := blobrpc .NewClient (ctx , endpoints .GetDAAddress (), "" , "" )
456+ require .NoError (t , err , "Failed to create blob RPC client" )
457+ defer blobClient .Close ()
458+
459+ daClient := block .NewDAClient (
460+ blobClient ,
461+ config.Config {
462+ DA : config.DAConfig {
463+ Namespace : "evm-e2e" ,
464+ DataNamespace : "evm-e2e-data" ,
465+ ForcedInclusionNamespace : "forced-inc" , // Correct namespace
466+ },
467+ },
468+ zerolog .Nop (),
469+ )
470+
471+ // Submit transaction to DA on the forced inclusion namespace
472+ result := daClient .Submit (ctx , [][]byte {txBytes }, - 1 , []byte ("forced-inc" ), nil )
473+ require .Equal (t , da .StatusSuccess , result .Code , "Failed to submit to DA: %s" , result .Message )
474+ t .Logf ("Forced inclusion transaction submitted to DA: %s" , txForce .Hash ().Hex ())
475+
476+ // 3. Wait a moment for the forced tx to be written to DA
477+ time .Sleep (1 * time .Second )
478+
479+ // 4. The malicious sequencer will NOT include the forced transaction in blocks
480+ // because it's listening to "wrong-namespace" instead of "forced-inc"
481+ // Send normal transactions to advance the chain past the epoch boundary and grace period.
482+ t .Log ("Advancing chain to trigger malicious behavior detection..." )
483+ for i := 0 ; i < 15 ; i ++ {
484+ txExtra := evm .GetRandomTransaction (t , TestPrivateKey , TestToAddress , DefaultChainID , DefaultGasLimit , & nonce )
485+ err = seqClient .SendTransaction (context .Background (), txExtra )
486+ require .NoError (t , err )
487+ time .Sleep (400 * time .Millisecond )
488+ }
489+
490+ // 5. The sync node should detect the malicious behavior and stop syncing
491+ // With epoch=2, after ~2 DA blocks the forced tx should be included
492+ // The grace period gives some buffer, but eventually the violation is detected
493+ t .Log ("Monitoring sync node for malicious behavior detection..." )
494+
495+ // Track whether the sync node stops advancing
496+ var lastHeight uint64
497+ var lastSeqHeight uint64
498+ stoppedSyncing := false
499+ consecutiveStops := 0
500+
501+ // Monitor for up to 60 seconds
502+ for i := 0 ; i < 120 ; i ++ {
503+ time .Sleep (500 * time .Millisecond )
504+
505+ // Verify forced tx is NOT on the malicious sequencer
506+ if evm .CheckTxIncluded (seqClient , txForce .Hash ()) {
507+ t .Fatal ("Malicious sequencer incorrectly included the forced tx" )
508+ }
509+
510+ // Get sequencer height
511+ seqHeader , seqErr := seqClient .HeaderByNumber (context .Background (), nil )
512+ if seqErr == nil {
513+ seqHeight := seqHeader .Number .Uint64 ()
514+ if seqHeight > lastSeqHeight {
515+ t .Logf ("Sequencer height: %d (was: %d) - producing blocks" , seqHeight , lastSeqHeight )
516+ lastSeqHeight = seqHeight
517+ }
518+ }
519+
520+ // Get full node height
521+ fnHeader , fnErr := fnClient .HeaderByNumber (context .Background (), nil )
522+ if fnErr != nil {
523+ t .Logf ("Full node error (may have stopped): %v" , fnErr )
524+ stoppedSyncing = true
525+ break
526+ }
527+
528+ currentHeight := fnHeader .Number .Uint64 ()
529+
530+ // Check if sync node stopped advancing
531+ if lastHeight > 0 && currentHeight == lastHeight {
532+ consecutiveStops ++
533+ t .Logf ("Full node height unchanged at %d (count: %d)" , currentHeight , consecutiveStops )
534+
535+ // If height hasn't changed for 10 consecutive checks (~5s), it's stopped
536+ if consecutiveStops >= 10 {
537+ t .Log ("✅ Full node stopped syncing - malicious behavior detected!" )
538+ stoppedSyncing = true
539+ break
540+ }
541+ } else if currentHeight > lastHeight {
542+ consecutiveStops = 0
543+ t .Logf ("Full node height: %d (was: %d)" , currentHeight , lastHeight )
544+ }
545+
546+ lastHeight = currentHeight
547+
548+ // Log gap between sequencer and sync node
549+ if seqErr == nil && lastSeqHeight > currentHeight {
550+ gap := lastSeqHeight - currentHeight
551+ if gap > 10 {
552+ t .Logf ("⚠️ Sync node falling behind - gap: %d blocks" , gap )
553+ }
554+ }
555+ }
556+
557+ // Verify expected behavior
558+ require .True (t , stoppedSyncing ,
559+ "Sync node should have stopped syncing after detecting malicious behavior" )
560+
561+ require .False (t , evm .CheckTxIncluded (seqClient , txForce .Hash ()),
562+ "Malicious sequencer should NOT have included the forced inclusion transaction" )
563+ }
0 commit comments