Skip to content

Commit a680309

Browse files
committed
feat(syncing): add grace period for missing force txs inclusion
1 parent f1aa2cb commit a680309

File tree

5 files changed

+381
-124
lines changed

5 files changed

+381
-124
lines changed

block/internal/common/metrics.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ type Metrics struct {
6565
DAInclusionHeight metrics.Gauge
6666
PendingHeadersCount metrics.Gauge
6767
PendingDataCount metrics.Gauge
68+
69+
// Forced inclusion metrics
70+
ForcedInclusionTxsInGracePeriod metrics.Gauge // Number of forced inclusion txs currently in grace period
71+
ForcedInclusionTxsMalicious metrics.Counter // Total number of forced inclusion txs marked as malicious
6872
}
6973

7074
// PrometheusMetrics returns Metrics built using Prometheus client library
@@ -182,6 +186,21 @@ func PrometheusMetrics(namespace string, labelsAndValues ...string) *Metrics {
182186
Help: "Number of data blocks pending DA submission",
183187
}, labels).With(labelsAndValues...)
184188

189+
// Forced inclusion metrics
190+
m.ForcedInclusionTxsInGracePeriod = prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{
191+
Namespace: namespace,
192+
Subsystem: MetricsSubsystem,
193+
Name: "forced_inclusion_txs_in_grace_period",
194+
Help: "Number of forced inclusion transactions currently in grace period (past epoch end but within grace boundary)",
195+
}, labels).With(labelsAndValues...)
196+
197+
m.ForcedInclusionTxsMalicious = prometheus.NewCounterFrom(stdprometheus.CounterOpts{
198+
Namespace: namespace,
199+
Subsystem: MetricsSubsystem,
200+
Name: "forced_inclusion_txs_malicious_total",
201+
Help: "Total number of forced inclusion transactions marked as malicious (past grace boundary)",
202+
}, labels).With(labelsAndValues...)
203+
185204
// DA Submitter metrics
186205
m.DASubmitterPendingBlobs = prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{
187206
Namespace: namespace,
@@ -246,6 +265,10 @@ func NopMetrics() *Metrics {
246265
DASubmitterLastFailure: make(map[DASubmitterFailureReason]metrics.Gauge),
247266
DASubmitterPendingBlobs: discard.NewGauge(),
248267
DASubmitterResends: discard.NewCounter(),
268+
269+
// Forced inclusion metrics
270+
ForcedInclusionTxsInGracePeriod: discard.NewGauge(),
271+
ForcedInclusionTxsMalicious: discard.NewCounter(),
249272
}
250273

251274
// Initialize maps with no-op metrics

block/internal/syncing/syncer.go

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -741,16 +741,44 @@ func (s *Syncer) verifyForcedInclusionTxs(currentState types.State, data *types.
741741
}
742742

743743
// Check if we've moved past any epoch boundaries with pending txs
744+
// Grace period: Allow forced inclusion txs from epoch N to be included in epoch N+1, N+2, etc.
745+
// Only flag as malicious if past grace boundary to prevent false positives during DA unavailability.
744746
var maliciousTxs, remainingPending []pendingForcedInclusionTx
747+
var txsInGracePeriod int
745748
for _, pending := range stillPending {
746-
// If current DA height is past this epoch's end, these txs should have been included
747-
if currentState.DAHeight > pending.EpochEnd {
749+
// Calculate grace boundary: epoch end + (grace periods × epoch size)
750+
graceBoundary := pending.EpochEnd + (s.genesis.ForcedInclusionGracePeriod * s.genesis.DAEpochForcedInclusion)
751+
752+
// If current DA height is past the grace boundary, these txs should have been included
753+
if currentState.DAHeight > graceBoundary {
748754
maliciousTxs = append(maliciousTxs, pending)
755+
s.logger.Warn().
756+
Uint64("current_da_height", currentState.DAHeight).
757+
Uint64("epoch_end", pending.EpochEnd).
758+
Uint64("grace_boundary", graceBoundary).
759+
Uint64("grace_periods", s.genesis.ForcedInclusionGracePeriod).
760+
Str("tx_hash", pending.TxHash[:16]).
761+
Msg("forced inclusion transaction past grace boundary - marking as malicious")
749762
} else {
750763
remainingPending = append(remainingPending, pending)
764+
765+
// Track if we're in the grace period (past epoch end but within grace boundary)
766+
if currentState.DAHeight > pending.EpochEnd {
767+
txsInGracePeriod++
768+
s.logger.Info().
769+
Uint64("current_da_height", currentState.DAHeight).
770+
Uint64("epoch_end", pending.EpochEnd).
771+
Uint64("grace_boundary", graceBoundary).
772+
Uint64("grace_periods", s.genesis.ForcedInclusionGracePeriod).
773+
Str("tx_hash", pending.TxHash[:16]).
774+
Msg("forced inclusion transaction in grace period - not yet malicious")
775+
}
751776
}
752777
}
753778

779+
// Update metrics for grace period tracking
780+
s.metrics.ForcedInclusionTxsInGracePeriod.Set(float64(txsInGracePeriod))
781+
754782
// Update pending map - clear old entries and store only remaining pending
755783
s.pendingForcedInclusionTxs.Range(func(key, value any) bool {
756784
s.pendingForcedInclusionTxs.Delete(key)
@@ -760,14 +788,18 @@ func (s *Syncer) verifyForcedInclusionTxs(currentState types.State, data *types.
760788
s.pendingForcedInclusionTxs.Store(pending.TxHash, pending)
761789
}
762790

763-
// If there are transactions from past epochs that weren't included, sequencer is malicious
791+
// If there are transactions past grace boundary that weren't included, sequencer is malicious
764792
if len(maliciousTxs) > 0 {
793+
// Update metrics for malicious detection
794+
s.metrics.ForcedInclusionTxsMalicious.Add(float64(len(maliciousTxs)))
795+
765796
s.logger.Error().
766797
Uint64("height", data.Height()).
767798
Uint64("current_da_height", currentState.DAHeight).
768799
Int("malicious_count", len(maliciousTxs)).
769-
Msg("SEQUENCER IS MALICIOUS: forced inclusion transactions from past epoch(s) not included")
770-
return errors.Join(errMaliciousProposer, fmt.Errorf("sequencer is malicious: %d forced inclusion transactions from past epoch(s) not included", len(maliciousTxs)))
800+
Uint64("grace_periods", s.genesis.ForcedInclusionGracePeriod).
801+
Msg("SEQUENCER IS MALICIOUS: forced inclusion transactions past grace boundary not included")
802+
return errors.Join(errMaliciousProposer, fmt.Errorf("sequencer is malicious: %d forced inclusion transactions past grace boundary (grace_periods=%d) not included", len(maliciousTxs), s.genesis.ForcedInclusionGracePeriod))
771803
}
772804

773805
// Log current state

block/internal/syncing/syncer_forced_inclusion_test.go

Lines changed: 135 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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

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

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

Comments
 (0)