@@ -206,7 +206,7 @@ func TestVerifyForcedInclusionTxs_MissingTransactions(t *testing.T) {
206206 err = s .verifyForcedInclusionTxs (currentState , data2 )
207207 require .Error (t , err )
208208 require .Contains (t , err .Error (), "sequencer is malicious" )
209- require .Contains (t , err .Error (), "forced inclusion transactions from past epoch(s) not included " )
209+ require .Contains (t , err .Error (), "past grace boundary " )
210210}
211211
212212func TestVerifyForcedInclusionTxs_PartiallyIncluded (t * testing.T ) {
@@ -309,7 +309,7 @@ func TestVerifyForcedInclusionTxs_PartiallyIncluded(t *testing.T) {
309309 err = s .verifyForcedInclusionTxs (currentState , data2 )
310310 require .Error (t , err )
311311 require .Contains (t , err .Error (), "sequencer is malicious" )
312- require .Contains (t , err .Error (), "forced inclusion transactions from past epoch(s) not included " )
312+ require .Contains (t , err .Error (), "past grace boundary " )
313313}
314314
315315func TestVerifyForcedInclusionTxs_NoForcedTransactions (t * testing.T ) {
@@ -759,5 +759,137 @@ func TestVerifyForcedInclusionTxs_MaliciousAfterEpochEnd(t *testing.T) {
759759 err = s .verifyForcedInclusionTxs (currentState , data3 )
760760 require .Error (t , err )
761761 require .Contains (t , err .Error (), "sequencer is malicious" )
762- require .Contains (t , err .Error (), "forced inclusion transactions from past epoch(s) not included" )
762+ require .Contains (t , err .Error (), "past grace boundary" )
763+ }
764+
765+ // TestVerifyForcedInclusionTxs_SmoothingExceedsEpoch tests the critical scenario where
766+ // forced inclusion transactions cannot all be included before an epoch ends.
767+ // This demonstrates that the system correctly detects malicious behavior when
768+ // transactions remain pending after the epoch boundary.
769+ func TestVerifyForcedInclusionTxs_SmoothingExceedsEpoch (t * testing.T ) {
770+ ds := dssync .MutexWrap (datastore .NewMapDatastore ())
771+ st := store .New (ds )
772+
773+ cm , err := cache .NewCacheManager (config .DefaultConfig (), zerolog .Nop ())
774+ require .NoError (t , err )
775+
776+ addr , pub , signer := buildSyncTestSigner (t )
777+ gen := genesis.Genesis {
778+ ChainID : "tchain" ,
779+ InitialHeight : 1 ,
780+ StartTime : time .Now ().Add (- time .Second ),
781+ ProposerAddress : addr ,
782+ DAStartHeight : 100 ,
783+ DAEpochForcedInclusion : 3 , // Epoch: [100, 102]
784+ }
785+
786+ cfg := config .DefaultConfig ()
787+ cfg .DA .ForcedInclusionNamespace = "nsForcedInclusion"
788+
789+ mockExec := testmocks .NewMockExecutor (t )
790+ mockExec .EXPECT ().InitChain (mock .Anything , mock .Anything , uint64 (1 ), "tchain" ).
791+ Return ([]byte ("app0" ), uint64 (1024 ), nil ).Once ()
792+
793+ mockDA := testmocks .NewMockDA (t )
794+
795+ daClient := da .NewClient (da.Config {
796+ DA : mockDA ,
797+ Logger : zerolog .Nop (),
798+ Namespace : cfg .DA .Namespace ,
799+ DataNamespace : cfg .DA .DataNamespace ,
800+ ForcedInclusionNamespace : cfg .DA .ForcedInclusionNamespace ,
801+ })
802+ daRetriever := NewDARetriever (daClient , cm , gen , zerolog .Nop ())
803+ fiRetriever := da .NewForcedInclusionRetriever (daClient , gen , zerolog .Nop ())
804+
805+ s := NewSyncer (
806+ st ,
807+ mockExec ,
808+ daClient ,
809+ cm ,
810+ common .NopMetrics (),
811+ cfg ,
812+ gen ,
813+ common.NewMockBroadcaster [* types.SignedHeader ](t ),
814+ common.NewMockBroadcaster [* types.Data ](t ),
815+ zerolog .Nop (),
816+ common .DefaultBlockOptions (),
817+ make (chan error , 1 ),
818+ )
819+ s .daRetriever = daRetriever
820+ s .fiRetriever = fiRetriever
821+
822+ require .NoError (t , s .initializeState ())
823+ s .ctx = context .Background ()
824+
825+ namespaceForcedInclusionBz := coreda .NamespaceFromString (cfg .DA .GetForcedInclusionNamespace ()).Bytes ()
826+
827+ // Create 3 forced inclusion transactions
828+ dataBin1 , _ := makeSignedDataBytes (t , gen .ChainID , 10 , addr , pub , signer , 2 )
829+ dataBin2 , _ := makeSignedDataBytes (t , gen .ChainID , 11 , addr , pub , signer , 2 )
830+ dataBin3 , _ := makeSignedDataBytes (t , gen .ChainID , 12 , addr , pub , signer , 2 )
831+
832+ // Mock DA retrieval for Epoch 1: [100, 102]
833+ mockDA .EXPECT ().GetIDs (mock .Anything , uint64 (100 ), mock .MatchedBy (func (ns []byte ) bool {
834+ return bytes .Equal (ns , namespaceForcedInclusionBz )
835+ })).Return (& coreda.GetIDsResult {
836+ IDs : [][]byte {[]byte ("fi1" ), []byte ("fi2" ), []byte ("fi3" )},
837+ Timestamp : time .Now (),
838+ }, nil ).Once ()
839+
840+ mockDA .EXPECT ().Get (mock .Anything , mock .Anything , mock .MatchedBy (func (ns []byte ) bool {
841+ return bytes .Equal (ns , namespaceForcedInclusionBz )
842+ })).Return ([][]byte {dataBin1 , dataBin2 , dataBin3 }, nil ).Once ()
843+
844+ for height := uint64 (101 ); height <= 102 ; height ++ {
845+ mockDA .EXPECT ().GetIDs (mock .Anything , height , mock .MatchedBy (func (ns []byte ) bool {
846+ return bytes .Equal (ns , namespaceForcedInclusionBz )
847+ })).Return (& coreda.GetIDsResult {IDs : [][]byte {}, Timestamp : time .Now ()}, nil ).Once ()
848+ }
849+
850+ // Block at DA height 102 (epoch end): Only includes 2 of 3 txs
851+ // The third tx remains pending - legitimate within the epoch
852+ data1 := makeData (gen .ChainID , 1 , 2 )
853+ data1 .Txs [0 ] = types .Tx (dataBin1 )
854+ data1 .Txs [1 ] = types .Tx (dataBin2 )
855+
856+ currentState := s .GetLastState ()
857+ currentState .DAHeight = 102 // At epoch end
858+
859+ err = s .verifyForcedInclusionTxs (currentState , data1 )
860+ require .NoError (t , err , "smoothing within epoch should be allowed" )
861+
862+ // Verify 1 tx still pending
863+ pendingCount := 0
864+ s .pendingForcedInclusionTxs .Range (func (key , value any ) bool {
865+ pendingCount ++
866+ return true
867+ })
868+ require .Equal (t , 1 , pendingCount , "should have 1 pending forced inclusion tx" )
869+
870+ // === CRITICAL TEST: Move to next epoch WITHOUT including the pending tx ===
871+ // Mock DA for next epoch [103, 105] with no forced txs
872+ mockDA .EXPECT ().GetIDs (mock .Anything , uint64 (103 ), mock .MatchedBy (func (ns []byte ) bool {
873+ return bytes .Equal (ns , namespaceForcedInclusionBz )
874+ })).Return (& coreda.GetIDsResult {IDs : [][]byte {}, Timestamp : time .Now ()}, nil ).Once ()
875+
876+ mockDA .EXPECT ().GetIDs (mock .Anything , uint64 (104 ), mock .MatchedBy (func (ns []byte ) bool {
877+ return bytes .Equal (ns , namespaceForcedInclusionBz )
878+ })).Return (& coreda.GetIDsResult {IDs : [][]byte {}, Timestamp : time .Now ()}, nil ).Once ()
879+
880+ mockDA .EXPECT ().GetIDs (mock .Anything , uint64 (105 ), mock .MatchedBy (func (ns []byte ) bool {
881+ return bytes .Equal (ns , namespaceForcedInclusionBz )
882+ })).Return (& coreda.GetIDsResult {IDs : [][]byte {}, Timestamp : time .Now ()}, nil ).Once ()
883+
884+ // Block at DA height 105 (next epoch end): Doesn't include the pending tx
885+ data2 := makeData (gen .ChainID , 2 , 1 )
886+ data2 .Txs [0 ] = types .Tx ([]byte ("regular_tx_only" ))
887+
888+ currentState .DAHeight = 105 // Past previous epoch boundary [100, 102]
889+
890+ // Should FAIL - forced tx from previous epoch wasn't included before epoch ended
891+ err = s .verifyForcedInclusionTxs (currentState , data2 )
892+ require .Error (t , err , "should detect malicious sequencer when forced tx exceeds epoch" )
893+ require .Contains (t , err .Error (), "sequencer is malicious" )
894+ require .Contains (t , err .Error (), "past grace boundary" )
763895}
0 commit comments