Skip to content

Commit 53932ef

Browse files
committed
test: add e2e tests for force inclusion (part 2)
1 parent ddf8f31 commit 53932ef

File tree

1 file changed

+287
-0
lines changed

1 file changed

+287
-0
lines changed

test/e2e/evm_force_inclusion_e2e_test.go

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)