Skip to content

Commit c963984

Browse files
committed
fetch DA height
1 parent 17fcc48 commit c963984

File tree

9 files changed

+248
-123
lines changed

9 files changed

+248
-123
lines changed

apps/evm/server/force_inclusion_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ func (m *mockDA) HasForcedInclusionNamespace() bool {
7373
return true
7474
}
7575

76+
func (m *mockDA) GetLatestDAHeight(_ context.Context) (uint64, error) {
77+
return 0, nil
78+
}
79+
7680
func TestForceInclusionServer_handleSendRawTransaction_Success(t *testing.T) {
7781
testHeight := uint64(100)
7882

block/internal/da/client.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,23 @@ func (c *client) Retrieve(ctx context.Context, height uint64, namespace []byte)
299299
}
300300
}
301301

302+
// GetLatestDAHeight returns the latest height available on the DA layer by
303+
// querying the network head.
304+
func (c *client) GetLatestDAHeight(ctx context.Context) (uint64, error) {
305+
headCtx, cancel := context.WithTimeout(ctx, c.defaultTimeout)
306+
defer cancel()
307+
308+
header, err := c.headerAPI.NetworkHead(headCtx)
309+
if err != nil {
310+
return 0, fmt.Errorf("failed to get DA network head: %w", err)
311+
}
312+
if header == nil {
313+
return 0, fmt.Errorf("DA network head returned nil header")
314+
}
315+
316+
return header.Height, nil
317+
}
318+
302319
// RetrieveForcedInclusion retrieves blobs from the forced inclusion namespace at the specified height.
303320
func (c *client) RetrieveForcedInclusion(ctx context.Context, height uint64) datypes.ResultRetrieve {
304321
if !c.hasForcedNamespace {

block/internal/da/interface.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ type Client interface {
1717
// Get retrieves blobs by their IDs. Used for visualization and fetching specific blobs.
1818
Get(ctx context.Context, ids []datypes.ID, namespace []byte) ([]datypes.Blob, error)
1919

20+
// GetLatestDAHeight returns the latest height available on the DA layer..
21+
GetLatestDAHeight(ctx context.Context) (uint64, error)
22+
2023
// Namespace accessors.
2124
GetHeaderNamespace() []byte
2225
GetDataNamespace() []byte

block/internal/da/tracing.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,20 @@ func (t *tracedClient) Validate(ctx context.Context, ids []datypes.ID, proofs []
123123
return res, nil
124124
}
125125

126+
func (t *tracedClient) GetLatestDAHeight(ctx context.Context) (uint64, error) {
127+
ctx, span := t.tracer.Start(ctx, "DA.GetLatestDAHeight")
128+
defer span.End()
129+
130+
height, err := t.inner.GetLatestDAHeight(ctx)
131+
if err != nil {
132+
span.RecordError(err)
133+
span.SetStatus(codes.Error, err.Error())
134+
return 0, err
135+
}
136+
span.SetAttributes(attribute.Int64("da.latest_height", int64(height)))
137+
return height, nil
138+
}
139+
126140
func (t *tracedClient) GetHeaderNamespace() []byte { return t.inner.GetHeaderNamespace() }
127141
func (t *tracedClient) GetDataNamespace() []byte { return t.inner.GetDataNamespace() }
128142
func (t *tracedClient) GetForcedInclusionNamespace() []byte {

block/internal/da/tracing_test.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,11 @@ func (m *mockFullClient) Validate(ctx context.Context, ids []datypes.ID, proofs
5454
}
5555
return nil, nil
5656
}
57-
func (m *mockFullClient) GetHeaderNamespace() []byte { return []byte{0x01} }
58-
func (m *mockFullClient) GetDataNamespace() []byte { return []byte{0x02} }
59-
func (m *mockFullClient) GetForcedInclusionNamespace() []byte { return []byte{0x03} }
60-
func (m *mockFullClient) HasForcedInclusionNamespace() bool { return true }
57+
func (m *mockFullClient) GetLatestDAHeight(_ context.Context) (uint64, error) { return 0, nil }
58+
func (m *mockFullClient) GetHeaderNamespace() []byte { return []byte{0x01} }
59+
func (m *mockFullClient) GetDataNamespace() []byte { return []byte{0x02} }
60+
func (m *mockFullClient) GetForcedInclusionNamespace() []byte { return []byte{0x03} }
61+
func (m *mockFullClient) HasForcedInclusionNamespace() bool { return true }
6162

6263
// setup a tracer provider + span recorder
6364
func setupDATrace(t *testing.T, inner FullClient) (FullClient, *tracetest.SpanRecorder) {

pkg/sequencers/single/sequencer.go

Lines changed: 72 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/evstack/ev-node/pkg/genesis"
2222
seqcommon "github.com/evstack/ev-node/pkg/sequencers/common"
2323
"github.com/evstack/ev-node/pkg/store"
24+
"github.com/evstack/ev-node/types"
2425
)
2526

2627
// ErrInvalidId is returned when the chain id is invalid
@@ -57,6 +58,11 @@ type Sequencer struct {
5758
// inclusion transactions, no mempool) before resuming normal sequencing.
5859
// This ensures the sequencer produces the same blocks that nodes running in
5960
// base sequencing mode would have produced during the downtime.
61+
//
62+
// catchingUp is true when the sequencer is replaying missed DA epochs.
63+
// It is set when we detect (via GetLatestDAHeight) that the DA layer is more
64+
// than one epoch ahead of our checkpoint, and cleared when we hit
65+
// ErrHeightFromFuture (meaning we've reached the DA head).
6066
catchingUp bool
6167
// currentDAEndTime is the DA epoch end timestamp from the last fetched epoch.
6268
// Used as the block timestamp during catch-up to match based sequencing behavior.
@@ -421,14 +427,20 @@ func (c *Sequencer) IsCatchingUp() bool {
421427
}
422428

423429
// fetchNextDAEpoch fetches transactions from the next DA epoch using checkpoint.
424-
// It also updates the catch-up state based on the DA epoch timestamp:
425-
// - If the fetched epoch's timestamp is significantly in the past (more than
426-
// one epoch's wall-clock duration), the sequencer enters catch-up mode.
430+
// It also updates the catch-up state based on DA heights:
431+
// - Before the first fetch, it queries GetLatestDAHeight to determine if the
432+
// sequencer has missed more than one DA epoch. If so, catch-up mode is
433+
// entered and only forced-inclusion blocks (no mempool) are produced.
427434
// - If the DA height is from the future (not yet produced), the sequencer
428435
// exits catch-up mode as it has reached the DA head.
429436
func (c *Sequencer) fetchNextDAEpoch(ctx context.Context, maxBytes uint64) (uint64, error) {
430437
currentDAHeight := c.checkpoint.DAHeight
431438

439+
// Determine catch-up state before the (potentially expensive) epoch fetch.
440+
// This is done once per sequencer lifecycle — subsequent catch-up exits are
441+
// handled by ErrHeightFromFuture below.
442+
c.updateCatchUpState(ctx)
443+
432444
c.logger.Debug().
433445
Uint64("da_height", currentDAHeight).
434446
Uint64("tx_index", c.checkpoint.TxIndex).
@@ -466,11 +478,6 @@ func (c *Sequencer) fetchNextDAEpoch(ctx context.Context, maxBytes uint64) (uint
466478
c.currentDAEndTime = forcedTxsEvent.Timestamp.UTC()
467479
}
468480

469-
// Determine catch-up state based on epoch timestamp.
470-
// If the epoch we just fetched ended more than one epoch's wall-clock duration ago,
471-
// we are behind the DA head and must catch up by replaying missed epochs.
472-
c.updateCatchUpState(forcedTxsEvent)
473-
474481
// Validate and filter transactions
475482
validTxs := make([][]byte, 0, len(forcedTxsEvent.Txs))
476483
skippedTxs := 0
@@ -501,60 +508,75 @@ func (c *Sequencer) fetchNextDAEpoch(ctx context.Context, maxBytes uint64) (uint
501508
return forcedTxsEvent.EndDaHeight, nil
502509
}
503510

504-
// updateCatchUpState determines whether the sequencer is catching up to the DA head.
511+
// updateCatchUpState determines whether the sequencer needs to catch up to the
512+
// DA head by comparing the sequencer's checkpoint DA height against the latest
513+
// DA height.
505514
//
506-
// The sequencer is considered to be catching up when the DA epoch it just fetched
507-
// has a timestamp that is significantly in the past — specifically, more than one
508-
// full epoch's wall-clock duration ago. This means other nodes likely switched to
509-
// base sequencing during the sequencer's downtime, and the sequencer must replay
510-
// those missed epochs before resuming normal block production.
515+
// The detection is purely height-based: we query GetLatestDAHeight once (on the
516+
// first epoch fetch) and calculate how many epochs the sequencer has missed. If
517+
// the gap exceeds one epoch, the sequencer enters catch-up mode and replays
518+
// missed epochs with forced-inclusion transactions only (no mempool). It remains
519+
// in catch-up until fetchNextDAEpoch hits ErrHeightFromFuture, meaning we've
520+
// reached the DA head.
511521
//
512-
// When the epoch timestamp is recent (within one epoch duration), the sequencer
513-
// has reached the DA head and can resume normal operation.
514-
func (c *Sequencer) updateCatchUpState(event *block.ForcedInclusionEvent) {
515-
if event == nil || event.Timestamp.IsZero() {
516-
// No timestamp available (e.g., empty epoch) — don't change catch-up state.
517-
// If we were already catching up, we remain in that state until we see a
518-
// recent timestamp or hit HeightFromFuture.
522+
// This check is performed only once per sequencer lifecycle. If the downtime was
523+
// short enough that the sequencer is still within the current or next epoch, no
524+
// catch-up is needed and the (lightweight) GetLatestDAHeight call is the only
525+
// overhead.
526+
func (c *Sequencer) updateCatchUpState(ctx context.Context) {
527+
// Already catching up — nothing to do. We'll exit via ErrHeightFromFuture.
528+
if c.catchingUp {
519529
return
520530
}
521531

522-
if c.genesis.DAEpochForcedInclusion == 0 {
532+
epochSize := c.genesis.DAEpochForcedInclusion
533+
if epochSize == 0 {
523534
// No epoch-based forced inclusion configured — catch-up is irrelevant.
524-
c.catchingUp = false
525535
return
526536
}
527537

528-
// Calculate how long one DA epoch takes in wall-clock time.
529-
epochWallDuration := time.Duration(c.genesis.DAEpochForcedInclusion) * c.cfg.DA.BlockTime.Duration
538+
currentDAHeight := c.checkpoint.DAHeight
539+
daStartHeight := c.genesis.DAStartHeight
540+
541+
latestDAHeight, err := c.daClient.GetLatestDAHeight(ctx)
542+
if err != nil {
543+
c.logger.Warn().Err(err).
544+
Msg("failed to get latest DA height for catch-up detection, skipping check")
545+
return
546+
}
530547

531-
// Use a minimum threshold to avoid false positives from minor delays.
532-
catchUpThreshold := epochWallDuration
533-
if catchUpThreshold < 30*time.Second {
534-
catchUpThreshold = 30 * time.Second
548+
if latestDAHeight <= currentDAHeight {
549+
// DA hasn't moved beyond our position — nothing to catch up.
550+
c.logger.Debug().
551+
Uint64("checkpoint_da_height", currentDAHeight).
552+
Uint64("latest_da_height", latestDAHeight).
553+
Msg("sequencer is at or ahead of DA head, no catch-up needed")
554+
return
535555
}
536556

537-
timeSinceEpoch := time.Since(event.Timestamp)
538-
wasCatchingUp := c.catchingUp
557+
// Calculate epoch numbers for current position and DA head.
558+
currentEpoch := types.CalculateEpochNumber(currentDAHeight, daStartHeight, epochSize)
559+
latestEpoch := types.CalculateEpochNumber(latestDAHeight, daStartHeight, epochSize)
560+
missedEpochs := latestEpoch - currentEpoch
539561

540-
if timeSinceEpoch > catchUpThreshold {
541-
c.catchingUp = true
542-
if !wasCatchingUp {
543-
c.logger.Warn().
544-
Dur("time_since_epoch", timeSinceEpoch).
545-
Dur("threshold", catchUpThreshold).
546-
Uint64("epoch_start", event.StartDaHeight).
547-
Uint64("epoch_end", event.EndDaHeight).
548-
Msg("entering catch-up mode: DA epoch is behind head, replaying missed epochs with forced inclusion txs only")
549-
}
550-
} else {
551-
c.catchingUp = false
552-
if wasCatchingUp {
553-
c.logger.Info().
554-
Dur("time_since_epoch", timeSinceEpoch).
555-
Uint64("epoch_start", event.StartDaHeight).
556-
Uint64("epoch_end", event.EndDaHeight).
557-
Msg("exiting catch-up mode: reached DA head, resuming normal sequencing")
558-
}
562+
if missedEpochs <= 1 {
563+
// Within the current or next epoch — normal operation, no catch-up.
564+
c.logger.Debug().
565+
Uint64("checkpoint_da_height", currentDAHeight).
566+
Uint64("latest_da_height", latestDAHeight).
567+
Uint64("current_epoch", currentEpoch).
568+
Uint64("latest_epoch", latestEpoch).
569+
Msg("sequencer within one epoch of DA head, no catch-up needed")
570+
return
559571
}
572+
573+
// The DA layer is more than one epoch ahead. Enter catch-up mode.
574+
c.catchingUp = true
575+
c.logger.Warn().
576+
Uint64("checkpoint_da_height", currentDAHeight).
577+
Uint64("latest_da_height", latestDAHeight).
578+
Uint64("current_epoch", currentEpoch).
579+
Uint64("latest_epoch", latestEpoch).
580+
Uint64("missed_epochs", missedEpochs).
581+
Msg("entering catch-up mode: DA layer is multiple epochs ahead, replaying missed epochs with forced inclusion txs only")
560582
}

0 commit comments

Comments
 (0)