Skip to content

Commit 7726a64

Browse files
authored
test: add e2e tests for force inclusion (part 2) (#2970)
Forgot to push this other AI created test in this PR: #2964. A bit verbose qua comments for my liking, but i've left it as it makes debugging easy.
1 parent 07dd5d1 commit 7726a64

File tree

3 files changed

+422
-3
lines changed

3 files changed

+422
-3
lines changed

test/e2e/evm_force_inclusion_e2e_test.go

Lines changed: 293 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
@@ -119,6 +124,8 @@ func setupSequencerWithForceInclusion(t *testing.T, sut *SystemUnderTest, nodeHo
119124
}
120125

121126
func 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

172179
func 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

Comments
 (0)