@@ -1909,6 +1909,212 @@ func TestSequencer_CatchUp_CheckpointAdvancesDuringCatchUp(t *testing.T) {
19091909 assert .Equal (t , uint64 (102 ), seq .GetDAHeight ())
19101910}
19111911
1912+ func TestSequencer_CatchUp_MonotonicTimestamps (t * testing.T ) {
1913+ // When a single DA epoch has more forced txs than fit in one block,
1914+ // catch-up must produce strictly monotonic timestamps across the
1915+ // resulting blocks. This uses the same jitter scheme as the based
1916+ // sequencer: timestamp = DAEndTime - (remainingForcedTxs * 1ms).
1917+ ctx := context .Background ()
1918+ logger := zerolog .New (zerolog .NewConsoleWriter ())
1919+
1920+ db := ds .NewMapDatastore ()
1921+ defer db .Close ()
1922+
1923+ mockDA := newMockFullDAClient (t )
1924+ forcedInclusionNS := []byte ("forced-inclusion" )
1925+
1926+ mockDA .MockClient .On ("GetHeaderNamespace" ).Return ([]byte ("header" )).Maybe ()
1927+ mockDA .MockClient .On ("GetDataNamespace" ).Return ([]byte ("data" )).Maybe ()
1928+ mockDA .MockClient .On ("GetForcedInclusionNamespace" ).Return (forcedInclusionNS ).Maybe ()
1929+ mockDA .MockClient .On ("HasForcedInclusionNamespace" ).Return (true ).Maybe ()
1930+
1931+ // DA head is far ahead — triggers catch-up
1932+ mockDA .MockClient .On ("GetLatestDAHeight" , mock .Anything ).Return (uint64 (110 ), nil ).Once ()
1933+
1934+ // Epoch at height 100: 3 forced txs, each 100 bytes
1935+ epochTimestamp := time .Date (2025 , 1 , 1 , 12 , 0 , 0 , 0 , time .UTC )
1936+ tx1 := make ([]byte , 100 )
1937+ tx2 := make ([]byte , 100 )
1938+ tx3 := make ([]byte , 100 )
1939+ copy (tx1 , "forced-tx-1" )
1940+ copy (tx2 , "forced-tx-2" )
1941+ copy (tx3 , "forced-tx-3" )
1942+ mockDA .MockClient .On ("Retrieve" , mock .Anything , uint64 (100 ), forcedInclusionNS ).Return (datypes.ResultRetrieve {
1943+ BaseResult : datypes.BaseResult {Code : datypes .StatusSuccess , Timestamp : epochTimestamp },
1944+ Data : [][]byte {tx1 , tx2 , tx3 },
1945+ }).Once ()
1946+
1947+ // Epoch at height 101: single tx (to verify cross-epoch monotonicity)
1948+ epoch2Timestamp := time .Date (2025 , 1 , 1 , 12 , 0 , 10 , 0 , time .UTC ) // 10 seconds later
1949+ mockDA .MockClient .On ("Retrieve" , mock .Anything , uint64 (101 ), forcedInclusionNS ).Return (datypes.ResultRetrieve {
1950+ BaseResult : datypes.BaseResult {Code : datypes .StatusSuccess , Timestamp : epoch2Timestamp },
1951+ Data : [][]byte {[]byte ("forced-tx-4" )},
1952+ }).Once ()
1953+
1954+ // Epoch 102: future — exits catch-up
1955+ mockDA .MockClient .On ("Retrieve" , mock .Anything , uint64 (102 ), forcedInclusionNS ).Return (datypes.ResultRetrieve {
1956+ BaseResult : datypes.BaseResult {Code : datypes .StatusHeightFromFuture },
1957+ }).Maybe ()
1958+
1959+ gen := genesis.Genesis {
1960+ ChainID : "test-chain" ,
1961+ DAStartHeight : 100 ,
1962+ DAEpochForcedInclusion : 1 ,
1963+ }
1964+
1965+ // Custom executor: only 1 tx fits per block (gas-limited)
1966+ mockExec := mocks .NewMockExecutor (t )
1967+ mockExec .On ("GetExecutionInfo" , mock .Anything ).Return (execution.ExecutionInfo {MaxGas : 1000000 }, nil ).Maybe ()
1968+ mockExec .On ("FilterTxs" , mock .Anything , mock .Anything , mock .Anything , mock .Anything , mock .Anything ).Return (
1969+ func (ctx context.Context , txs [][]byte , maxBytes , maxGas uint64 , hasForceIncludedTransaction bool ) []execution.FilterStatus {
1970+ result := make ([]execution.FilterStatus , len (txs ))
1971+ // Only first tx fits, rest are postponed
1972+ for i := range result {
1973+ if i == 0 {
1974+ result [i ] = execution .FilterOK
1975+ } else {
1976+ result [i ] = execution .FilterPostpone
1977+ }
1978+ }
1979+ return result
1980+ },
1981+ nil ,
1982+ ).Maybe ()
1983+
1984+ seq , err := NewSequencer (
1985+ logger ,
1986+ db ,
1987+ mockDA ,
1988+ config .DefaultConfig (),
1989+ []byte ("test-chain" ),
1990+ 1000 ,
1991+ gen ,
1992+ mockExec ,
1993+ )
1994+ require .NoError (t , err )
1995+
1996+ req := coresequencer.GetNextBatchRequest {
1997+ Id : []byte ("test-chain" ),
1998+ MaxBytes : 1000000 ,
1999+ LastBatchData : nil ,
2000+ }
2001+
2002+ // Produce 3 blocks from epoch 100 (1 tx each due to gas filter)
2003+ var timestamps []time.Time
2004+ for i := 0 ; i < 3 ; i ++ {
2005+ resp , err := seq .GetNextBatch (ctx , req )
2006+ require .NoError (t , err )
2007+ assert .True (t , seq .IsCatchingUp (), "should be catching up during block %d" , i )
2008+ assert .Equal (t , 1 , len (resp .Batch .Transactions ), "block %d: exactly 1 forced tx" , i )
2009+ timestamps = append (timestamps , resp .Timestamp )
2010+ }
2011+
2012+ // All 3 timestamps must be strictly monotonically increasing
2013+ for i := 1 ; i < len (timestamps ); i ++ {
2014+ assert .True (t , timestamps [i ].After (timestamps [i - 1 ]),
2015+ "timestamp[%d] (%v) must be strictly after timestamp[%d] (%v)" ,
2016+ i , timestamps [i ], i - 1 , timestamps [i - 1 ])
2017+ }
2018+
2019+ // Verify exact jitter values:
2020+ // Block 0: 3 txs total, 1 consumed → 2 remaining → T - 2ms
2021+ // Block 1: 1 consumed → 1 remaining → T - 1ms
2022+ // Block 2: 1 consumed → 0 remaining → T
2023+ assert .Equal (t , epochTimestamp .Add (- 2 * time .Millisecond ), timestamps [0 ], "block 0: T - 2ms" )
2024+ assert .Equal (t , epochTimestamp .Add (- 1 * time .Millisecond ), timestamps [1 ], "block 1: T - 1ms" )
2025+ assert .Equal (t , epochTimestamp , timestamps [2 ], "block 2: T (exact epoch end time)" )
2026+
2027+ // Block from epoch 101 should also be monotonically after epoch 100's last block
2028+ resp4 , err := seq .GetNextBatch (ctx , req )
2029+ require .NoError (t , err )
2030+ assert .True (t , seq .IsCatchingUp (), "should still be catching up" )
2031+ assert .Equal (t , 1 , len (resp4 .Batch .Transactions ))
2032+ assert .True (t , resp4 .Timestamp .After (timestamps [2 ]),
2033+ "epoch 101 timestamp (%v) must be after epoch 100 last timestamp (%v)" ,
2034+ resp4 .Timestamp , timestamps [2 ])
2035+ assert .Equal (t , epoch2Timestamp , resp4 .Timestamp , "single-tx epoch gets exact DA end time" )
2036+ }
2037+
2038+ func TestSequencer_CatchUp_MonotonicTimestamps_EmptyEpoch (t * testing.T ) {
2039+ // Verify that an empty DA epoch (no forced txs) still advances the
2040+ // checkpoint and updates currentDAEndTime so subsequent epochs get
2041+ // correct timestamps.
2042+ ctx := context .Background ()
2043+
2044+ db := ds .NewMapDatastore ()
2045+ defer db .Close ()
2046+
2047+ mockDA := newMockFullDAClient (t )
2048+ forcedInclusionNS := []byte ("forced-inclusion" )
2049+
2050+ mockDA .MockClient .On ("GetHeaderNamespace" ).Return ([]byte ("header" )).Maybe ()
2051+ mockDA .MockClient .On ("GetDataNamespace" ).Return ([]byte ("data" )).Maybe ()
2052+ mockDA .MockClient .On ("GetForcedInclusionNamespace" ).Return (forcedInclusionNS ).Maybe ()
2053+ mockDA .MockClient .On ("HasForcedInclusionNamespace" ).Return (true ).Maybe ()
2054+
2055+ mockDA .MockClient .On ("GetLatestDAHeight" , mock .Anything ).Return (uint64 (110 ), nil ).Once ()
2056+
2057+ // Epoch 100: empty (no forced txs) but valid timestamp
2058+ emptyEpochTimestamp := time .Date (2025 , 1 , 1 , 12 , 0 , 0 , 0 , time .UTC )
2059+ mockDA .MockClient .On ("Retrieve" , mock .Anything , uint64 (100 ), forcedInclusionNS ).Return (datypes.ResultRetrieve {
2060+ BaseResult : datypes.BaseResult {Code : datypes .StatusSuccess , Timestamp : emptyEpochTimestamp },
2061+ Data : [][]byte {},
2062+ }).Once ()
2063+
2064+ // Epoch 101: has a forced tx with a later timestamp
2065+ epoch2Timestamp := time .Date (2025 , 1 , 1 , 12 , 0 , 15 , 0 , time .UTC )
2066+ mockDA .MockClient .On ("Retrieve" , mock .Anything , uint64 (101 ), forcedInclusionNS ).Return (datypes.ResultRetrieve {
2067+ BaseResult : datypes.BaseResult {Code : datypes .StatusSuccess , Timestamp : epoch2Timestamp },
2068+ Data : [][]byte {[]byte ("forced-tx-after-empty" )},
2069+ }).Once ()
2070+
2071+ // Epoch 102: future
2072+ mockDA .MockClient .On ("Retrieve" , mock .Anything , uint64 (102 ), forcedInclusionNS ).Return (datypes.ResultRetrieve {
2073+ BaseResult : datypes.BaseResult {Code : datypes .StatusHeightFromFuture },
2074+ }).Maybe ()
2075+
2076+ gen := genesis.Genesis {
2077+ ChainID : "test-chain" ,
2078+ DAStartHeight : 100 ,
2079+ DAEpochForcedInclusion : 1 ,
2080+ }
2081+
2082+ seq , err := NewSequencer (
2083+ zerolog .Nop (),
2084+ db ,
2085+ mockDA ,
2086+ config .DefaultConfig (),
2087+ []byte ("test-chain" ),
2088+ 1000 ,
2089+ gen ,
2090+ createDefaultMockExecutor (t ),
2091+ )
2092+ require .NoError (t , err )
2093+
2094+ req := coresequencer.GetNextBatchRequest {
2095+ Id : []byte ("test-chain" ),
2096+ MaxBytes : 1000000 ,
2097+ LastBatchData : nil ,
2098+ }
2099+
2100+ // First call processes the empty epoch 100 — empty batch, but checkpoint advances
2101+ resp1 , err := seq .GetNextBatch (ctx , req )
2102+ require .NoError (t , err )
2103+ assert .True (t , seq .IsCatchingUp ())
2104+ assert .Equal (t , 0 , len (resp1 .Batch .Transactions ), "empty epoch should produce empty batch" )
2105+ assert .Equal (t , emptyEpochTimestamp , resp1 .Timestamp ,
2106+ "empty epoch batch should use epoch DA end time (0 remaining)" )
2107+
2108+ // Second call processes epoch 101 — should have later timestamp
2109+ resp2 , err := seq .GetNextBatch (ctx , req )
2110+ require .NoError (t , err )
2111+ assert .True (t , seq .IsCatchingUp ())
2112+ assert .Equal (t , 1 , len (resp2 .Batch .Transactions ))
2113+ assert .True (t , resp2 .Timestamp .After (resp1 .Timestamp ),
2114+ "epoch 101 timestamp (%v) must be after empty epoch 100 timestamp (%v)" ,
2115+ resp2 .Timestamp , resp1 .Timestamp )
2116+ }
2117+
19122118func TestSequencer_GetNextBatch_GasFilteringPreservesUnprocessedTxs (t * testing.T ) {
19132119 db := ds .NewMapDatastore ()
19142120 logger := zerolog .New (zerolog .NewTestWriter (t ))
0 commit comments