Skip to content

Commit 014510b

Browse files
committed
align timestamping
1 parent 42f0405 commit 014510b

File tree

2 files changed

+212
-3
lines changed

2 files changed

+212
-3
lines changed

pkg/sequencers/single/sequencer.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -363,11 +363,14 @@ func (c *Sequencer) GetNextBatch(ctx context.Context, req coresequencer.GetNextB
363363
batchTxs = append(batchTxs, validMempoolTxs...)
364364

365365
// During catch-up, use the DA epoch end timestamp to match based sequencing behavior.
366-
// This ensures blocks produced during catch-up have timestamps consistent with
367-
// what base sequencing nodes would have produced.
366+
// Replicates based sequencing nodes' behavior of timestamping blocks during catchingUp.
368367
timestamp := time.Now()
369368
if c.catchingUp && !c.currentDAEndTime.IsZero() {
370-
timestamp = c.currentDAEndTime
369+
var remainingForcedTxs uint64
370+
if len(c.cachedForcedInclusionTxs) > 0 {
371+
remainingForcedTxs = uint64(len(c.cachedForcedInclusionTxs)) - c.checkpoint.TxIndex
372+
}
373+
timestamp = c.currentDAEndTime.Add(-time.Duration(remainingForcedTxs) * time.Millisecond)
371374
}
372375

373376
return &coresequencer.GetNextBatchResponse{

pkg/sequencers/single/sequencer_test.go

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
19122118
func TestSequencer_GetNextBatch_GasFilteringPreservesUnprocessedTxs(t *testing.T) {
19132119
db := ds.NewMapDatastore()
19142120
logger := zerolog.New(zerolog.NewTestWriter(t))

0 commit comments

Comments
 (0)