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