From 821a0efb2bf31aae2e54f5a310d78adad1de2ebd Mon Sep 17 00:00:00 2001 From: Cael Rowley Date: Mon, 4 May 2026 21:02:38 +0200 Subject: [PATCH 01/10] feat(syncing): detect sequencer double-signs and halt with persisted evidence --- block/internal/cache/generic_cache.go | 7 + block/internal/cache/generic_cache_test.go | 23 + block/internal/cache/manager.go | 101 ++- block/internal/cache/manager_test.go | 201 ++++++ block/internal/common/metrics.go | 13 + block/internal/syncing/da_retriever.go | 32 +- block/internal/syncing/da_retriever_test.go | 2 +- block/internal/syncing/doublesign.go | 179 +++++ .../syncing/doublesign_branches_test.go | 341 +++++++++ block/internal/syncing/doublesign_test.go | 671 ++++++++++++++++++ block/internal/syncing/p2p_handler.go | 48 +- .../syncing/p2p_handler_doublesign_test.go | 144 ++++ block/internal/syncing/p2p_handler_test.go | 2 +- block/internal/syncing/syncer.go | 20 +- .../syncing/syncer_forced_inclusion_test.go | 2 +- block/internal/syncing/syncer_test.go | 10 +- pkg/store/keys.go | 10 + proto/evnode/v1/evnode.proto | 12 + types/double_sign_evidence.go | 110 +++ types/pb/evnode/v1/evnode.pb.go | 118 ++- 20 files changed, 1993 insertions(+), 53 deletions(-) create mode 100644 block/internal/syncing/doublesign.go create mode 100644 block/internal/syncing/doublesign_branches_test.go create mode 100644 block/internal/syncing/doublesign_test.go create mode 100644 block/internal/syncing/p2p_handler_doublesign_test.go create mode 100644 types/double_sign_evidence.go diff --git a/block/internal/cache/generic_cache.go b/block/internal/cache/generic_cache.go index dc3e1b7d14..03b8042d03 100644 --- a/block/internal/cache/generic_cache.go +++ b/block/internal/cache/generic_cache.go @@ -113,6 +113,13 @@ func (c *Cache) setSeenBatch(hashes []string, height uint64) { } } +func (c *Cache) getHashByHeight(height uint64) (string, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + h, ok := c.hashByHeight[height] + return h, ok +} + func (c *Cache) getDAIncluded(hash string) (uint64, bool) { c.mu.RLock() defer c.mu.RUnlock() diff --git a/block/internal/cache/generic_cache_test.go b/block/internal/cache/generic_cache_test.go index ec4e92e7f7..d756681acd 100644 --- a/block/internal/cache/generic_cache_test.go +++ b/block/internal/cache/generic_cache_test.go @@ -391,3 +391,26 @@ func TestCache_DeleteAllForHeight_CleansHashAndDA(t *testing.T) { _, ok = c.getDAIncludedByHeight(2) assert.True(t, ok) } + +func TestCache_getHashByHeight(t *testing.T) { + c := NewCache(nil, "") + + h, ok := c.getHashByHeight(42) + assert.False(t, ok) + assert.Empty(t, h) + + c.setSeen("abc", 42) + h, ok = c.getHashByHeight(42) + assert.True(t, ok) + assert.Equal(t, "abc", h) + + // setDAIncluded also maintains hashByHeight. + c.setDAIncluded("def", 7, 100) + h, ok = c.getHashByHeight(100) + assert.True(t, ok) + assert.Equal(t, "def", h) + + c.deleteAllForHeight(42) + _, ok = c.getHashByHeight(42) + assert.False(t, ok) +} diff --git a/block/internal/cache/manager.go b/block/internal/cache/manager.go index 4d95a7d7e5..2a19f7104c 100644 --- a/block/internal/cache/manager.go +++ b/block/internal/cache/manager.go @@ -38,11 +38,17 @@ type CacheManager interface { // Header operations IsHeaderSeen(hash string) bool SetHeaderSeen(hash string, blockHeight uint64) + GetHeaderHashByHeight(blockHeight uint64) (string, bool) GetHeaderDAIncludedByHash(hash string) (uint64, bool) GetHeaderDAIncludedByHeight(blockHeight uint64) (uint64, bool) SetHeaderDAIncluded(hash string, daHeight uint64, blockHeight uint64) RemoveHeaderDAIncluded(hash string) + // Pending signed header operations (in-flight, pre-persistence) + SetPendingSignedHeader(h *types.SignedHeader, source string) + GetPendingSignedHeader(blockHeight uint64) (*types.SignedHeader, string, bool) + RemovePendingSignedHeader(blockHeight uint64) + // Data operations IsDataSeen(hash string) bool SetDataSeen(hash string, blockHeight uint64) @@ -92,17 +98,24 @@ type Manager interface { var _ Manager = (*implementation)(nil) type implementation struct { - headerCache *Cache - dataCache *Cache - txCache *Cache - txTimestamps *sync.Map // map[string]time.Time - pendingEvents map[uint64]*common.DAHeightEvent - pendingMu sync.Mutex - pendingHeaders *PendingHeaders - pendingData *PendingData - store store.Store - config config.Config - logger zerolog.Logger + headerCache *Cache + dataCache *Cache + txCache *Cache + txTimestamps *sync.Map // map[string]time.Time + pendingEvents map[uint64]*common.DAHeightEvent + pendingMu sync.Mutex + pendingHeaders *PendingHeaders + pendingData *PendingData + pendingSignedHeaders map[uint64]pendingSignedHeader + pendingSignedHeadersMu sync.RWMutex + store store.Store + config config.Config + logger zerolog.Logger +} + +type pendingSignedHeader struct { + header *types.SignedHeader + source string } // NewManager creates a new Manager, restoring or clearing persisted state as configured. @@ -122,16 +135,17 @@ func NewManager(cfg config.Config, st store.Store, logger zerolog.Logger) (Manag } impl := &implementation{ - headerCache: headerCache, - dataCache: dataCache, - txCache: txCache, - txTimestamps: new(sync.Map), - pendingEvents: make(map[uint64]*common.DAHeightEvent), - pendingHeaders: pendingHeaders, - pendingData: pendingData, - store: st, - config: cfg, - logger: logger, + headerCache: headerCache, + dataCache: dataCache, + txCache: txCache, + txTimestamps: new(sync.Map), + pendingEvents: make(map[uint64]*common.DAHeightEvent), + pendingHeaders: pendingHeaders, + pendingData: pendingData, + pendingSignedHeaders: make(map[uint64]pendingSignedHeader), + store: st, + config: cfg, + logger: logger, } if cfg.ClearCache { @@ -157,6 +171,11 @@ func (m *implementation) SetHeaderSeen(hash string, blockHeight uint64) { m.headerCache.setSeen(hash, blockHeight) } +// GetHeaderHashByHeight returns the first-seen header hash at the given height. +func (m *implementation) GetHeaderHashByHeight(blockHeight uint64) (string, bool) { + return m.headerCache.getHashByHeight(blockHeight) +} + func (m *implementation) GetHeaderDAIncludedByHash(hash string) (uint64, bool) { return m.headerCache.getDAIncluded(hash) } @@ -173,6 +192,42 @@ func (m *implementation) RemoveHeaderDAIncluded(hash string) { m.headerCache.removeDAIncluded(hash) } +// SetPendingSignedHeader records the first SignedHeader seen at this height. +// First-write-wins: later writes at the same height are ignored so the +// double-sign detector can match alternates against the original observation. +func (m *implementation) SetPendingSignedHeader(h *types.SignedHeader, source string) { + if h == nil { + return + } + height := h.Height() + m.pendingSignedHeadersMu.Lock() + defer m.pendingSignedHeadersMu.Unlock() + if _, exists := m.pendingSignedHeaders[height]; exists { + return + } + m.pendingSignedHeaders[height] = pendingSignedHeader{header: h, source: source} +} + +// GetPendingSignedHeader returns the first-seen SignedHeader and the source +// ("da" or "p2p") it was observed from. +func (m *implementation) GetPendingSignedHeader(blockHeight uint64) (*types.SignedHeader, string, bool) { + m.pendingSignedHeadersMu.RLock() + defer m.pendingSignedHeadersMu.RUnlock() + entry, ok := m.pendingSignedHeaders[blockHeight] + if !ok { + return nil, "", false + } + return entry.header, entry.source, true +} + +// RemovePendingSignedHeader evicts the entry once the height is persisted, so +// the store becomes the authoritative source for double-sign comparison. +func (m *implementation) RemovePendingSignedHeader(blockHeight uint64) { + m.pendingSignedHeadersMu.Lock() + delete(m.pendingSignedHeaders, blockHeight) + m.pendingSignedHeadersMu.Unlock() +} + // DaHeight returns the highest DA height seen across header and data caches. func (m *implementation) DaHeight() uint64 { return max(m.headerCache.daHeight(), m.dataCache.daHeight()) @@ -263,6 +318,7 @@ func (m *implementation) DeleteHeight(blockHeight uint64) { m.pendingMu.Lock() delete(m.pendingEvents, blockHeight) m.pendingMu.Unlock() + m.RemovePendingSignedHeader(blockHeight) // Note: txCache is intentionally NOT deleted here because: // 1. Transactions are tracked by hash, not by block height (they use height 0) @@ -408,6 +464,9 @@ func (m *implementation) ClearFromStore() error { m.dataCache = NewCache(m.store, DataDAIncludedPrefix) m.txCache = NewCache(nil, "") m.pendingEvents = make(map[uint64]*common.DAHeightEvent) + m.pendingSignedHeadersMu.Lock() + m.pendingSignedHeaders = make(map[uint64]pendingSignedHeader) + m.pendingSignedHeadersMu.Unlock() // Initialize DA height from store metadata to ensure DaHeight() is never 0. m.initDAHeightFromStore(ctx) diff --git a/block/internal/cache/manager_test.go b/block/internal/cache/manager_test.go index fa5aebf34b..2222ac6e6a 100644 --- a/block/internal/cache/manager_test.go +++ b/block/internal/cache/manager_test.go @@ -3,6 +3,8 @@ package cache import ( "context" "encoding/binary" + "sync" + "sync/atomic" "testing" "time" @@ -518,6 +520,205 @@ func TestManager_DaHeightAfterCacheClear(t *testing.T) { "DaHeight should be seeded from finalized-tip metadata even after ClearCache") } +// builds a minimal SignedHeader; variant differentiates hashes at the same height. +func signedHeaderForHeight(height uint64, variant byte) *types.SignedHeader { + return &types.SignedHeader{ + Header: types.Header{ + BaseHeader: types.BaseHeader{ChainID: "pending-signed", Height: height, Time: 1}, + AppHash: []byte{variant, variant, variant}, + }, + } +} + +func TestManager_PendingSignedHeader_FirstWriteWins(t *testing.T) { + t.Parallel() + cfg := tempConfig(t) + st := testMemStore(t) + + m, err := NewManager(cfg, st, zerolog.Nop()) + require.NoError(t, err) + + first := signedHeaderForHeight(5, 0x01) + second := signedHeaderForHeight(5, 0x02) + require.NotEqual(t, first.Hash().String(), second.Hash().String()) + + m.SetPendingSignedHeader(first, "p2p") + m.SetPendingSignedHeader(second, "da") + + got, source, ok := m.GetPendingSignedHeader(5) + require.True(t, ok) + require.Equal(t, first.Hash().String(), got.Hash().String()) + require.Equal(t, "p2p", source) +} + +func TestManager_PendingSignedHeader_NilHeaderIgnored(t *testing.T) { + t.Parallel() + cfg := tempConfig(t) + st := testMemStore(t) + + m, err := NewManager(cfg, st, zerolog.Nop()) + require.NoError(t, err) + + m.SetPendingSignedHeader(nil, "p2p") + _, _, ok := m.GetPendingSignedHeader(5) + require.False(t, ok) + + real := signedHeaderForHeight(5, 0x01) + m.SetPendingSignedHeader(real, "p2p") + got, _, ok := m.GetPendingSignedHeader(5) + require.True(t, ok) + require.Equal(t, real.Hash().String(), got.Hash().String()) +} + +func TestManager_GetPendingSignedHeader_Miss(t *testing.T) { + t.Parallel() + cfg := tempConfig(t) + st := testMemStore(t) + + m, err := NewManager(cfg, st, zerolog.Nop()) + require.NoError(t, err) + + hdr, source, ok := m.GetPendingSignedHeader(99) + require.False(t, ok) + require.Nil(t, hdr) + require.Empty(t, source) +} + +func TestManager_RemovePendingSignedHeader_Idempotent(t *testing.T) { + t.Parallel() + cfg := tempConfig(t) + st := testMemStore(t) + + m, err := NewManager(cfg, st, zerolog.Nop()) + require.NoError(t, err) + + require.NotPanics(t, func() { m.RemovePendingSignedHeader(123) }) + + hdr := signedHeaderForHeight(5, 0x01) + m.SetPendingSignedHeader(hdr, "p2p") + m.RemovePendingSignedHeader(5) + m.RemovePendingSignedHeader(5) + _, _, ok := m.GetPendingSignedHeader(5) + require.False(t, ok) +} + +func TestManager_DeleteHeight_EvictsPendingSignedHeader(t *testing.T) { + t.Parallel() + cfg := tempConfig(t) + st := testMemStore(t) + + m, err := NewManager(cfg, st, zerolog.Nop()) + require.NoError(t, err) + + hdr := signedHeaderForHeight(5, 0x01) + m.SetPendingSignedHeader(hdr, "p2p") + _, _, ok := m.GetPendingSignedHeader(5) + require.True(t, ok) + + m.DeleteHeight(5) + + _, _, ok = m.GetPendingSignedHeader(5) + require.False(t, ok) +} + +func TestManager_ClearFromStore_ResetsPendingSignedHeaders(t *testing.T) { + t.Parallel() + cfg := tempConfig(t) + st := testMemStore(t) + + m, err := NewManager(cfg, st, zerolog.Nop()) + require.NoError(t, err) + + m.SetPendingSignedHeader(signedHeaderForHeight(5, 0x01), "p2p") + m.SetPendingSignedHeader(signedHeaderForHeight(6, 0x02), "da") + + impl, ok := m.(*implementation) + require.True(t, ok) + require.NoError(t, impl.ClearFromStore()) + + for _, h := range []uint64{5, 6} { + _, _, present := m.GetPendingSignedHeader(h) + require.False(t, present, "pending entry at %d must be cleared", h) + } +} + +// Race-detector coverage for the pending-signed-header map. Run with -race. +func TestManager_PendingSignedHeader_Concurrency(t *testing.T) { + t.Parallel() + cfg := tempConfig(t) + st := testMemStore(t) + + m, err := NewManager(cfg, st, zerolog.Nop()) + require.NoError(t, err) + + const ( + writers = 8 + readers = 8 + removers = 4 + heightsPerRun = 200 + ) + + headers := make([]*types.SignedHeader, heightsPerRun) + for i := range headers { + headers[i] = signedHeaderForHeight(uint64(i+1), byte(i&0xff)) + } + + var ( + wg sync.WaitGroup + startCh = make(chan struct{}) + writerHit atomic.Int64 + readerHit atomic.Int64 + ) + + for w := range writers { + wg.Add(1) + go func(seed int) { + defer wg.Done() + <-startCh + for i := range heightsPerRun { + idx := (seed*7 + i) % heightsPerRun + m.SetPendingSignedHeader(headers[idx], "p2p") + writerHit.Add(1) + } + }(w) + } + + for r := range readers { + wg.Add(1) + go func(seed int) { + defer wg.Done() + <-startCh + for i := range heightsPerRun { + h := uint64((seed*11+i)%heightsPerRun + 1) + _, _, _ = m.GetPendingSignedHeader(h) + readerHit.Add(1) + } + }(r) + } + + for d := range removers { + wg.Add(1) + go func(seed int) { + defer wg.Done() + <-startCh + for i := range heightsPerRun { + h := uint64((seed*13+i)%heightsPerRun + 1) + if i%2 == 0 { + m.RemovePendingSignedHeader(h) + } else { + m.DeleteHeight(h) + } + } + }(d) + } + + close(startCh) + wg.Wait() + + require.Equal(t, int64(writers*heightsPerRun), writerHit.Load()) + require.Equal(t, int64(readers*heightsPerRun), readerHit.Load()) +} + func TestManager_DaHeightFromStoreOnRestore(t *testing.T) { t.Parallel() diff --git a/block/internal/common/metrics.go b/block/internal/common/metrics.go index 179157eb7e..a817af4be0 100644 --- a/block/internal/common/metrics.go +++ b/block/internal/common/metrics.go @@ -68,6 +68,9 @@ type Metrics struct { ForcedInclusionTxsInGracePeriod metrics.Gauge // Number of forced inclusion txs currently in grace period ForcedInclusionTxsMalicious metrics.Counter // Total number of forced inclusion txs marked as malicious + // Double-sign detection + DoubleSignsDetected metrics.Counter // Distinct (height, alternate-hash) pairs observed + // Syncer metrics BlocksSynchronized map[EventSource]metrics.Counter // Blocks synchronized by source (P2P or DA) } @@ -189,6 +192,13 @@ func PrometheusMetrics(namespace string, labelsAndValues ...string) *Metrics { Help: "Total number of forced inclusion transactions marked as malicious (past grace boundary)", }, labels).With(labelsAndValues...) + m.DoubleSignsDetected = prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, + Subsystem: MetricsSubsystem, + Name: "double_signs_detected_total", + Help: "Total number of distinct (height, alternate-hash) double-sign events observed", + }, labels).With(labelsAndValues...) + // DA Submitter metrics m.DASubmitterPendingBlobs = prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ Namespace: namespace, @@ -269,6 +279,9 @@ func NopMetrics() *Metrics { ForcedInclusionTxsInGracePeriod: discard.NewGauge(), ForcedInclusionTxsMalicious: discard.NewCounter(), + // Double-sign detection + DoubleSignsDetected: discard.NewCounter(), + // Syncer metrics BlocksSynchronized: make(map[EventSource]metrics.Counter), } diff --git a/block/internal/syncing/da_retriever.go b/block/internal/syncing/da_retriever.go index d4fa93ce04..ca488e07b7 100644 --- a/block/internal/syncing/da_retriever.go +++ b/block/internal/syncing/da_retriever.go @@ -15,6 +15,7 @@ import ( "github.com/evstack/ev-node/block/internal/da" datypes "github.com/evstack/ev-node/pkg/da/types" "github.com/evstack/ev-node/pkg/genesis" + "github.com/evstack/ev-node/pkg/store" "github.com/evstack/ev-node/types" pb "github.com/evstack/ev-node/types/pb/evnode/v1" ) @@ -34,6 +35,8 @@ type daRetriever struct { cache cache.CacheManager genesis genesis.Genesis logger zerolog.Logger + store store.Store + onDoubleSign doubleSignHandler // nil disables detection; the retriever aborts the batch on a positive mu sync.Mutex // transient cache, only full event need to be passed to the syncer @@ -46,18 +49,23 @@ type daRetriever struct { strictMode bool } -// NewDARetriever creates a new DA retriever +// NewDARetriever creates a new DA retriever. Double-sign detection is disabled +// when st or onDoubleSign is nil. func NewDARetriever( client da.Client, cache cache.CacheManager, genesis genesis.Genesis, logger zerolog.Logger, + st store.Store, + onDoubleSign doubleSignHandler, ) *daRetriever { return &daRetriever{ client: client, cache: cache, genesis: genesis, logger: logger.With().Str("component", "da_retriever").Logger(), + store: st, + onDoubleSign: onDoubleSign, pendingHeaders: make(map[uint64]*types.SignedHeader), pendingData: make(map[uint64]*types.Data), strictMode: false, @@ -172,9 +180,18 @@ func (r *daRetriever) processBlobs(ctx context.Context, blobs [][]byte, daHeight } if header := r.tryDecodeHeader(bz, daHeight); header != nil { + // Catches both in-batch alternates and alternates of already-persisted heights. + if r.store != nil && r.onDoubleSign != nil { + if ev, err := detectDoubleSign(ctx, r.store, r.cache, header, types.EvidenceSourceDA); err == nil && ev != nil { + r.onDoubleSign(ctx, ev) + return nil + } else if err != nil { + r.logger.Warn().Err(err).Uint64("height", header.Height()).Msg("double-sign detection error") + } + r.cache.SetPendingSignedHeader(header, types.EvidenceSourceDA) + } + if _, ok := r.pendingHeaders[header.Height()]; ok { - // a (malicious) node may have re-published valid header to another da height (should never happen) - // we can already discard it, only the first one is valid r.logger.Debug().Uint64("height", header.Height()).Uint64("da_height", daHeight).Msg("header blob already exists for height, discarding") continue } @@ -304,6 +321,15 @@ func (r *daRetriever) tryDecodeHeader(bz []byte, daHeight uint64) *types.SignedH return nil } + // Precondition for the double-sign detector: a forged blob must never + // reach the pending cache or be persisted as equivocation evidence. + // Required even in strict envelope mode — the inner SignedHeader + // signature is a separate commitment from the envelope signature. + if err := header.ValidateBasic(); err != nil { + r.logger.Debug().Err(err).Msg("signed header failed validation") + return nil + } + if isValidEnvelope && !r.strictMode { r.logger.Info().Uint64("height", header.Height()).Msg("valid DA envelope detected, switching to STRICT MODE") r.strictMode = true diff --git a/block/internal/syncing/da_retriever_test.go b/block/internal/syncing/da_retriever_test.go index 3b587def1f..fe7718eed6 100644 --- a/block/internal/syncing/da_retriever_test.go +++ b/block/internal/syncing/da_retriever_test.go @@ -52,7 +52,7 @@ func newTestDARetriever(t *testing.T, mockClient *mocks.MockClient, cfg config.C mockClient.On("GetForcedInclusionNamespace").Return([]byte(nil)).Maybe() mockClient.On("HasForcedInclusionNamespace").Return(false).Maybe() - return NewDARetriever(mockClient, cm, gen, zerolog.Nop()) + return NewDARetriever(mockClient, cm, gen, zerolog.Nop(), nil, nil) } // makeSignedDataBytes builds SignedData containing the provided Data and returns its binary encoding diff --git a/block/internal/syncing/doublesign.go b/block/internal/syncing/doublesign.go new file mode 100644 index 0000000000..65002cfe50 --- /dev/null +++ b/block/internal/syncing/doublesign.go @@ -0,0 +1,179 @@ +package syncing + +import ( + "bytes" + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/rs/zerolog" + + "github.com/evstack/ev-node/block/internal/cache" + "github.com/evstack/ev-node/block/internal/common" + "github.com/evstack/ev-node/pkg/store" + "github.com/evstack/ev-node/types" +) + +// ErrDoubleSign is returned when two validly-signed SignedHeaders are observed at the same height. +var ErrDoubleSign = errors.New("double-sign detected") + +// doubleSignHandler is fired when an equivocation is confirmed. It persists +// evidence, bumps metrics, and halts the syncer. +type doubleSignHandler func(ctx context.Context, evidence *types.DoubleSignEvidence) + +// detectDoubleSign compares incoming against the first-seen SignedHeader at +// the same height (cache then store) and returns non-nil evidence when their +// hashes differ. Caller must verify proposer + signature first. +func detectDoubleSign( + ctx context.Context, + st store.Store, + cm cache.CacheManager, + incoming *types.SignedHeader, + incomingSource string, +) (*types.DoubleSignEvidence, error) { + if incoming == nil { + return nil, errors.New("incoming header is nil") + } + height := incoming.Height() + + // Cache wins over store: the cached entry is the literal first observation + // and carries the original FirstSource. + if cached, source, ok := cm.GetPendingSignedHeader(height); ok { + return buildEvidenceFromPair(cached, incoming, source, incomingSource), nil + } + + storedHeader, storeErr := st.GetHeader(ctx, height) + if storeErr != nil { + if store.IsNotFound(storeErr) { + return nil, nil + } + return nil, fmt.Errorf("lookup stored header at %d: %w", height, storeErr) + } + if storedHeader == nil { + return nil, nil + } + return buildEvidenceFromPair(storedHeader, incoming, "stored", incomingSource), nil +} + +// buildEvidenceFromPair returns evidence for two SignedHeaders at the same +// height with different hashes and matching proposer. Returns nil otherwise. +func buildEvidenceFromPair(first, alternate *types.SignedHeader, firstSource, altSource string) *types.DoubleSignEvidence { + if first == nil || alternate == nil { + return nil + } + if first.Height() != alternate.Height() { + return nil + } + if bytes.Equal(first.Hash(), alternate.Hash()) { + return nil + } + if !bytes.Equal(first.ProposerAddress, alternate.ProposerAddress) { + return nil + } + return &types.DoubleSignEvidence{ + Height: first.Height(), + FirstHeader: first, + AlternateHeader: alternate, + DetectedAt: time.Now().UTC(), + FirstSource: firstSource, + AlternateSource: altSource, + } +} + +// persistEvidence writes evidence to its canonical metadata key. Idempotent. +func persistEvidence(ctx context.Context, st store.Store, ev *types.DoubleSignEvidence) error { + if err := ev.ValidateBasic(); err != nil { + return fmt.Errorf("invalid evidence: %w", err) + } + blob, err := ev.MarshalBinary() + if err != nil { + return fmt.Errorf("marshal evidence: %w", err) + } + altHash := ev.AlternateHeader.Hash() + key := store.GetDoubleSignEvidenceKey(ev.Height, altHash) + if err := st.SetMetadata(ctx, key, blob); err != nil { + return fmt.Errorf("persist evidence at %s: %w", key, err) + } + return nil +} + +// reportDoubleSign persists evidence, logs, bumps the metric (once per +// distinct alternate hash via seen), fires criticalErr, and returns the +// wrapped ErrDoubleSign for the caller to propagate as the halt cause. +func reportDoubleSign( + ctx context.Context, + st store.Store, + metrics *common.Metrics, + logger zerolog.Logger, + seen *doubleSignDedup, + criticalErr func(error), + ev *types.DoubleSignEvidence, +) error { + altHashStr := ev.AlternateHeader.Hash().String() + firstHashStr := ev.FirstHeader.Hash().String() + key := store.GetDoubleSignEvidenceKey(ev.Height, ev.AlternateHeader.Hash()) + + // Persist on every call: idempotent, and a retry covers a transient + // failure on the first attempt. + persistErr := persistEvidence(ctx, st, ev) + + if seen != nil && !seen.markSeen(ev.Height, altHashStr) { + return nil + } + + if persistErr != nil { + logger.Error().Err(persistErr). + Uint64("height", ev.Height). + Str("first_hash", firstHashStr). + Str("alternate_hash", altHashStr). + Msg("failed to persist double-sign evidence") + } + + if metrics != nil && metrics.DoubleSignsDetected != nil { + metrics.DoubleSignsDetected.Add(1) + } + + logger.Error(). + Uint64("height", ev.Height). + Str("first_hash", firstHashStr). + Str("first_source", ev.FirstSource). + Str("alternate_hash", altHashStr). + Str("alternate_source", ev.AlternateSource). + Str("evidence_key", key). + Msg("DOUBLE-SIGN DETECTED — sequencer equivocation; halting syncer") + + halt := fmt.Errorf( + "double-sign detected at height %d: sequencer signed conflicting headers %s and %s. "+ + "Evidence persisted at metadata key %s. Manual intervention required: %w", + ev.Height, firstHashStr, altHashStr, key, ErrDoubleSign, + ) + if criticalErr != nil { + criticalErr(halt) + } + return halt +} + +// doubleSignDedup collapses (height, altHash) duplicates so the same +// equivocation arriving from both P2P and DA is only reported once. +type doubleSignDedup struct { + mu sync.Mutex + seen map[string]struct{} +} + +func newDoubleSignDedup() *doubleSignDedup { + return &doubleSignDedup{seen: make(map[string]struct{})} +} + +// markSeen records (height, altHash) and returns true on first sight. +func (d *doubleSignDedup) markSeen(height uint64, altHash string) bool { + key := fmt.Sprintf("%d/%s", height, altHash) + d.mu.Lock() + defer d.mu.Unlock() + if _, ok := d.seen[key]; ok { + return false + } + d.seen[key] = struct{}{} + return true +} diff --git a/block/internal/syncing/doublesign_branches_test.go b/block/internal/syncing/doublesign_branches_test.go new file mode 100644 index 0000000000..4fcafeb6c5 --- /dev/null +++ b/block/internal/syncing/doublesign_branches_test.go @@ -0,0 +1,341 @@ +package syncing + +import ( + "context" + "errors" + "sync/atomic" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" + + "github.com/evstack/ev-node/block/internal/common" + "github.com/evstack/ev-node/block/internal/da" + "github.com/evstack/ev-node/pkg/store" + testmocks "github.com/evstack/ev-node/test/mocks" + "github.com/evstack/ev-node/types" + pb "github.com/evstack/ev-node/types/pb/evnode/v1" +) + +// DA client stub with shared namespace mocks. +func newMockDAClient(t *testing.T) da.Client { + t.Helper() + c := testmocks.NewMockClient(t) + c.On("GetHeaderNamespace").Return([]byte("ns")).Maybe() + c.On("GetDataNamespace").Return([]byte("ns")).Maybe() + return c +} + +// errStore wraps a store and injects errors on selected reads/writes to +// exercise error-handling branches an in-memory store can't hit. +type errStore struct { + store.Store + getHeaderErr error + setMetadataErr error +} + +func (e *errStore) GetHeader(ctx context.Context, height uint64) (*types.SignedHeader, error) { + if e.getHeaderErr != nil { + return nil, e.getHeaderErr + } + return e.Store.GetHeader(ctx, height) +} + +func (e *errStore) SetMetadata(ctx context.Context, key string, value []byte) error { + if e.setMetadataErr != nil { + return e.setMetadataErr + } + return e.Store.SetMetadata(ctx, key, value) +} + +// nilHeaderStore returns (nil, nil) from GetHeader; the detector must treat +// that as "no record" rather than crashing. +type nilHeaderStore struct{ store.Store } + +func (nilHeaderStore) GetHeader(context.Context, uint64) (*types.SignedHeader, error) { + return nil, nil +} + +func TestDetectDoubleSign_NilIncomingReturnsError(t *testing.T) { + env := newDSTestEnv(t) + ev, err := detectDoubleSign(context.Background(), env.store, env.cache, nil, types.EvidenceSourceP2P) + require.Error(t, err) + require.Nil(t, ev) +} + +// A non-NotFound store failure must be surfaced, not swallowed. +func TestDetectDoubleSign_StoreErrorWrapped(t *testing.T) { + env := newDSTestEnv(t) + wrapped := &errStore{Store: env.store, getHeaderErr: errors.New("backend down")} + + alt := env.signHeaderAtHeight(5, 0x01) + ev, err := detectDoubleSign(context.Background(), wrapped, env.cache, alt, types.EvidenceSourceP2P) + require.Error(t, err) + require.Nil(t, ev) + require.Contains(t, err.Error(), "lookup stored header") + require.ErrorContains(t, err, "backend down") +} + +func TestDetectDoubleSign_StoredHeaderNilDefensive(t *testing.T) { + env := newDSTestEnv(t) + wrapped := nilHeaderStore{Store: env.store} + + alt := env.signHeaderAtHeight(5, 0x01) + ev, err := detectDoubleSign(context.Background(), wrapped, env.cache, alt, types.EvidenceSourceP2P) + require.NoError(t, err) + require.Nil(t, ev) +} + +// Store-path detections must use the "stored" sentinel as FirstSource so +// downstream consumers can disambiguate it from in-flight observations. +func TestDetectDoubleSign_FirstSourceStoredSentinel(t *testing.T) { + env := newDSTestEnv(t) + first := env.signHeaderAtHeight(5, 0x01) + env.saveHeader(first) + + alt := env.signHeaderAtHeight(5, 0x02) + ev, err := detectDoubleSign(context.Background(), env.store, env.cache, alt, types.EvidenceSourceP2P) + require.NoError(t, err) + require.NotNil(t, ev) + require.Equal(t, "stored", ev.FirstSource) +} + +// SetMetadata failures must include the canonical key so an operator can +// recover the persistence target from logs alone. +func TestPersistEvidence_StoreError(t *testing.T) { + env := newDSTestEnv(t) + wrapped := &errStore{Store: env.store, setMetadataErr: errors.New("disk full")} + + first := env.signHeaderAtHeight(5, 0x01) + alt := env.signHeaderAtHeight(5, 0x02) + ev := buildEvidenceFromPair(first, alt, types.EvidenceSourceP2P, types.EvidenceSourceDA) + require.NotNil(t, ev) + + err := persistEvidence(context.Background(), wrapped, ev) + require.Error(t, err) + require.ErrorContains(t, err, "disk full") + require.Contains(t, err.Error(), store.GetDoubleSignEvidenceKey(ev.Height, ev.AlternateHeader.Hash())) +} + +// Persistence failure must not break the halt contract: metric still +// increments, criticalErr still fires, returned error still wraps ErrDoubleSign. +func TestReportDoubleSign_PersistFailureLoggedNotBlocking(t *testing.T) { + env := newDSTestEnv(t) + wrapped := &errStore{Store: env.store, setMetadataErr: errors.New("disk full")} + + first := env.signHeaderAtHeight(5, 0x01) + alt := env.signHeaderAtHeight(5, 0x02) + ev := buildEvidenceFromPair(first, alt, types.EvidenceSourceP2P, types.EvidenceSourceDA) + require.NotNil(t, ev) + + var dsCount atomic.Int64 + metrics := common.NopMetrics() + metrics.DoubleSignsDetected = &counterCtr{n: &dsCount} + + var fired atomic.Pointer[error] + crit := func(err error) { fired.Store(&err) } + + halt := reportDoubleSign(context.Background(), wrapped, metrics, zerolog.Nop(), + newDoubleSignDedup(), crit, ev) + require.Error(t, halt) + require.ErrorIs(t, halt, ErrDoubleSign) + + require.Equal(t, int64(1), dsCount.Load()) + require.NotNil(t, fired.Load()) +} + +// Dedup is keyed on (height, altHash), so two distinct alts at the same +// height must each produce evidence. +func TestReportDoubleSign_TwoDistinctAltsAtSameHeight(t *testing.T) { + env := newDSTestEnv(t) + first := env.signHeaderAtHeight(5, 0x01) + alt1 := env.signHeaderAtHeight(5, 0x02) + alt2 := env.signHeaderAtHeight(5, 0x03) + require.NotEqual(t, alt1.Hash().String(), alt2.Hash().String()) + + ev1 := buildEvidenceFromPair(first, alt1, types.EvidenceSourceP2P, types.EvidenceSourceDA) + ev2 := buildEvidenceFromPair(first, alt2, types.EvidenceSourceP2P, types.EvidenceSourceDA) + require.NotNil(t, ev1) + require.NotNil(t, ev2) + + var dsCount atomic.Int64 + metrics := common.NopMetrics() + metrics.DoubleSignsDetected = &counterCtr{n: &dsCount} + + seen := newDoubleSignDedup() + noopCrit := func(error) {} + + require.Error(t, reportDoubleSign(context.Background(), env.store, metrics, + zerolog.Nop(), seen, noopCrit, ev1)) + require.Error(t, reportDoubleSign(context.Background(), env.store, metrics, + zerolog.Nop(), seen, noopCrit, ev2)) + + require.Equal(t, int64(2), dsCount.Load()) + + for _, ev := range []*types.DoubleSignEvidence{ev1, ev2} { + key := store.GetDoubleSignEvidenceKey(ev.Height, ev.AlternateHeader.Hash()) + blob, err := env.store.GetMetadata(context.Background(), key) + require.NoError(t, err) + require.NotEmpty(t, blob) + } +} + +func TestReportDoubleSign_NilSeenAndNilGuards(t *testing.T) { + env := newDSTestEnv(t) + first := env.signHeaderAtHeight(5, 0x01) + alt := env.signHeaderAtHeight(5, 0x02) + ev := buildEvidenceFromPair(first, alt, types.EvidenceSourceP2P, types.EvidenceSourceDA) + require.NotNil(t, ev) + + t.Run("nil seen still halts", func(t *testing.T) { + halt := reportDoubleSign(context.Background(), env.store, common.NopMetrics(), + zerolog.Nop(), nil, func(error) {}, ev) + require.Error(t, halt) + require.ErrorIs(t, halt, ErrDoubleSign) + }) + + t.Run("nil metrics still halts", func(t *testing.T) { + halt := reportDoubleSign(context.Background(), env.store, nil, + zerolog.Nop(), newDoubleSignDedup(), func(error) {}, ev) + require.Error(t, halt) + require.ErrorIs(t, halt, ErrDoubleSign) + }) + + t.Run("nil counter inside metrics still halts", func(t *testing.T) { + m := common.NopMetrics() + m.DoubleSignsDetected = nil + halt := reportDoubleSign(context.Background(), env.store, m, + zerolog.Nop(), newDoubleSignDedup(), func(error) {}, ev) + require.Error(t, halt) + require.ErrorIs(t, halt, ErrDoubleSign) + }) + + t.Run("nil criticalErr still halts", func(t *testing.T) { + halt := reportDoubleSign(context.Background(), env.store, common.NopMetrics(), + zerolog.Nop(), newDoubleSignDedup(), nil, ev) + require.Error(t, halt) + require.ErrorIs(t, halt, ErrDoubleSign) + }) +} + +func TestDoubleSignEvidence_FromProtoNil(t *testing.T) { + dst := new(types.DoubleSignEvidence) + require.Error(t, dst.FromProto(nil)) +} + +func TestDoubleSignEvidence_FromProtoInnerHeaderError(t *testing.T) { + // Both inner SignedHeader fields nil — the wrapper must surface the error. + p := &pb.DoubleSignEvidence{Height: 5} + dst := new(types.DoubleSignEvidence) + require.Error(t, dst.FromProto(p)) +} + +func TestDoubleSignEvidence_UnmarshalBinaryGarbage(t *testing.T) { + dst := new(types.DoubleSignEvidence) + require.Error(t, dst.UnmarshalBinary([]byte{0xff, 0xff, 0xff, 0xff})) +} + +// Once equivocation is detected, the rest of the batch must be dropped. +func TestDARetriever_AbortsBatchOnDetection(t *testing.T) { + env := newDSTestEnv(t) + + first := env.signHeaderAtHeight(5, 0x01) + alt := env.signHeaderAtHeight(5, 0x02) + next := env.signHeaderAtHeight(6, 0x01) + + firstBin, err := first.MarshalBinary() + require.NoError(t, err) + altBin, err := alt.MarshalBinary() + require.NoError(t, err) + nextBin, err := next.MarshalBinary() + require.NoError(t, err) + + mockClient := newMockDAClient(t) + + r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.store, env.onDouble) + + events := r.ProcessBlobs(context.Background(), + [][]byte{firstBin, altBin, nextBin}, 100) + require.Empty(t, events) + require.NotContains(t, r.pendingHeaders, uint64(6)) +} + +// On a detector error, the retriever still caches the header so a later +// alternate can be matched once the store recovers. +func TestDARetriever_DetectorErrorWarnAndContinue(t *testing.T) { + env := newDSTestEnv(t) + wrapped := &errStore{Store: env.store, getHeaderErr: errors.New("flapping disk")} + + hdr := env.signHeaderAtHeight(5, 0x01) + bin, err := hdr.MarshalBinary() + require.NoError(t, err) + + mockClient := newMockDAClient(t) + + r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), wrapped, env.onDouble) + + _ = r.ProcessBlobs(context.Background(), [][]byte{bin}, 100) + + require.Empty(t, env.captured()) + + got, src, ok := env.cache.GetPendingSignedHeader(hdr.Height()) + require.True(t, ok) + require.Equal(t, hdr.Hash().String(), got.Hash().String()) + require.Equal(t, types.EvidenceSourceDA, src) +} + +// Double-sign detection through the envelope (strict-mode) path. +func TestDARetriever_StrictModeEnvelopeDoubleSign(t *testing.T) { + env := newDSTestEnv(t) + + first := env.signHeaderAtHeight(5, 0x01) + alt := env.signHeaderAtHeight(5, 0x02) + + mkEnvelope := func(h *types.SignedHeader) []byte { + content, err := h.MarshalBinary() + require.NoError(t, err) + envSig, err := env.signer.Sign(t.Context(), content) + require.NoError(t, err) + envBin, err := h.MarshalDAEnvelope(envSig) + require.NoError(t, err) + return envBin + } + + firstBin := mkEnvelope(first) + altBin := mkEnvelope(alt) + + mockClient := newMockDAClient(t) + + r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.store, env.onDouble) + + events := r.ProcessBlobs(context.Background(), [][]byte{firstBin, altBin}, 100) + require.Empty(t, events) + require.True(t, r.strictMode) + + captured := env.captured() + require.Len(t, captured, 1) + require.Equal(t, uint64(5), captured[0].Height) + require.Equal(t, types.EvidenceSourceDA, captured[0].FirstSource) + require.Equal(t, types.EvidenceSourceDA, captured[0].AlternateSource) +} + +// First DA observation must populate the pending cache so a later cross-source +// alternate can be matched against it. +func TestDARetriever_SetsPendingSignedHeaderOnFirstObservation(t *testing.T) { + env := newDSTestEnv(t) + + first := env.signHeaderAtHeight(5, 0x01) + bin, err := first.MarshalBinary() + require.NoError(t, err) + + mockClient := newMockDAClient(t) + + r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.store, env.onDouble) + _ = r.ProcessBlobs(context.Background(), [][]byte{bin}, 100) + + got, src, ok := env.cache.GetPendingSignedHeader(5) + require.True(t, ok) + require.Equal(t, first.Hash().String(), got.Hash().String()) + require.Equal(t, types.EvidenceSourceDA, src) +} + diff --git a/block/internal/syncing/doublesign_test.go b/block/internal/syncing/doublesign_test.go new file mode 100644 index 0000000000..70117e31f1 --- /dev/null +++ b/block/internal/syncing/doublesign_test.go @@ -0,0 +1,671 @@ +package syncing + +import ( + "context" + "sync/atomic" + "testing" + "time" + + gkmetrics "github.com/go-kit/kit/metrics" + ds "github.com/ipfs/go-datastore" + dssync "github.com/ipfs/go-datastore/sync" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/rs/zerolog" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + "github.com/evstack/ev-node/block/internal/cache" + "github.com/evstack/ev-node/block/internal/common" + "github.com/evstack/ev-node/pkg/config" + "github.com/evstack/ev-node/pkg/genesis" + signerpkg "github.com/evstack/ev-node/pkg/signer" + "github.com/evstack/ev-node/pkg/store" + testmocks "github.com/evstack/ev-node/test/mocks" + extmocks "github.com/evstack/ev-node/test/mocks/external" + "github.com/evstack/ev-node/types" + pb "github.com/evstack/ev-node/types/pb/evnode/v1" +) + +// dsTestEnv bundles the store, cache, genesis and signer used by the +// double-sign tests. +type dsTestEnv struct { + t *testing.T + store store.Store + cache cache.CacheManager + gen genesis.Genesis + addr []byte + pub crypto.PubKey + signer signerpkg.Signer + chainID string + capLock atomic.Pointer[[]*types.DoubleSignEvidence] + onDouble doubleSignHandler +} + +func newDSTestEnv(t *testing.T) *dsTestEnv { + t.Helper() + memDS := dssync.MutexWrap(ds.NewMapDatastore()) + st := store.New(memDS) + cm, err := cache.NewManager(config.DefaultConfig(), st, zerolog.Nop()) + require.NoError(t, err) + + addr, pub, signer := buildSyncTestSigner(t) + gen := genesis.Genesis{ + ChainID: "ds-test", + InitialHeight: 1, + StartTime: time.Now().Add(-time.Second), + ProposerAddress: addr, + } + + env := &dsTestEnv{ + t: t, + store: st, + cache: cm, + gen: gen, + addr: addr, + pub: pub, + signer: signer, + chainID: gen.ChainID, + } + empty := []*types.DoubleSignEvidence{} + env.capLock.Store(&empty) + env.onDouble = func(ctx context.Context, ev *types.DoubleSignEvidence) { + require.NoError(t, ev.ValidateBasic()) + for { + cur := env.capLock.Load() + next := append([]*types.DoubleSignEvidence(nil), *cur...) + next = append(next, ev) + if env.capLock.CompareAndSwap(cur, &next) { + break + } + } + // Persist immediately so tests can verify round-trip decoding. + require.NoError(t, persistEvidence(ctx, st, ev)) + } + return env +} + +func (e *dsTestEnv) captured() []*types.DoubleSignEvidence { + return *e.capLock.Load() +} + +// signs a header at height by the genesis proposer; variant differentiates hashes. +func (e *dsTestEnv) signHeaderAtHeight(height uint64, variant byte) *types.SignedHeader { + e.t.Helper() + _, hdr := makeSignedHeaderBytes( + e.t, e.chainID, height, e.addr, e.pub, e.signer, + []byte{variant, variant, variant}, + nil, + nil, + ) + return hdr +} + +// signs a header by a fresh (non-genesis) signer. +func (e *dsTestEnv) signHeaderWithOtherProposer(height uint64, variant byte) *types.SignedHeader { + e.t.Helper() + otherAddr, otherPub, otherSigner := buildSyncTestSigner(e.t) + _, hdr := makeSignedHeaderBytes( + e.t, e.chainID, height, otherAddr, otherPub, otherSigner, + []byte{variant, variant, variant}, + nil, + nil, + ) + return hdr +} + +func (e *dsTestEnv) saveHeader(hdr *types.SignedHeader) { + e.t.Helper() + batch, err := e.store.NewBatch(context.Background()) + require.NoError(e.t, err) + require.NoError(e.t, batch.SaveBlockData(hdr, &types.Data{ + Metadata: &types.Metadata{ChainID: e.chainID, Height: hdr.Height(), Time: hdr.BaseHeader.Time}, + }, &hdr.Signature)) + require.NoError(e.t, batch.SetHeight(hdr.Height())) + require.NoError(e.t, batch.Commit()) +} + +func TestDetectDoubleSign_TwoValidHeadersSameHeight(t *testing.T) { + env := newDSTestEnv(t) + first := env.signHeaderAtHeight(5, 0x01) + env.saveHeader(first) + + alt := env.signHeaderAtHeight(5, 0x02) + require.NotEqual(t, first.Hash().String(), alt.Hash().String()) + + ev, err := detectDoubleSign(context.Background(), env.store, env.cache, alt, types.EvidenceSourceP2P) + require.NoError(t, err) + require.NotNil(t, ev) + require.Equal(t, uint64(5), ev.Height) + require.Equal(t, first.Hash().String(), ev.FirstHeader.Hash().String()) + require.Equal(t, alt.Hash().String(), ev.AlternateHeader.Hash().String()) + require.Equal(t, types.EvidenceSourceP2P, ev.AlternateSource) + + // Round-trip through marshal/unmarshal. + blob, err := ev.MarshalBinary() + require.NoError(t, err) + decoded := new(types.DoubleSignEvidence) + require.NoError(t, decoded.UnmarshalBinary(blob)) + require.Equal(t, ev.Height, decoded.Height) + require.Equal(t, ev.FirstHeader.Hash().String(), decoded.FirstHeader.Hash().String()) + require.Equal(t, ev.AlternateHeader.Hash().String(), decoded.AlternateHeader.Hash().String()) + require.Equal(t, ev.FirstSource, decoded.FirstSource) + require.Equal(t, ev.AlternateSource, decoded.AlternateSource) +} + +func TestDetectDoubleSign_IdenticalHashNoEvidence(t *testing.T) { + env := newDSTestEnv(t) + first := env.signHeaderAtHeight(5, 0x01) + env.saveHeader(first) + + ev, err := detectDoubleSign(context.Background(), env.store, env.cache, first, types.EvidenceSourceP2P) + require.NoError(t, err) + require.Nil(t, ev) +} + +func TestDetectDoubleSign_NoPriorRecordReturnsNil(t *testing.T) { + env := newDSTestEnv(t) + alt := env.signHeaderAtHeight(5, 0x01) + ev, err := detectDoubleSign(context.Background(), env.store, env.cache, alt, types.EvidenceSourceP2P) + require.NoError(t, err) + require.Nil(t, ev) +} + +func TestBuildEvidenceFromPair_ProposerMismatch(t *testing.T) { + env := newDSTestEnv(t) + first := env.signHeaderAtHeight(5, 0x01) + alt := env.signHeaderWithOtherProposer(5, 0x02) + require.Nil(t, buildEvidenceFromPair(first, alt, types.EvidenceSourceDA, types.EvidenceSourceDA)) +} + +func TestBuildEvidenceFromPair_HappyPath(t *testing.T) { + env := newDSTestEnv(t) + first := env.signHeaderAtHeight(5, 0x01) + alt := env.signHeaderAtHeight(5, 0x02) + ev := buildEvidenceFromPair(first, alt, types.EvidenceSourceDA, types.EvidenceSourceDA) + require.NotNil(t, ev) + require.NoError(t, ev.ValidateBasic()) +} + +func TestBuildEvidenceFromPair_EdgeCases(t *testing.T) { + env := newDSTestEnv(t) + a := env.signHeaderAtHeight(5, 0x01) + b := env.signHeaderAtHeight(6, 0x02) + + require.Nil(t, buildEvidenceFromPair(nil, a, types.EvidenceSourceDA, types.EvidenceSourceDA)) + require.Nil(t, buildEvidenceFromPair(a, nil, types.EvidenceSourceDA, types.EvidenceSourceDA)) + require.Nil(t, buildEvidenceFromPair(a, b, types.EvidenceSourceDA, types.EvidenceSourceDA)) + require.Nil(t, buildEvidenceFromPair(a, a, types.EvidenceSourceDA, types.EvidenceSourceDA)) +} + +func TestPersistEvidence_RejectsInvalid(t *testing.T) { + env := newDSTestEnv(t) + first := env.signHeaderAtHeight(5, 0x01) + bad := &types.DoubleSignEvidence{ + Height: 5, + FirstHeader: first, + AlternateHeader: first, + } + require.Error(t, persistEvidence(context.Background(), env.store, bad)) +} + +func TestDoubleSignDedup(t *testing.T) { + d := newDoubleSignDedup() + require.True(t, d.markSeen(7, "abc")) + require.False(t, d.markSeen(7, "abc")) + require.True(t, d.markSeen(7, "def")) + require.True(t, d.markSeen(8, "abc")) +} + +func TestReportDoubleSign_PersistsAndHalts(t *testing.T) { + env := newDSTestEnv(t) + first := env.signHeaderAtHeight(5, 0x01) + alt := env.signHeaderAtHeight(5, 0x02) + ev := buildEvidenceFromPair(first, alt, types.EvidenceSourceP2P, types.EvidenceSourceDA) + require.NotNil(t, ev) + + metrics := common.NopMetrics() + seen := newDoubleSignDedup() + var halted atomic.Pointer[error] + crit := func(err error) { halted.Store(&err) } + + halt1 := reportDoubleSign(context.Background(), env.store, metrics, zerolog.Nop(), seen, crit, ev) + require.Error(t, halt1) + + // Second call must be a no-op via dedup. + halted.Store(nil) + halt2 := reportDoubleSign(context.Background(), env.store, metrics, zerolog.Nop(), seen, crit, ev) + require.NoError(t, halt2) + require.Nil(t, halted.Load()) + + key := store.GetDoubleSignEvidenceKey(ev.Height, ev.AlternateHeader.Hash()) + blob, err := env.store.GetMetadata(context.Background(), key) + require.NoError(t, err) + decoded := new(types.DoubleSignEvidence) + require.NoError(t, decoded.UnmarshalBinary(blob)) + require.Equal(t, ev.Height, decoded.Height) + require.Equal(t, ev.AlternateHeader.Hash().String(), decoded.AlternateHeader.Hash().String()) +} + +func TestP2PHandler_DoubleSignTriggersCriticalError(t *testing.T) { + env := newDSTestEnv(t) + + // Persist canonical header, then arrange a conflicting one to come in via P2P. + first := env.signHeaderAtHeight(5, 0x01) + env.saveHeader(first) + alt := env.signHeaderAtHeight(5, 0x02) + require.NotEqual(t, first.Hash().String(), alt.Hash().String()) + + headerStoreMock := extmocks.NewMockStore[*types.P2PSignedHeader](t) + dataStoreMock := extmocks.NewMockStore[*types.P2PData](t) + headerStoreMock.EXPECT(). + GetByHeight(mock.Anything, uint64(5)). + Return(&types.P2PSignedHeader{SignedHeader: alt}, nil). + Once() + + h := NewP2PHandler(headerStoreMock, dataStoreMock, env.cache, env.gen, zerolog.Nop(), env.store, env.onDouble) + + ch := make(chan common.DAHeightEvent, 1) + require.NoError(t, h.ProcessHeight(context.Background(), 5, ch)) + + captured := env.captured() + require.Len(t, captured, 1) + require.Equal(t, alt.Hash().String(), captured[0].AlternateHeader.Hash().String()) + require.Equal(t, types.EvidenceSourceP2P, captured[0].AlternateSource) + + key := store.GetDoubleSignEvidenceKey(5, alt.Hash()) + blob, err := env.store.GetMetadata(context.Background(), key) + require.NoError(t, err) + require.NotEmpty(t, blob) + + select { + case evt := <-ch: + t.Fatalf("expected no event on double-sign; got %+v", evt) + default: + } +} + +func TestP2PHandler_ProposerMismatchIsNotEvidence(t *testing.T) { + env := newDSTestEnv(t) + first := env.signHeaderAtHeight(5, 0x01) + env.saveHeader(first) + + // A header from a different signer must be rejected before the detector runs. + badHdr := env.signHeaderWithOtherProposer(5, 0x02) + + headerStoreMock := extmocks.NewMockStore[*types.P2PSignedHeader](t) + dataStoreMock := extmocks.NewMockStore[*types.P2PData](t) + headerStoreMock.EXPECT(). + GetByHeight(mock.Anything, uint64(5)). + Return(&types.P2PSignedHeader{SignedHeader: badHdr}, nil). + Once() + + h := NewP2PHandler(headerStoreMock, dataStoreMock, env.cache, env.gen, zerolog.Nop(), env.store, env.onDouble) + + ch := make(chan common.DAHeightEvent, 1) + err := h.ProcessHeight(context.Background(), 5, ch) + require.Error(t, err) + require.Empty(t, env.captured()) +} + +func TestDARetriever_DoubleSignSamePendingBatch(t *testing.T) { + env := newDSTestEnv(t) + + first := env.signHeaderAtHeight(5, 0x01) + alt := env.signHeaderAtHeight(5, 0x02) + require.NotEqual(t, first.Hash().String(), alt.Hash().String()) + + firstBin, err := first.MarshalBinary() + require.NoError(t, err) + altBin, err := alt.MarshalBinary() + require.NoError(t, err) + + mockClient := testmocks.NewMockClient(t) + mockClient.On("GetHeaderNamespace").Return([]byte("ns")).Maybe() + mockClient.On("GetDataNamespace").Return([]byte("ns")).Maybe() + + r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.store, env.onDouble) + events := r.ProcessBlobs(context.Background(), [][]byte{firstBin, altBin}, 100) + require.Empty(t, events) + + captured := env.captured() + require.Len(t, captured, 1) + require.Equal(t, uint64(5), captured[0].Height) + require.Equal(t, types.EvidenceSourceDA, captured[0].FirstSource) + require.Equal(t, types.EvidenceSourceDA, captured[0].AlternateSource) +} + +func TestDARetriever_DoubleSignAcrossBatches(t *testing.T) { + env := newDSTestEnv(t) + + first := env.signHeaderAtHeight(5, 0x01) + env.saveHeader(first) + + alt := env.signHeaderAtHeight(5, 0x02) + altBin, err := alt.MarshalBinary() + require.NoError(t, err) + + mockClient := testmocks.NewMockClient(t) + mockClient.On("GetHeaderNamespace").Return([]byte("ns")).Maybe() + mockClient.On("GetDataNamespace").Return([]byte("ns")).Maybe() + + r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.store, env.onDouble) + events := r.ProcessBlobs(context.Background(), [][]byte{altBin}, 101) + require.Empty(t, events) + + captured := env.captured() + require.Len(t, captured, 1) + require.Equal(t, alt.Hash().String(), captured[0].AlternateHeader.Hash().String()) +} + +func TestDARetriever_BenignDuplicateAcrossBatchesDoesNotFire(t *testing.T) { + env := newDSTestEnv(t) + + first := env.signHeaderAtHeight(5, 0x01) + env.saveHeader(first) + + // Same header re-observed from DA (e.g. re-posted at a different DA height). + sameBin, err := first.MarshalBinary() + require.NoError(t, err) + + mockClient := testmocks.NewMockClient(t) + mockClient.On("GetHeaderNamespace").Return([]byte("ns")).Maybe() + mockClient.On("GetDataNamespace").Return([]byte("ns")).Maybe() + + r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.store, env.onDouble) + _ = r.ProcessBlobs(context.Background(), [][]byte{sameBin}, 101) + require.Empty(t, env.captured()) +} + +// A legacy blob with the correct proposer but a tampered signature must be +// rejected before reaching the detector or pending cache. +func TestDARetriever_LegacyForgedSignatureRejected(t *testing.T) { + env := newDSTestEnv(t) + + // Tamper the signature byte to invalidate verification while preserving + // every other field (proposer address included). + good := env.signHeaderAtHeight(5, 0x01) + pbHdr, err := good.ToProto() + require.NoError(t, err) + pbHdr.Signature = append([]byte(nil), good.Signature...) + pbHdr.Signature[0] ^= 0xff + bin, err := proto.Marshal(pbHdr) + require.NoError(t, err) + + mockClient := testmocks.NewMockClient(t) + mockClient.On("GetHeaderNamespace").Return([]byte("ns")).Maybe() + mockClient.On("GetDataNamespace").Return([]byte("ns")).Maybe() + + r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.store, env.onDouble) + require.Nil(t, r.tryDecodeHeader(bin, 100)) + + _, _, ok := env.cache.GetPendingSignedHeader(5) + require.False(t, ok) +} + +// Detection must trigger from a pending cache entry too, before persistence. +func TestDetectDoubleSign_PendingCacheHitProducesEvidence(t *testing.T) { + env := newDSTestEnv(t) + + first := env.signHeaderAtHeight(5, 0x01) + env.cache.SetPendingSignedHeader(first, types.EvidenceSourceDA) + // First header is in-flight, not yet on disk. + + alt := env.signHeaderAtHeight(5, 0x02) + ev, err := detectDoubleSign(context.Background(), env.store, env.cache, alt, types.EvidenceSourceP2P) + require.NoError(t, err) + require.NotNil(t, ev) + require.Equal(t, first.Hash().String(), ev.FirstHeader.Hash().String()) + require.Equal(t, alt.Hash().String(), ev.AlternateHeader.Hash().String()) + require.Equal(t, types.EvidenceSourceDA, ev.FirstSource) + require.Equal(t, types.EvidenceSourceP2P, ev.AlternateSource) +} + +func TestDetectDoubleSign_PendingCacheBenignDuplicate(t *testing.T) { + env := newDSTestEnv(t) + + first := env.signHeaderAtHeight(5, 0x01) + env.cache.SetPendingSignedHeader(first, types.EvidenceSourceDA) + + ev, err := detectDoubleSign(context.Background(), env.store, env.cache, first, types.EvidenceSourceP2P) + require.NoError(t, err) + require.Nil(t, ev) +} + +func TestDetectDoubleSign_PendingEvictedAfterRemoval(t *testing.T) { + env := newDSTestEnv(t) + + first := env.signHeaderAtHeight(5, 0x01) + env.cache.SetPendingSignedHeader(first, types.EvidenceSourceDA) + env.cache.RemovePendingSignedHeader(5) + + alt := env.signHeaderAtHeight(5, 0x02) + ev, err := detectDoubleSign(context.Background(), env.store, env.cache, alt, types.EvidenceSourceP2P) + require.NoError(t, err) + require.Nil(t, ev) +} + +func TestDoubleSignEvidence_ValidateBasic(t *testing.T) { + env := newDSTestEnv(t) + first := env.signHeaderAtHeight(5, 0x01) + alt := env.signHeaderAtHeight(5, 0x02) + + t.Run("nil receiver", func(t *testing.T) { + var e *types.DoubleSignEvidence + require.Error(t, e.ValidateBasic()) + }) + t.Run("missing headers", func(t *testing.T) { + require.Error(t, (&types.DoubleSignEvidence{Height: 5}).ValidateBasic()) + }) + t.Run("height mismatch", func(t *testing.T) { + e := &types.DoubleSignEvidence{Height: 99, FirstHeader: first, AlternateHeader: alt} + require.Error(t, e.ValidateBasic()) + }) + t.Run("identical hashes", func(t *testing.T) { + e := &types.DoubleSignEvidence{Height: 5, FirstHeader: first, AlternateHeader: first} + require.Error(t, e.ValidateBasic()) + }) + t.Run("happy path", func(t *testing.T) { + e := &types.DoubleSignEvidence{Height: 5, FirstHeader: first, AlternateHeader: alt} + require.NoError(t, e.ValidateBasic()) + }) +} + +func TestPBDoubleSignEvidence_RoundTrip(t *testing.T) { + env := newDSTestEnv(t) + ev := &types.DoubleSignEvidence{ + Height: 7, + FirstHeader: env.signHeaderAtHeight(7, 0x01), + AlternateHeader: env.signHeaderAtHeight(7, 0x02), + DetectedAt: time.Unix(1_700_000_000, 500).UTC(), + FirstSource: types.EvidenceSourceDA, + AlternateSource: types.EvidenceSourceP2P, + } + p, err := ev.ToProto() + require.NoError(t, err) + blob, err := proto.Marshal(p) + require.NoError(t, err) + + decoded := new(pb.DoubleSignEvidence) + require.NoError(t, proto.Unmarshal(blob, decoded)) + require.Equal(t, ev.Height, decoded.Height) + require.Equal(t, ev.DetectedAt.UnixNano(), decoded.DetectedAt) + require.Equal(t, ev.FirstSource, decoded.FirstSource) + require.Equal(t, ev.AlternateSource, decoded.AlternateSource) + require.NotNil(t, decoded.FirstHeader) + require.NotNil(t, decoded.AlternateHeader) + require.Equal(t, ev.FirstHeader.Height(), decoded.FirstHeader.Header.Height) +} + +func TestDARetriever_DoubleSignEvidenceHasMatchingProposers(t *testing.T) { + env := newDSTestEnv(t) + + first := env.signHeaderAtHeight(5, 0x01) + alt := env.signHeaderAtHeight(5, 0x02) + firstBin, err := first.MarshalBinary() + require.NoError(t, err) + altBin, err := alt.MarshalBinary() + require.NoError(t, err) + + mockClient := testmocks.NewMockClient(t) + mockClient.On("GetHeaderNamespace").Return([]byte("ns")).Maybe() + mockClient.On("GetDataNamespace").Return([]byte("ns")).Maybe() + + r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.store, env.onDouble) + _ = r.ProcessBlobs(context.Background(), [][]byte{firstBin, altBin}, 100) + + captured := env.captured() + require.Len(t, captured, 1) + require.Equal(t, env.gen.ProposerAddress, []byte(captured[0].FirstHeader.ProposerAddress)) + require.Equal(t, env.gen.ProposerAddress, []byte(captured[0].AlternateHeader.ProposerAddress)) +} + +func TestSyncer_EvictsPendingHeaderOnPersist(t *testing.T) { + memDS := dssync.MutexWrap(ds.NewMapDatastore()) + st := store.New(memDS) + + cm, err := cache.NewManager(config.DefaultConfig(), st, zerolog.Nop()) + require.NoError(t, err) + + addr, pub, signer := buildSyncTestSigner(t) + cfg := config.DefaultConfig() + gen := genesis.Genesis{ + ChainID: "syncer-evict", InitialHeight: 1, + StartTime: time.Now().Add(-time.Second), ProposerAddress: addr, + } + + mockExec := testmocks.NewMockExecutor(t) + mockExec.EXPECT().InitChain(mock.Anything, mock.Anything, uint64(1), gen.ChainID). + Return([]byte("app0"), nil).Once() + mockExec.EXPECT().ExecuteTxs(mock.Anything, mock.Anything, uint64(1), mock.Anything, mock.Anything). + Return([]byte("app1"), nil).Once() + + mockHeaderStore := extmocks.NewMockStore[*types.P2PSignedHeader](t) + mockHeaderStore.EXPECT().Height().Return(uint64(0)).Maybe() + mockDataStore := extmocks.NewMockStore[*types.P2PData](t) + mockDataStore.EXPECT().Height().Return(uint64(0)).Maybe() + + s := NewSyncer( + st, mockExec, nil, cm, common.NopMetrics(), cfg, gen, + mockHeaderStore, mockDataStore, zerolog.Nop(), + common.DefaultBlockOptions(), make(chan error, 1), nil, + ) + require.NoError(t, s.initializeState()) + s.ctx = t.Context() + + state := s.getLastState() + data := makeData(gen.ChainID, 1, 0) + _, hdr := makeSignedHeaderBytes(t, gen.ChainID, 1, addr, pub, signer, state.AppHash, data, nil) + + cm.SetPendingSignedHeader(hdr, types.EvidenceSourceP2P) + _, _, ok := cm.GetPendingSignedHeader(1) + require.True(t, ok) + + evt := common.DAHeightEvent{Header: hdr, Data: data, DaHeight: 1} + s.processHeightEvent(s.ctx, &evt) + + _, _, ok = cm.GetPendingSignedHeader(1) + require.False(t, ok) +} + +// End-to-end: a double-sign through a real Syncer must halt on errorCh, +// flip hasCriticalError, and bump DoubleSignsDetected only once for duplicate evidence. +func TestSyncer_DoubleSignHaltsAndEmitsCriticalError(t *testing.T) { + memDS := dssync.MutexWrap(ds.NewMapDatastore()) + st := store.New(memDS) + + cm, err := cache.NewManager(config.DefaultConfig(), st, zerolog.Nop()) + require.NoError(t, err) + + addr, pub, signer := buildSyncTestSigner(t) + cfg := config.DefaultConfig() + gen := genesis.Genesis{ + ChainID: "syncer-ds", InitialHeight: 1, + StartTime: time.Now().Add(-time.Second), ProposerAddress: addr, + } + + mockExec := testmocks.NewMockExecutor(t) + mockExec.EXPECT(). + InitChain(mock.Anything, mock.Anything, uint64(1), gen.ChainID). + Return([]byte("app0"), nil).Once() + + mockHeaderStore := extmocks.NewMockStore[*types.P2PSignedHeader](t) + mockHeaderStore.EXPECT().Height().Return(uint64(0)).Maybe() + mockDataStore := extmocks.NewMockStore[*types.P2PData](t) + mockDataStore.EXPECT().Height().Return(uint64(0)).Maybe() + + // Wire a counting metric so we can assert exact increments. + metrics := common.NopMetrics() + var dsCount atomic.Int64 + metrics.DoubleSignsDetected = &counterCtr{n: &dsCount} + + errCh := make(chan error, 4) + s := NewSyncer( + st, mockExec, nil, cm, metrics, cfg, gen, + mockHeaderStore, mockDataStore, zerolog.Nop(), + common.DefaultBlockOptions(), errCh, nil, + ) + require.NoError(t, s.initializeState()) + s.doubleSignSeen = newDoubleSignDedup() // normally set up by Start() + + // Fire two identical alternate events to simulate P2P + DA converging. + first := makeHeaderForSyncer(t, gen, addr, pub, signer, 1, 0x01) + saveHeaderViaBatch(t, st, gen, first) + + alt := makeHeaderForSyncer(t, gen, addr, pub, signer, 1, 0x02) + require.NotEqual(t, first.Hash().String(), alt.Hash().String()) + + p2pEv := &types.DoubleSignEvidence{ + Height: 1, FirstHeader: first, AlternateHeader: alt, + DetectedAt: time.Now(), FirstSource: "stored", AlternateSource: types.EvidenceSourceP2P, + } + daEv := &types.DoubleSignEvidence{ + Height: 1, FirstHeader: first, AlternateHeader: alt, + DetectedAt: time.Now(), FirstSource: "stored", AlternateSource: types.EvidenceSourceDA, + } + + s.handleDoubleSign(context.Background(), p2pEv) + s.handleDoubleSign(context.Background(), daEv) + + require.Equal(t, int64(1), dsCount.Load(), "duplicate evidence must not double-count") + require.True(t, s.hasCriticalError.Load()) + + select { + case got := <-errCh: + require.ErrorIs(t, got, ErrDoubleSign) + case <-time.After(time.Second): + t.Fatal("timed out waiting for critical error on errCh") + } + + key := store.GetDoubleSignEvidenceKey(1, alt.Hash()) + blob, err := st.GetMetadata(context.Background(), key) + require.NoError(t, err) + require.NotEmpty(t, blob) +} + +func makeHeaderForSyncer(t *testing.T, gen genesis.Genesis, addr []byte, pub crypto.PubKey, signer signerpkg.Signer, height uint64, variant byte) *types.SignedHeader { + t.Helper() + _, hdr := makeSignedHeaderBytes(t, gen.ChainID, height, addr, pub, signer, + []byte{variant, variant, variant}, nil, nil) + return hdr +} + +// persists a signed header + empty data + signature and bumps the store height. +func saveHeaderViaBatch(t *testing.T, st store.Store, gen genesis.Genesis, hdr *types.SignedHeader) { + t.Helper() + batch, err := st.NewBatch(context.Background()) + require.NoError(t, err) + require.NoError(t, batch.SaveBlockData(hdr, &types.Data{ + Metadata: &types.Metadata{ChainID: gen.ChainID, Height: hdr.Height(), Time: hdr.BaseHeader.Time}, + }, &hdr.Signature)) + require.NoError(t, batch.SetHeight(hdr.Height())) + require.NoError(t, batch.Commit()) +} + +// go-kit Counter backed by an atomic int64 so tests can read exact increments. +type counterCtr struct { + n *atomic.Int64 +} + +func (c *counterCtr) Add(delta float64) { c.n.Add(int64(delta)) } +func (c *counterCtr) With(labelValues ...string) gkmetrics.Counter { return c } diff --git a/block/internal/syncing/p2p_handler.go b/block/internal/syncing/p2p_handler.go index a3778757a1..1fc839f150 100644 --- a/block/internal/syncing/p2p_handler.go +++ b/block/internal/syncing/p2p_handler.go @@ -12,6 +12,7 @@ import ( "github.com/evstack/ev-node/block/internal/cache" "github.com/evstack/ev-node/block/internal/common" "github.com/evstack/ev-node/pkg/genesis" + "github.com/evstack/ev-node/pkg/store" "github.com/evstack/ev-node/types" ) @@ -33,23 +34,31 @@ type P2PHandler struct { genesis genesis.Genesis logger zerolog.Logger + store store.Store + onDoubleSign doubleSignHandler // nil disables detection + processedHeight atomic.Uint64 } -// NewP2PHandler creates a new P2P handler. +// NewP2PHandler creates a new P2P handler. Double-sign detection is disabled +// when st or onDoubleSign is nil. func NewP2PHandler( headerStore header.Store[*types.P2PSignedHeader], dataStore header.Store[*types.P2PData], cache cache.CacheManager, genesis genesis.Genesis, logger zerolog.Logger, + st store.Store, + onDoubleSign doubleSignHandler, ) *P2PHandler { return &P2PHandler{ - headerStore: headerStore, - dataStore: dataStore, - cache: cache, - genesis: genesis, - logger: logger.With().Str("component", "p2p_handler").Logger(), + headerStore: headerStore, + dataStore: dataStore, + cache: cache, + genesis: genesis, + logger: logger.With().Str("component", "p2p_handler").Logger(), + store: st, + onDoubleSign: onDoubleSign, } } @@ -69,9 +78,14 @@ func (h *P2PHandler) SetProcessedHeight(height uint64) { // ProcessHeight retrieves and validates both header and data for the given height from P2P stores. // It blocks until both are available, validates consistency (proposer address and data hash match), // then emits the event to heightInCh or stores it as pending. Updates processedHeight on success. +// +// When double-sign detection is enabled, the processedHeight short-circuit is +// deferred so alternates at already-processed heights still trigger detection. func (h *P2PHandler) ProcessHeight(ctx context.Context, height uint64, heightInCh chan<- common.DAHeightEvent) error { - if height <= h.processedHeight.Load() { - return nil + if h.store == nil || h.onDoubleSign == nil { + if height <= h.processedHeight.Load() { + return nil + } } p2pHeader, err := h.headerStore.GetByHeight(ctx, height) @@ -86,6 +100,24 @@ func (h *P2PHandler) ProcessHeight(ctx context.Context, height uint64, heightInC return err } + // ValidateBasic is the precondition for treating an alternate as evidence. + if h.store != nil && h.onDoubleSign != nil { + if err := p2pHeader.SignedHeader.ValidateBasic(); err != nil { + h.logger.Debug().Uint64("height", height).Err(err).Msg("invalid signed header from P2P") + return err + } + if ev, derr := detectDoubleSign(ctx, h.store, h.cache, p2pHeader.SignedHeader, types.EvidenceSourceP2P); derr == nil && ev != nil { + h.onDoubleSign(ctx, ev) + return nil + } else if derr != nil { + h.logger.Warn().Err(derr).Uint64("height", height).Msg("double-sign detection error") + } + h.cache.SetPendingSignedHeader(p2pHeader.SignedHeader, types.EvidenceSourceP2P) + if height <= h.processedHeight.Load() { + return nil + } + } + p2pData, err := h.dataStore.GetByHeight(ctx, height) if err != nil { if ctx.Err() == nil { diff --git a/block/internal/syncing/p2p_handler_doublesign_test.go b/block/internal/syncing/p2p_handler_doublesign_test.go new file mode 100644 index 0000000000..dc5669687b --- /dev/null +++ b/block/internal/syncing/p2p_handler_doublesign_test.go @@ -0,0 +1,144 @@ +package syncing + +import ( + "context" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + "github.com/evstack/ev-node/block/internal/common" + extmocks "github.com/evstack/ev-node/test/mocks/external" + "github.com/evstack/ev-node/types" +) + +// The processedHeight short-circuit must run AFTER the detector so an +// alternate at an already-applied height still triggers detection. +func TestP2PHandler_DetectsAtAlreadyProcessedHeight(t *testing.T) { + env := newDSTestEnv(t) + + first := env.signHeaderAtHeight(5, 0x01) + env.saveHeader(first) + + alt := env.signHeaderAtHeight(5, 0x02) + require.NotEqual(t, first.Hash().String(), alt.Hash().String()) + + headerStoreMock := extmocks.NewMockStore[*types.P2PSignedHeader](t) + dataStoreMock := extmocks.NewMockStore[*types.P2PData](t) + headerStoreMock.EXPECT(). + GetByHeight(mock.Anything, uint64(5)). + Return(&types.P2PSignedHeader{SignedHeader: alt}, nil). + Once() + + h := NewP2PHandler(headerStoreMock, dataStoreMock, env.cache, env.gen, + zerolog.Nop(), env.store, env.onDouble) + + h.SetProcessedHeight(5) + + ch := make(chan common.DAHeightEvent, 1) + require.NoError(t, h.ProcessHeight(context.Background(), 5, ch)) + + captured := env.captured() + require.Len(t, captured, 1) + require.Equal(t, alt.Hash().String(), captured[0].AlternateHeader.Hash().String()) + require.Equal(t, types.EvidenceSourceP2P, captured[0].AlternateSource) + + select { + case evt := <-ch: + t.Fatalf("expected no event when double-sign fires; got %+v", evt) + default: + } +} + +// When detection is disabled the legacy short-circuit must still fire. +func TestP2PHandler_LegacyShortCircuitWhenDetectionDisabled(t *testing.T) { + env := newDSTestEnv(t) + + headerStoreMock := extmocks.NewMockStore[*types.P2PSignedHeader](t) + dataStoreMock := extmocks.NewMockStore[*types.P2PData](t) + + h := NewP2PHandler(headerStoreMock, dataStoreMock, env.cache, env.gen, + zerolog.Nop(), nil, nil) + + h.SetProcessedHeight(5) + + // Mock has no expectation set — a call to GetByHeight would panic. + ch := make(chan common.DAHeightEvent, 1) + require.NoError(t, h.ProcessHeight(context.Background(), 5, ch)) +} + +// A P2P header with the correct proposer but a tampered signature must be +// rejected before the detector runs. +func TestP2PHandler_InvalidSigRejectedBeforeDetector(t *testing.T) { + env := newDSTestEnv(t) + + good := env.signHeaderAtHeight(5, 0x01) + pbHdr, err := good.ToProto() + require.NoError(t, err) + pbHdr.Signature = append([]byte(nil), good.Signature...) + pbHdr.Signature[0] ^= 0xff + bin, err := proto.Marshal(pbHdr) + require.NoError(t, err) + + forged := new(types.SignedHeader) + { + var pbDecoded = pbHdr + require.NoError(t, proto.Unmarshal(bin, pbDecoded)) + require.NoError(t, forged.FromProto(pbDecoded)) + } + + headerStoreMock := extmocks.NewMockStore[*types.P2PSignedHeader](t) + dataStoreMock := extmocks.NewMockStore[*types.P2PData](t) + headerStoreMock.EXPECT(). + GetByHeight(mock.Anything, uint64(5)). + Return(&types.P2PSignedHeader{SignedHeader: forged}, nil). + Once() + + h := NewP2PHandler(headerStoreMock, dataStoreMock, env.cache, env.gen, + zerolog.Nop(), env.store, env.onDouble) + + ch := make(chan common.DAHeightEvent, 1) + err = h.ProcessHeight(context.Background(), 5, ch) + require.Error(t, err) + + require.Empty(t, env.captured()) + + _, _, ok := env.cache.GetPendingSignedHeader(5) + require.False(t, ok) +} + +// First P2P observation must populate the pending cache so a later DA blob +// at the same height can be matched against it. +func TestP2PHandler_SetsPendingSignedHeaderOnFirstObservation(t *testing.T) { + env := newDSTestEnv(t) + + // Provide both header and data so ProcessHeight reaches the emit step. + first := env.signHeaderAtHeight(5, 0x01) + first.DataHash = common.DataHashForEmptyTxs + + headerStoreMock := extmocks.NewMockStore[*types.P2PSignedHeader](t) + dataStoreMock := extmocks.NewMockStore[*types.P2PData](t) + headerStoreMock.EXPECT(). + GetByHeight(mock.Anything, uint64(5)). + Return(&types.P2PSignedHeader{SignedHeader: first}, nil). + Once() + dataStoreMock.EXPECT(). + GetByHeight(mock.Anything, uint64(5)). + Return(&types.P2PData{Data: &types.Data{ + Metadata: &types.Metadata{ChainID: env.chainID, Height: 5, Time: first.BaseHeader.Time}, + }}, nil). + Once() + + h := NewP2PHandler(headerStoreMock, dataStoreMock, env.cache, env.gen, + zerolog.Nop(), env.store, env.onDouble) + + ch := make(chan common.DAHeightEvent, 1) + require.NoError(t, h.ProcessHeight(context.Background(), 5, ch)) + + got, src, ok := env.cache.GetPendingSignedHeader(5) + require.True(t, ok) + require.Equal(t, first.Hash().String(), got.Hash().String()) + require.Equal(t, types.EvidenceSourceP2P, src) +} diff --git a/block/internal/syncing/p2p_handler_test.go b/block/internal/syncing/p2p_handler_test.go index 8bffc31ede..8feda8c2d6 100644 --- a/block/internal/syncing/p2p_handler_test.go +++ b/block/internal/syncing/p2p_handler_test.go @@ -90,7 +90,7 @@ func setupP2P(t *testing.T) *P2PTestData { cacheManager, err := cache.NewManager(cfg, st, zerolog.Nop()) require.NoError(t, err, "failed to create cache manager") - handler := NewP2PHandler(headerStoreMock, dataStoreMock, cacheManager, gen, zerolog.Nop()) + handler := NewP2PHandler(headerStoreMock, dataStoreMock, cacheManager, gen, zerolog.Nop(), nil, nil) return &P2PTestData{ Handler: handler, HeaderStore: headerStoreMock, diff --git a/block/internal/syncing/syncer.go b/block/internal/syncing/syncer.go index 40e3c9523f..f9d4570a2b 100644 --- a/block/internal/syncing/syncer.go +++ b/block/internal/syncing/syncer.go @@ -95,6 +95,9 @@ type Syncer struct { wg sync.WaitGroup hasCriticalError atomic.Bool + // Double-sign detection + doubleSignSeen *doubleSignDedup + // P2P wait coordination p2pWaitState atomic.Value // stores p2pWaitState @@ -180,15 +183,18 @@ func (s *Syncer) Start(ctx context.Context) (err error) { return fmt.Errorf("failed to initialize syncer state: %w", err) } - // Initialize handlers - s.daRetriever = NewDARetriever(s.daClient, s.cache, s.genesis, s.logger) + // Initialize handlers. DA and P2P share dsHandler so cross-path duplicates + // are deduped through doubleSignSeen and only reported once. + s.doubleSignSeen = newDoubleSignDedup() + dsHandler := s.handleDoubleSign + s.daRetriever = NewDARetriever(s.daClient, s.cache, s.genesis, s.logger, s.store, dsHandler) if s.config.Instrumentation.IsTracingEnabled() { s.daRetriever = WithTracingDARetriever(s.daRetriever) } s.fiRetriever = da.NewForcedInclusionRetriever(s.daClient, s.logger, s.config.DA.BlockTime.Duration, s.config.Instrumentation.IsTracingEnabled(), s.genesis.DAStartHeight, s.genesis.DAEpochForcedInclusion) s.fiRetriever.Start(ctx) - s.p2pHandler = NewP2PHandler(s.headerStore, s.dataStore, s.cache, s.genesis, s.logger) + s.p2pHandler = NewP2PHandler(s.headerStore, s.dataStore, s.cache, s.genesis, s.logger, s.store, dsHandler) currentHeight, initErr := s.store.Height(ctx) if initErr != nil { @@ -798,6 +804,8 @@ func (s *Syncer) trySyncNextBlockWithState(ctx context.Context, event *common.DA if !bytes.Equal(header.DataHash, common.DataHashForEmptyTxs) { s.cache.SetDataSeen(data.DACommitment().String(), newState.LastBlockHeight) } + // Subsequent alternates resolve against the persisted header. + s.cache.RemovePendingSignedHeader(header.Height()) if s.p2pHandler != nil { s.p2pHandler.SetProcessedHeight(newState.LastBlockHeight) @@ -1065,6 +1073,12 @@ func (s *Syncer) sendCriticalError(err error) { } } +// handleDoubleSign persists evidence, bumps the metric, and halts the syncer +// via sendCriticalError. Wired into the DA retriever and P2P handler. +func (s *Syncer) handleDoubleSign(ctx context.Context, ev *types.DoubleSignEvidence) { + _ = reportDoubleSign(ctx, s.store, s.metrics, s.logger, s.doubleSignSeen, s.sendCriticalError, ev) +} + // processPendingEvents fetches and processes pending events from cache // optimistically fetches the next events from cache until no matching heights are found func (s *Syncer) processPendingEvents(ctx context.Context) { diff --git a/block/internal/syncing/syncer_forced_inclusion_test.go b/block/internal/syncing/syncer_forced_inclusion_test.go index 3c15fde125..fe8372996b 100644 --- a/block/internal/syncing/syncer_forced_inclusion_test.go +++ b/block/internal/syncing/syncer_forced_inclusion_test.go @@ -77,7 +77,7 @@ func newForcedInclusionSyncer(t *testing.T, daStart, epochSize uint64) (*Syncer, subCh := make(chan datypes.SubscriptionEvent) client.On("Subscribe", mock.Anything, mock.Anything, mock.Anything).Return((<-chan datypes.SubscriptionEvent)(subCh), nil).Maybe() - daRetriever := NewDARetriever(client, cm, gen, zerolog.Nop()) + daRetriever := NewDARetriever(client, cm, gen, zerolog.Nop(), nil, nil) fiRetriever := da.NewForcedInclusionRetriever(client, zerolog.Nop(), cfg.DA.BlockTime.Duration, false, gen.DAStartHeight, gen.DAEpochForcedInclusion) t.Cleanup(fiRetriever.Stop) diff --git a/block/internal/syncing/syncer_test.go b/block/internal/syncing/syncer_test.go index 67c87e06ed..e8fcd0aac7 100644 --- a/block/internal/syncing/syncer_test.go +++ b/block/internal/syncing/syncer_test.go @@ -1070,7 +1070,7 @@ func TestProcessHeightEvent_TriggersAsyncDARetrieval(t *testing.T) { s.ctx = context.Background() // Create a real daRetriever to test priority queue - s.daRetriever = NewDARetriever(nil, cm, gen, zerolog.Nop()) + s.daRetriever = NewDARetriever(nil, cm, gen, zerolog.Nop(), nil, nil) s.daFollower = NewDAFollower(DAFollowerConfig{ Retriever: s.daRetriever, Logger: zerolog.Nop(), @@ -1139,7 +1139,7 @@ func TestProcessHeightEvent_RejectsUnreasonableDAHint(t *testing.T) { ) require.NoError(t, s.initializeState()) s.ctx = context.Background() - s.daRetriever = NewDARetriever(nil, cm, gen, zerolog.Nop()) + s.daRetriever = NewDARetriever(nil, cm, gen, zerolog.Nop(), nil, nil) s.daFollower = NewDAFollower(DAFollowerConfig{ Retriever: s.daRetriever, Logger: zerolog.Nop(), @@ -1208,7 +1208,7 @@ func TestProcessHeightEvent_AcceptsValidDAHint(t *testing.T) { ) require.NoError(t, s.initializeState()) s.ctx = context.Background() - s.daRetriever = NewDARetriever(nil, cm, gen, zerolog.Nop()) + s.daRetriever = NewDARetriever(nil, cm, gen, zerolog.Nop(), nil, nil) s.daFollower = NewDAFollower(DAFollowerConfig{ Retriever: s.daRetriever, Logger: zerolog.Nop(), @@ -1278,7 +1278,7 @@ func TestProcessHeightEvent_SkipsDAHintWhenAlreadyDAIncluded(t *testing.T) { ) require.NoError(t, s.initializeState()) s.ctx = context.Background() - s.daRetriever = NewDARetriever(nil, cm, gen, zerolog.Nop()) + s.daRetriever = NewDARetriever(nil, cm, gen, zerolog.Nop(), nil, nil) s.daFollower = NewDAFollower(DAFollowerConfig{ Retriever: s.daRetriever, Logger: zerolog.Nop(), @@ -1377,7 +1377,7 @@ func TestProcessHeightEvent_SkipsDAHintWhenBelowRetrieverCursor(t *testing.T) { s.ctx = context.Background() // Create a real daRetriever to test priority queue - s.daRetriever = NewDARetriever(nil, cm, gen, zerolog.Nop()) + s.daRetriever = NewDARetriever(nil, cm, gen, zerolog.Nop(), nil, nil) s.daFollower = NewDAFollower(DAFollowerConfig{ Retriever: s.daRetriever, Logger: zerolog.Nop(), diff --git a/pkg/store/keys.go b/pkg/store/keys.go index 02053bb849..578d300111 100644 --- a/pkg/store/keys.go +++ b/pkg/store/keys.go @@ -30,6 +30,10 @@ const ( // pruned state height in the store. LastPrunedStateHeightKey = "lst-prnd-s" + // DoubleSignEvidenceKey is the metadata key prefix for persisted double-sign + // evidence. Full keys are like: ds// + DoubleSignEvidenceKey = "ds" + headerPrefix = "h" dataPrefix = "d" signaturePrefix = "c" @@ -102,3 +106,9 @@ func GetHeightToDAHeightHeaderKey(height uint64) string { func GetHeightToDAHeightDataKey(height uint64) string { return HeightToDAHeightKey + "/" + strconv.FormatUint(height, 10) + "/d" } + +// GetDoubleSignEvidenceKey returns the metadata key for persisted double-sign +// evidence at the given height and alternate-header hash. +func GetDoubleSignEvidenceKey(height uint64, altHash types.Hash) string { + return DoubleSignEvidenceKey + "/" + strconv.FormatUint(height, 10) + "/" + altHash.String() +} diff --git a/proto/evnode/v1/evnode.proto b/proto/evnode/v1/evnode.proto index e60bd56e0d..39716126bd 100644 --- a/proto/evnode/v1/evnode.proto +++ b/proto/evnode/v1/evnode.proto @@ -121,3 +121,15 @@ message P2PData { repeated bytes txs = 2; optional uint64 da_height_hint = 3; } + +// DoubleSignEvidence records two validly-signed SignedHeaders at the same +// height produced by the sequencer. Persisted as proof of equivocation. +message DoubleSignEvidence { + uint64 height = 1; + SignedHeader first_header = 2; + SignedHeader alternate_header = 3; + int64 detected_at = 4; + // Ingestion source for each header: "p2p", "da", or "stored". + string first_source = 5; + string alternate_source = 6; +} diff --git a/types/double_sign_evidence.go b/types/double_sign_evidence.go new file mode 100644 index 0000000000..01723ee796 --- /dev/null +++ b/types/double_sign_evidence.go @@ -0,0 +1,110 @@ +package types + +import ( + "bytes" + "errors" + "fmt" + "time" + + "google.golang.org/protobuf/proto" + + pb "github.com/evstack/ev-node/types/pb/evnode/v1" +) + +// Ingestion source identifying which path observed a SignedHeader. +const ( + EvidenceSourceP2P = "p2p" + EvidenceSourceDA = "da" +) + +// DoubleSignEvidence records two validly-signed SignedHeaders at the same +// height produced by the sequencer. Persisted as proof of equivocation. +type DoubleSignEvidence struct { + Height uint64 + FirstHeader *SignedHeader + AlternateHeader *SignedHeader + DetectedAt time.Time + FirstSource string + AlternateSource string +} + +// ValidateBasic checks structural consistency of the evidence. +func (e *DoubleSignEvidence) ValidateBasic() error { + if e == nil { + return errors.New("evidence is nil") + } + if e.FirstHeader == nil || e.AlternateHeader == nil { + return errors.New("evidence requires both first and alternate headers") + } + if e.FirstHeader.Height() != e.Height || e.AlternateHeader.Height() != e.Height { + return fmt.Errorf("evidence height %d does not match both headers (%d, %d)", + e.Height, e.FirstHeader.Height(), e.AlternateHeader.Height()) + } + if bytes.Equal(e.FirstHeader.Hash(), e.AlternateHeader.Hash()) { + return errors.New("evidence headers have identical hash — no equivocation") + } + return nil +} + +// ToProto converts DoubleSignEvidence to protobuf representation. +func (e *DoubleSignEvidence) ToProto() (*pb.DoubleSignEvidence, error) { + if e == nil { + return nil, errors.New("evidence is nil") + } + first, err := e.FirstHeader.ToProto() + if err != nil { + return nil, fmt.Errorf("marshal first header: %w", err) + } + alt, err := e.AlternateHeader.ToProto() + if err != nil { + return nil, fmt.Errorf("marshal alternate header: %w", err) + } + return &pb.DoubleSignEvidence{ + Height: e.Height, + FirstHeader: first, + AlternateHeader: alt, + DetectedAt: e.DetectedAt.UnixNano(), + FirstSource: e.FirstSource, + AlternateSource: e.AlternateSource, + }, nil +} + +// FromProto fills DoubleSignEvidence from protobuf representation. +func (e *DoubleSignEvidence) FromProto(p *pb.DoubleSignEvidence) error { + if p == nil { + return errors.New("proto evidence is nil") + } + first := new(SignedHeader) + if err := first.FromProto(p.FirstHeader); err != nil { + return fmt.Errorf("unmarshal first header: %w", err) + } + alt := new(SignedHeader) + if err := alt.FromProto(p.AlternateHeader); err != nil { + return fmt.Errorf("unmarshal alternate header: %w", err) + } + e.Height = p.Height + e.FirstHeader = first + e.AlternateHeader = alt + e.DetectedAt = time.Unix(0, p.DetectedAt).UTC() + e.FirstSource = p.FirstSource + e.AlternateSource = p.AlternateSource + return nil +} + +// MarshalBinary encodes DoubleSignEvidence to protobuf bytes. +func (e *DoubleSignEvidence) MarshalBinary() ([]byte, error) { + p, err := e.ToProto() + if err != nil { + return nil, err + } + return proto.Marshal(p) +} + +// UnmarshalBinary decodes DoubleSignEvidence from protobuf bytes. +func (e *DoubleSignEvidence) UnmarshalBinary(data []byte) error { + p := new(pb.DoubleSignEvidence) + if err := proto.Unmarshal(data, p); err != nil { + return err + } + return e.FromProto(p) +} diff --git a/types/pb/evnode/v1/evnode.pb.go b/types/pb/evnode/v1/evnode.pb.go index b0a866e76e..8f34134fed 100644 --- a/types/pb/evnode/v1/evnode.pb.go +++ b/types/pb/evnode/v1/evnode.pb.go @@ -785,6 +785,93 @@ func (x *P2PData) GetDaHeightHint() uint64 { return 0 } +// DoubleSignEvidence records two validly-signed SignedHeaders at the same +// height produced by the sequencer. Persisted as proof of equivocation. +type DoubleSignEvidence struct { + state protoimpl.MessageState `protogen:"open.v1"` + Height uint64 `protobuf:"varint,1,opt,name=height,proto3" json:"height,omitempty"` + FirstHeader *SignedHeader `protobuf:"bytes,2,opt,name=first_header,json=firstHeader,proto3" json:"first_header,omitempty"` + AlternateHeader *SignedHeader `protobuf:"bytes,3,opt,name=alternate_header,json=alternateHeader,proto3" json:"alternate_header,omitempty"` + DetectedAt int64 `protobuf:"varint,4,opt,name=detected_at,json=detectedAt,proto3" json:"detected_at,omitempty"` + // Ingestion source for each header: "p2p", "da", or "stored". + FirstSource string `protobuf:"bytes,5,opt,name=first_source,json=firstSource,proto3" json:"first_source,omitempty"` + AlternateSource string `protobuf:"bytes,6,opt,name=alternate_source,json=alternateSource,proto3" json:"alternate_source,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DoubleSignEvidence) Reset() { + *x = DoubleSignEvidence{} + mi := &file_evnode_v1_evnode_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DoubleSignEvidence) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DoubleSignEvidence) ProtoMessage() {} + +func (x *DoubleSignEvidence) ProtoReflect() protoreflect.Message { + mi := &file_evnode_v1_evnode_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DoubleSignEvidence.ProtoReflect.Descriptor instead. +func (*DoubleSignEvidence) Descriptor() ([]byte, []int) { + return file_evnode_v1_evnode_proto_rawDescGZIP(), []int{11} +} + +func (x *DoubleSignEvidence) GetHeight() uint64 { + if x != nil { + return x.Height + } + return 0 +} + +func (x *DoubleSignEvidence) GetFirstHeader() *SignedHeader { + if x != nil { + return x.FirstHeader + } + return nil +} + +func (x *DoubleSignEvidence) GetAlternateHeader() *SignedHeader { + if x != nil { + return x.AlternateHeader + } + return nil +} + +func (x *DoubleSignEvidence) GetDetectedAt() int64 { + if x != nil { + return x.DetectedAt + } + return 0 +} + +func (x *DoubleSignEvidence) GetFirstSource() string { + if x != nil { + return x.FirstSource + } + return "" +} + +func (x *DoubleSignEvidence) GetAlternateSource() string { + if x != nil { + return x.AlternateSource + } + return "" +} + var File_evnode_v1_evnode_proto protoreflect.FileDescriptor const file_evnode_v1_evnode_proto_rawDesc = "" + @@ -846,7 +933,15 @@ const file_evnode_v1_evnode_proto_rawDesc = "" + "\bmetadata\x18\x01 \x01(\v2\x13.evnode.v1.MetadataR\bmetadata\x12\x10\n" + "\x03txs\x18\x02 \x03(\fR\x03txs\x12)\n" + "\x0eda_height_hint\x18\x03 \x01(\x04H\x00R\fdaHeightHint\x88\x01\x01B\x11\n" + - "\x0f_da_height_hintB/Z-github.com/evstack/ev-node/types/pb/evnode/v1b\x06proto3" + "\x0f_da_height_hint\"\x9b\x02\n" + + "\x12DoubleSignEvidence\x12\x16\n" + + "\x06height\x18\x01 \x01(\x04R\x06height\x12:\n" + + "\ffirst_header\x18\x02 \x01(\v2\x17.evnode.v1.SignedHeaderR\vfirstHeader\x12B\n" + + "\x10alternate_header\x18\x03 \x01(\v2\x17.evnode.v1.SignedHeaderR\x0falternateHeader\x12\x1f\n" + + "\vdetected_at\x18\x04 \x01(\x03R\n" + + "detectedAt\x12!\n" + + "\ffirst_source\x18\x05 \x01(\tR\vfirstSource\x12)\n" + + "\x10alternate_source\x18\x06 \x01(\tR\x0falternateSourceB/Z-github.com/evstack/ev-node/types/pb/evnode/v1b\x06proto3" var ( file_evnode_v1_evnode_proto_rawDescOnce sync.Once @@ -860,7 +955,7 @@ func file_evnode_v1_evnode_proto_rawDescGZIP() []byte { return file_evnode_v1_evnode_proto_rawDescData } -var file_evnode_v1_evnode_proto_msgTypes = make([]protoimpl.MessageInfo, 11) +var file_evnode_v1_evnode_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_evnode_v1_evnode_proto_goTypes = []any{ (*Version)(nil), // 0: evnode.v1.Version (*Header)(nil), // 1: evnode.v1.Header @@ -873,7 +968,8 @@ var file_evnode_v1_evnode_proto_goTypes = []any{ (*Vote)(nil), // 8: evnode.v1.Vote (*P2PSignedHeader)(nil), // 9: evnode.v1.P2PSignedHeader (*P2PData)(nil), // 10: evnode.v1.P2PData - (*timestamppb.Timestamp)(nil), // 11: google.protobuf.Timestamp + (*DoubleSignEvidence)(nil), // 11: evnode.v1.DoubleSignEvidence + (*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp } var file_evnode_v1_evnode_proto_depIdxs = []int32{ 0, // 0: evnode.v1.Header.version:type_name -> evnode.v1.Version @@ -884,15 +980,17 @@ var file_evnode_v1_evnode_proto_depIdxs = []int32{ 5, // 5: evnode.v1.Data.metadata:type_name -> evnode.v1.Metadata 6, // 6: evnode.v1.SignedData.data:type_name -> evnode.v1.Data 4, // 7: evnode.v1.SignedData.signer:type_name -> evnode.v1.Signer - 11, // 8: evnode.v1.Vote.timestamp:type_name -> google.protobuf.Timestamp + 12, // 8: evnode.v1.Vote.timestamp:type_name -> google.protobuf.Timestamp 1, // 9: evnode.v1.P2PSignedHeader.header:type_name -> evnode.v1.Header 4, // 10: evnode.v1.P2PSignedHeader.signer:type_name -> evnode.v1.Signer 5, // 11: evnode.v1.P2PData.metadata:type_name -> evnode.v1.Metadata - 12, // [12:12] is the sub-list for method output_type - 12, // [12:12] is the sub-list for method input_type - 12, // [12:12] is the sub-list for extension type_name - 12, // [12:12] is the sub-list for extension extendee - 0, // [0:12] is the sub-list for field type_name + 2, // 12: evnode.v1.DoubleSignEvidence.first_header:type_name -> evnode.v1.SignedHeader + 2, // 13: evnode.v1.DoubleSignEvidence.alternate_header:type_name -> evnode.v1.SignedHeader + 14, // [14:14] is the sub-list for method output_type + 14, // [14:14] is the sub-list for method input_type + 14, // [14:14] is the sub-list for extension type_name + 14, // [14:14] is the sub-list for extension extendee + 0, // [0:14] is the sub-list for field type_name } func init() { file_evnode_v1_evnode_proto_init() } @@ -908,7 +1006,7 @@ func file_evnode_v1_evnode_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_evnode_v1_evnode_proto_rawDesc), len(file_evnode_v1_evnode_proto_rawDesc)), NumEnums: 0, - NumMessages: 11, + NumMessages: 12, NumExtensions: 0, NumServices: 0, }, From f4bd83d184c780f34bd7631e548f7448f727148d Mon Sep 17 00:00:00 2001 From: Cael Rowley Date: Tue, 5 May 2026 18:05:15 +0200 Subject: [PATCH 02/10] chore(types): tighten DoubleSignEvidence validation and proto nil checks --- types/double_sign_evidence.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/types/double_sign_evidence.go b/types/double_sign_evidence.go index 01723ee796..5dbc224866 100644 --- a/types/double_sign_evidence.go +++ b/types/double_sign_evidence.go @@ -43,6 +43,9 @@ func (e *DoubleSignEvidence) ValidateBasic() error { if bytes.Equal(e.FirstHeader.Hash(), e.AlternateHeader.Hash()) { return errors.New("evidence headers have identical hash — no equivocation") } + if !bytes.Equal(e.FirstHeader.ProposerAddress, e.AlternateHeader.ProposerAddress) { + return errors.New("evidence headers have different proposers — not an equivocation") + } return nil } @@ -74,6 +77,9 @@ func (e *DoubleSignEvidence) FromProto(p *pb.DoubleSignEvidence) error { if p == nil { return errors.New("proto evidence is nil") } + if p.FirstHeader == nil || p.AlternateHeader == nil { + return errors.New("proto evidence missing first or alternate header") + } first := new(SignedHeader) if err := first.FromProto(p.FirstHeader); err != nil { return fmt.Errorf("unmarshal first header: %w", err) From efcd4d9ce10deee47ac4dc73e20e523dd4a7db40 Mon Sep 17 00:00:00 2001 From: Cael Rowley Date: Tue, 5 May 2026 18:07:54 +0200 Subject: [PATCH 03/10] refactor(types): add EvidenceSourceStored constant to replace magic string --- block/internal/syncing/doublesign.go | 2 +- block/internal/syncing/doublesign_branches_test.go | 2 +- block/internal/syncing/doublesign_test.go | 4 ++-- types/double_sign_evidence.go | 5 +++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/block/internal/syncing/doublesign.go b/block/internal/syncing/doublesign.go index 65002cfe50..3cdcb21965 100644 --- a/block/internal/syncing/doublesign.go +++ b/block/internal/syncing/doublesign.go @@ -54,7 +54,7 @@ func detectDoubleSign( if storedHeader == nil { return nil, nil } - return buildEvidenceFromPair(storedHeader, incoming, "stored", incomingSource), nil + return buildEvidenceFromPair(storedHeader, incoming, types.EvidenceSourceStored, incomingSource), nil } // buildEvidenceFromPair returns evidence for two SignedHeaders at the same diff --git a/block/internal/syncing/doublesign_branches_test.go b/block/internal/syncing/doublesign_branches_test.go index 4fcafeb6c5..7761fd1310 100644 --- a/block/internal/syncing/doublesign_branches_test.go +++ b/block/internal/syncing/doublesign_branches_test.go @@ -97,7 +97,7 @@ func TestDetectDoubleSign_FirstSourceStoredSentinel(t *testing.T) { ev, err := detectDoubleSign(context.Background(), env.store, env.cache, alt, types.EvidenceSourceP2P) require.NoError(t, err) require.NotNil(t, ev) - require.Equal(t, "stored", ev.FirstSource) + require.Equal(t, types.EvidenceSourceStored, ev.FirstSource) } // SetMetadata failures must include the canonical key so an operator can diff --git a/block/internal/syncing/doublesign_test.go b/block/internal/syncing/doublesign_test.go index 70117e31f1..32f10ee9f9 100644 --- a/block/internal/syncing/doublesign_test.go +++ b/block/internal/syncing/doublesign_test.go @@ -617,11 +617,11 @@ func TestSyncer_DoubleSignHaltsAndEmitsCriticalError(t *testing.T) { p2pEv := &types.DoubleSignEvidence{ Height: 1, FirstHeader: first, AlternateHeader: alt, - DetectedAt: time.Now(), FirstSource: "stored", AlternateSource: types.EvidenceSourceP2P, + DetectedAt: time.Now(), FirstSource: types.EvidenceSourceStored, AlternateSource: types.EvidenceSourceP2P, } daEv := &types.DoubleSignEvidence{ Height: 1, FirstHeader: first, AlternateHeader: alt, - DetectedAt: time.Now(), FirstSource: "stored", AlternateSource: types.EvidenceSourceDA, + DetectedAt: time.Now(), FirstSource: types.EvidenceSourceStored, AlternateSource: types.EvidenceSourceDA, } s.handleDoubleSign(context.Background(), p2pEv) diff --git a/types/double_sign_evidence.go b/types/double_sign_evidence.go index 5dbc224866..1a6e115072 100644 --- a/types/double_sign_evidence.go +++ b/types/double_sign_evidence.go @@ -13,8 +13,9 @@ import ( // Ingestion source identifying which path observed a SignedHeader. const ( - EvidenceSourceP2P = "p2p" - EvidenceSourceDA = "da" + EvidenceSourceP2P = "p2p" + EvidenceSourceDA = "da" + EvidenceSourceStored = "stored" ) // DoubleSignEvidence records two validly-signed SignedHeaders at the same From e810cf7f43da106f03098162710cd0a4fceb1a99 Mon Sep 17 00:00:00 2001 From: Cael Rowley Date: Wed, 6 May 2026 09:50:11 +0200 Subject: [PATCH 04/10] chore(types): harden DoubleSignEvidence (de)serialization --- .../syncing/doublesign_branches_test.go | 18 ++++++++++++++++++ block/internal/syncing/doublesign_test.go | 5 +++++ types/double_sign_evidence.go | 5 ++++- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/block/internal/syncing/doublesign_branches_test.go b/block/internal/syncing/doublesign_branches_test.go index 7761fd1310..ec99bfb3ac 100644 --- a/block/internal/syncing/doublesign_branches_test.go +++ b/block/internal/syncing/doublesign_branches_test.go @@ -230,6 +230,24 @@ func TestDoubleSignEvidence_FromProtoInnerHeaderError(t *testing.T) { require.Error(t, dst.FromProto(p)) } +// FromProto must reject partial-nil sub-messages (one set, one nil) to keep +// the (FirstHeader, AlternateHeader) pair invariant after deserialization. +func TestDoubleSignEvidence_FromProtoPartialNilHeader(t *testing.T) { + env := newDSTestEnv(t) + hdr := env.signHeaderAtHeight(5, 0x01) + hdrPB, err := hdr.ToProto() + require.NoError(t, err) + + t.Run("alternate nil", func(t *testing.T) { + dst := new(types.DoubleSignEvidence) + require.Error(t, dst.FromProto(&pb.DoubleSignEvidence{Height: 5, FirstHeader: hdrPB})) + }) + t.Run("first nil", func(t *testing.T) { + dst := new(types.DoubleSignEvidence) + require.Error(t, dst.FromProto(&pb.DoubleSignEvidence{Height: 5, AlternateHeader: hdrPB})) + }) +} + func TestDoubleSignEvidence_UnmarshalBinaryGarbage(t *testing.T) { dst := new(types.DoubleSignEvidence) require.Error(t, dst.UnmarshalBinary([]byte{0xff, 0xff, 0xff, 0xff})) diff --git a/block/internal/syncing/doublesign_test.go b/block/internal/syncing/doublesign_test.go index 32f10ee9f9..14916c7377 100644 --- a/block/internal/syncing/doublesign_test.go +++ b/block/internal/syncing/doublesign_test.go @@ -465,6 +465,11 @@ func TestDoubleSignEvidence_ValidateBasic(t *testing.T) { e := &types.DoubleSignEvidence{Height: 5, FirstHeader: first, AlternateHeader: first} require.Error(t, e.ValidateBasic()) }) + t.Run("proposer mismatch", func(t *testing.T) { + other := env.signHeaderWithOtherProposer(5, 0x02) + e := &types.DoubleSignEvidence{Height: 5, FirstHeader: first, AlternateHeader: other} + require.ErrorContains(t, e.ValidateBasic(), "different proposers") + }) t.Run("happy path", func(t *testing.T) { e := &types.DoubleSignEvidence{Height: 5, FirstHeader: first, AlternateHeader: alt} require.NoError(t, e.ValidateBasic()) diff --git a/types/double_sign_evidence.go b/types/double_sign_evidence.go index 1a6e115072..e883d0cc64 100644 --- a/types/double_sign_evidence.go +++ b/types/double_sign_evidence.go @@ -55,6 +55,9 @@ func (e *DoubleSignEvidence) ToProto() (*pb.DoubleSignEvidence, error) { if e == nil { return nil, errors.New("evidence is nil") } + if e.FirstHeader == nil || e.AlternateHeader == nil { + return nil, errors.New("evidence requires both first and alternate headers") + } first, err := e.FirstHeader.ToProto() if err != nil { return nil, fmt.Errorf("marshal first header: %w", err) @@ -111,7 +114,7 @@ func (e *DoubleSignEvidence) MarshalBinary() ([]byte, error) { func (e *DoubleSignEvidence) UnmarshalBinary(data []byte) error { p := new(pb.DoubleSignEvidence) if err := proto.Unmarshal(data, p); err != nil { - return err + return fmt.Errorf("proto unmarshal double sign evidence: %w", err) } return e.FromProto(p) } From 300adc2c14c3904ed893fb8019aba98747140644 Mon Sep 17 00:00:00 2001 From: Cael Rowley Date: Thu, 7 May 2026 21:50:06 +0200 Subject: [PATCH 05/10] refactor(syncing): centralize double-sign detection, drop reportDoubleSign callback --- block/internal/syncing/da_retriever.go | 38 ++---- block/internal/syncing/da_retriever_test.go | 2 +- block/internal/syncing/doublesign.go | 56 +++----- .../syncing/doublesign_branches_test.go | 71 ++++------ block/internal/syncing/doublesign_test.go | 125 ++++++++++-------- block/internal/syncing/p2p_handler.go | 34 ++--- .../syncing/p2p_handler_doublesign_test.go | 8 +- block/internal/syncing/p2p_handler_test.go | 2 +- block/internal/syncing/syncer.go | 32 ++++- .../syncing/syncer_forced_inclusion_test.go | 2 +- block/internal/syncing/syncer_test.go | 10 +- 11 files changed, 183 insertions(+), 197 deletions(-) diff --git a/block/internal/syncing/da_retriever.go b/block/internal/syncing/da_retriever.go index ca488e07b7..cd6fff34ba 100644 --- a/block/internal/syncing/da_retriever.go +++ b/block/internal/syncing/da_retriever.go @@ -15,7 +15,6 @@ import ( "github.com/evstack/ev-node/block/internal/da" datypes "github.com/evstack/ev-node/pkg/da/types" "github.com/evstack/ev-node/pkg/genesis" - "github.com/evstack/ev-node/pkg/store" "github.com/evstack/ev-node/types" pb "github.com/evstack/ev-node/types/pb/evnode/v1" ) @@ -35,8 +34,8 @@ type daRetriever struct { cache cache.CacheManager genesis genesis.Genesis logger zerolog.Logger - store store.Store - onDoubleSign doubleSignHandler // nil disables detection; the retriever aborts the batch on a positive + // detectDoubleSign aborts the batch when it returns true. Nil disables. + detectDoubleSign doubleSignDetector mu sync.Mutex // transient cache, only full event need to be passed to the syncer @@ -49,26 +48,23 @@ type daRetriever struct { strictMode bool } -// NewDARetriever creates a new DA retriever. Double-sign detection is disabled -// when st or onDoubleSign is nil. +// NewDARetriever creates a new DA retriever. func NewDARetriever( client da.Client, cache cache.CacheManager, genesis genesis.Genesis, logger zerolog.Logger, - st store.Store, - onDoubleSign doubleSignHandler, + detectDoubleSign doubleSignDetector, ) *daRetriever { return &daRetriever{ - client: client, - cache: cache, - genesis: genesis, - logger: logger.With().Str("component", "da_retriever").Logger(), - store: st, - onDoubleSign: onDoubleSign, - pendingHeaders: make(map[uint64]*types.SignedHeader), - pendingData: make(map[uint64]*types.Data), - strictMode: false, + client: client, + cache: cache, + genesis: genesis, + logger: logger.With().Str("component", "da_retriever").Logger(), + detectDoubleSign: detectDoubleSign, + pendingHeaders: make(map[uint64]*types.SignedHeader), + pendingData: make(map[uint64]*types.Data), + strictMode: false, } } @@ -181,14 +177,8 @@ func (r *daRetriever) processBlobs(ctx context.Context, blobs [][]byte, daHeight if header := r.tryDecodeHeader(bz, daHeight); header != nil { // Catches both in-batch alternates and alternates of already-persisted heights. - if r.store != nil && r.onDoubleSign != nil { - if ev, err := detectDoubleSign(ctx, r.store, r.cache, header, types.EvidenceSourceDA); err == nil && ev != nil { - r.onDoubleSign(ctx, ev) - return nil - } else if err != nil { - r.logger.Warn().Err(err).Uint64("height", header.Height()).Msg("double-sign detection error") - } - r.cache.SetPendingSignedHeader(header, types.EvidenceSourceDA) + if r.detectDoubleSign != nil && r.detectDoubleSign(ctx, header, types.EvidenceSourceDA) { + return nil } if _, ok := r.pendingHeaders[header.Height()]; ok { diff --git a/block/internal/syncing/da_retriever_test.go b/block/internal/syncing/da_retriever_test.go index fe7718eed6..087a4743ea 100644 --- a/block/internal/syncing/da_retriever_test.go +++ b/block/internal/syncing/da_retriever_test.go @@ -52,7 +52,7 @@ func newTestDARetriever(t *testing.T, mockClient *mocks.MockClient, cfg config.C mockClient.On("GetForcedInclusionNamespace").Return([]byte(nil)).Maybe() mockClient.On("HasForcedInclusionNamespace").Return(false).Maybe() - return NewDARetriever(mockClient, cm, gen, zerolog.Nop(), nil, nil) + return NewDARetriever(mockClient, cm, gen, zerolog.Nop(), nil) } // makeSignedDataBytes builds SignedData containing the provided Data and returns its binary encoding diff --git a/block/internal/syncing/doublesign.go b/block/internal/syncing/doublesign.go index 3cdcb21965..15ed46195b 100644 --- a/block/internal/syncing/doublesign.go +++ b/block/internal/syncing/doublesign.go @@ -19,42 +19,33 @@ import ( // ErrDoubleSign is returned when two validly-signed SignedHeaders are observed at the same height. var ErrDoubleSign = errors.New("double-sign detected") -// doubleSignHandler is fired when an equivocation is confirmed. It persists -// evidence, bumps metrics, and halts the syncer. -type doubleSignHandler func(ctx context.Context, evidence *types.DoubleSignEvidence) - -// detectDoubleSign compares incoming against the first-seen SignedHeader at -// the same height (cache then store) and returns non-nil evidence when their -// hashes differ. Caller must verify proposer + signature first. -func detectDoubleSign( +// doubleSignDetector reports an observed header for equivocation detection. +// Returns true on a confirmed double-sign so the caller can abort. +type doubleSignDetector func(ctx context.Context, header *types.SignedHeader, source string) bool + +// firstObservation returns the first-seen SignedHeader at this height, +// preferring the cache over the store. Returns (nil, "", nil) when none. +func firstObservation( ctx context.Context, st store.Store, cm cache.CacheManager, - incoming *types.SignedHeader, - incomingSource string, -) (*types.DoubleSignEvidence, error) { - if incoming == nil { - return nil, errors.New("incoming header is nil") - } - height := incoming.Height() - - // Cache wins over store: the cached entry is the literal first observation - // and carries the original FirstSource. + height uint64, +) (*types.SignedHeader, string, error) { if cached, source, ok := cm.GetPendingSignedHeader(height); ok { - return buildEvidenceFromPair(cached, incoming, source, incomingSource), nil + return cached, source, nil } - storedHeader, storeErr := st.GetHeader(ctx, height) - if storeErr != nil { - if store.IsNotFound(storeErr) { - return nil, nil + storedHeader, err := st.GetHeader(ctx, height) + if err != nil { + if store.IsNotFound(err) { + return nil, "", nil } - return nil, fmt.Errorf("lookup stored header at %d: %w", height, storeErr) + return nil, "", fmt.Errorf("lookup stored header at %d: %w", height, err) } if storedHeader == nil { - return nil, nil + return nil, "", nil } - return buildEvidenceFromPair(storedHeader, incoming, types.EvidenceSourceStored, incomingSource), nil + return storedHeader, types.EvidenceSourceStored, nil } // buildEvidenceFromPair returns evidence for two SignedHeaders at the same @@ -99,16 +90,15 @@ func persistEvidence(ctx context.Context, st store.Store, ev *types.DoubleSignEv return nil } -// reportDoubleSign persists evidence, logs, bumps the metric (once per -// distinct alternate hash via seen), fires criticalErr, and returns the -// wrapped ErrDoubleSign for the caller to propagate as the halt cause. +// reportDoubleSign persists evidence and bumps the metric, deduping by +// (height, altHash). Returns a wrapped ErrDoubleSign on first sighting, +// nil when already seen. func reportDoubleSign( ctx context.Context, st store.Store, metrics *common.Metrics, logger zerolog.Logger, seen *doubleSignDedup, - criticalErr func(error), ev *types.DoubleSignEvidence, ) error { altHashStr := ev.AlternateHeader.Hash().String() @@ -144,15 +134,11 @@ func reportDoubleSign( Str("evidence_key", key). Msg("DOUBLE-SIGN DETECTED — sequencer equivocation; halting syncer") - halt := fmt.Errorf( + return fmt.Errorf( "double-sign detected at height %d: sequencer signed conflicting headers %s and %s. "+ "Evidence persisted at metadata key %s. Manual intervention required: %w", ev.Height, firstHashStr, altHashStr, key, ErrDoubleSign, ) - if criticalErr != nil { - criticalErr(halt) - } - return halt } // doubleSignDedup collapses (height, altHash) duplicates so the same diff --git a/block/internal/syncing/doublesign_branches_test.go b/block/internal/syncing/doublesign_branches_test.go index ec99bfb3ac..1acbf0cecf 100644 --- a/block/internal/syncing/doublesign_branches_test.go +++ b/block/internal/syncing/doublesign_branches_test.go @@ -56,48 +56,40 @@ func (nilHeaderStore) GetHeader(context.Context, uint64) (*types.SignedHeader, e return nil, nil } -func TestDetectDoubleSign_NilIncomingReturnsError(t *testing.T) { - env := newDSTestEnv(t) - ev, err := detectDoubleSign(context.Background(), env.store, env.cache, nil, types.EvidenceSourceP2P) - require.Error(t, err) - require.Nil(t, ev) -} - // A non-NotFound store failure must be surfaced, not swallowed. -func TestDetectDoubleSign_StoreErrorWrapped(t *testing.T) { +func TestFirstObservation_StoreErrorWrapped(t *testing.T) { env := newDSTestEnv(t) wrapped := &errStore{Store: env.store, getHeaderErr: errors.New("backend down")} - alt := env.signHeaderAtHeight(5, 0x01) - ev, err := detectDoubleSign(context.Background(), wrapped, env.cache, alt, types.EvidenceSourceP2P) + prior, priorSource, err := firstObservation(context.Background(), wrapped, env.cache, 5) require.Error(t, err) - require.Nil(t, ev) + require.Nil(t, prior) + require.Empty(t, priorSource) require.Contains(t, err.Error(), "lookup stored header") require.ErrorContains(t, err, "backend down") } -func TestDetectDoubleSign_StoredHeaderNilDefensive(t *testing.T) { +func TestFirstObservation_StoredHeaderNilDefensive(t *testing.T) { env := newDSTestEnv(t) wrapped := nilHeaderStore{Store: env.store} - alt := env.signHeaderAtHeight(5, 0x01) - ev, err := detectDoubleSign(context.Background(), wrapped, env.cache, alt, types.EvidenceSourceP2P) + prior, priorSource, err := firstObservation(context.Background(), wrapped, env.cache, 5) require.NoError(t, err) - require.Nil(t, ev) + require.Nil(t, prior) + require.Empty(t, priorSource) } -// Store-path detections must use the "stored" sentinel as FirstSource so -// downstream consumers can disambiguate it from in-flight observations. -func TestDetectDoubleSign_FirstSourceStoredSentinel(t *testing.T) { +// Store-path observations must use the "stored" sentinel as the source so +// downstream consumers can disambiguate them from in-flight observations. +func TestFirstObservation_StoredSourceSentinel(t *testing.T) { env := newDSTestEnv(t) first := env.signHeaderAtHeight(5, 0x01) env.saveHeader(first) - alt := env.signHeaderAtHeight(5, 0x02) - ev, err := detectDoubleSign(context.Background(), env.store, env.cache, alt, types.EvidenceSourceP2P) + prior, priorSource, err := firstObservation(context.Background(), env.store, env.cache, first.Height()) require.NoError(t, err) - require.NotNil(t, ev) - require.Equal(t, types.EvidenceSourceStored, ev.FirstSource) + require.NotNil(t, prior) + require.Equal(t, types.EvidenceSourceStored, priorSource) } // SetMetadata failures must include the canonical key so an operator can @@ -118,7 +110,7 @@ func TestPersistEvidence_StoreError(t *testing.T) { } // Persistence failure must not break the halt contract: metric still -// increments, criticalErr still fires, returned error still wraps ErrDoubleSign. +// increments and the returned error still wraps ErrDoubleSign. func TestReportDoubleSign_PersistFailureLoggedNotBlocking(t *testing.T) { env := newDSTestEnv(t) wrapped := &errStore{Store: env.store, setMetadataErr: errors.New("disk full")} @@ -132,16 +124,12 @@ func TestReportDoubleSign_PersistFailureLoggedNotBlocking(t *testing.T) { metrics := common.NopMetrics() metrics.DoubleSignsDetected = &counterCtr{n: &dsCount} - var fired atomic.Pointer[error] - crit := func(err error) { fired.Store(&err) } - halt := reportDoubleSign(context.Background(), wrapped, metrics, zerolog.Nop(), - newDoubleSignDedup(), crit, ev) + newDoubleSignDedup(), ev) require.Error(t, halt) require.ErrorIs(t, halt, ErrDoubleSign) require.Equal(t, int64(1), dsCount.Load()) - require.NotNil(t, fired.Load()) } // Dedup is keyed on (height, altHash), so two distinct alts at the same @@ -163,12 +151,11 @@ func TestReportDoubleSign_TwoDistinctAltsAtSameHeight(t *testing.T) { metrics.DoubleSignsDetected = &counterCtr{n: &dsCount} seen := newDoubleSignDedup() - noopCrit := func(error) {} require.Error(t, reportDoubleSign(context.Background(), env.store, metrics, - zerolog.Nop(), seen, noopCrit, ev1)) + zerolog.Nop(), seen, ev1)) require.Error(t, reportDoubleSign(context.Background(), env.store, metrics, - zerolog.Nop(), seen, noopCrit, ev2)) + zerolog.Nop(), seen, ev2)) require.Equal(t, int64(2), dsCount.Load()) @@ -189,14 +176,14 @@ func TestReportDoubleSign_NilSeenAndNilGuards(t *testing.T) { t.Run("nil seen still halts", func(t *testing.T) { halt := reportDoubleSign(context.Background(), env.store, common.NopMetrics(), - zerolog.Nop(), nil, func(error) {}, ev) + zerolog.Nop(), nil, ev) require.Error(t, halt) require.ErrorIs(t, halt, ErrDoubleSign) }) t.Run("nil metrics still halts", func(t *testing.T) { halt := reportDoubleSign(context.Background(), env.store, nil, - zerolog.Nop(), newDoubleSignDedup(), func(error) {}, ev) + zerolog.Nop(), newDoubleSignDedup(), ev) require.Error(t, halt) require.ErrorIs(t, halt, ErrDoubleSign) }) @@ -205,14 +192,7 @@ func TestReportDoubleSign_NilSeenAndNilGuards(t *testing.T) { m := common.NopMetrics() m.DoubleSignsDetected = nil halt := reportDoubleSign(context.Background(), env.store, m, - zerolog.Nop(), newDoubleSignDedup(), func(error) {}, ev) - require.Error(t, halt) - require.ErrorIs(t, halt, ErrDoubleSign) - }) - - t.Run("nil criticalErr still halts", func(t *testing.T) { - halt := reportDoubleSign(context.Background(), env.store, common.NopMetrics(), - zerolog.Nop(), newDoubleSignDedup(), nil, ev) + zerolog.Nop(), newDoubleSignDedup(), ev) require.Error(t, halt) require.ErrorIs(t, halt, ErrDoubleSign) }) @@ -270,7 +250,7 @@ func TestDARetriever_AbortsBatchOnDetection(t *testing.T) { mockClient := newMockDAClient(t) - r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.store, env.onDouble) + r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.detectDoubleSign) events := r.ProcessBlobs(context.Background(), [][]byte{firstBin, altBin, nextBin}, 100) @@ -290,7 +270,7 @@ func TestDARetriever_DetectorErrorWarnAndContinue(t *testing.T) { mockClient := newMockDAClient(t) - r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), wrapped, env.onDouble) + r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.makeDetectDoubleSign(wrapped)) _ = r.ProcessBlobs(context.Background(), [][]byte{bin}, 100) @@ -324,7 +304,7 @@ func TestDARetriever_StrictModeEnvelopeDoubleSign(t *testing.T) { mockClient := newMockDAClient(t) - r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.store, env.onDouble) + r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.detectDoubleSign) events := r.ProcessBlobs(context.Background(), [][]byte{firstBin, altBin}, 100) require.Empty(t, events) @@ -348,7 +328,7 @@ func TestDARetriever_SetsPendingSignedHeaderOnFirstObservation(t *testing.T) { mockClient := newMockDAClient(t) - r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.store, env.onDouble) + r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.detectDoubleSign) _ = r.ProcessBlobs(context.Background(), [][]byte{bin}, 100) got, src, ok := env.cache.GetPendingSignedHeader(5) @@ -356,4 +336,3 @@ func TestDARetriever_SetsPendingSignedHeaderOnFirstObservation(t *testing.T) { require.Equal(t, first.Hash().String(), got.Hash().String()) require.Equal(t, types.EvidenceSourceDA, src) } - diff --git a/block/internal/syncing/doublesign_test.go b/block/internal/syncing/doublesign_test.go index 14916c7377..d2cd191fdf 100644 --- a/block/internal/syncing/doublesign_test.go +++ b/block/internal/syncing/doublesign_test.go @@ -30,16 +30,16 @@ import ( // dsTestEnv bundles the store, cache, genesis and signer used by the // double-sign tests. type dsTestEnv struct { - t *testing.T - store store.Store - cache cache.CacheManager - gen genesis.Genesis - addr []byte - pub crypto.PubKey - signer signerpkg.Signer - chainID string - capLock atomic.Pointer[[]*types.DoubleSignEvidence] - onDouble doubleSignHandler + t *testing.T + store store.Store + cache cache.CacheManager + gen genesis.Genesis + addr []byte + pub crypto.PubKey + signer signerpkg.Signer + chainID string + capLock atomic.Pointer[[]*types.DoubleSignEvidence] + detectDoubleSign doubleSignDetector } func newDSTestEnv(t *testing.T) *dsTestEnv { @@ -69,20 +69,35 @@ func newDSTestEnv(t *testing.T) *dsTestEnv { } empty := []*types.DoubleSignEvidence{} env.capLock.Store(&empty) - env.onDouble = func(ctx context.Context, ev *types.DoubleSignEvidence) { - require.NoError(t, ev.ValidateBasic()) - for { - cur := env.capLock.Load() - next := append([]*types.DoubleSignEvidence(nil), *cur...) - next = append(next, ev) - if env.capLock.CompareAndSwap(cur, &next) { - break + env.detectDoubleSign = env.makeDetectDoubleSign(st) + return env +} + +// makeDetectDoubleSign mimics Syncer.detectDoubleSign for tests, capturing +// evidence into env.capLock instead of halting. +func (e *dsTestEnv) makeDetectDoubleSign(st store.Store) doubleSignDetector { + return func(ctx context.Context, header *types.SignedHeader, source string) bool { + prior, priorSource, err := firstObservation(ctx, st, e.cache, header.Height()) + if err != nil { + e.cache.SetPendingSignedHeader(header, source) + return false + } + if ev := buildEvidenceFromPair(prior, header, priorSource, source); ev != nil { + require.NoError(e.t, ev.ValidateBasic()) + for { + cur := e.capLock.Load() + next := append([]*types.DoubleSignEvidence(nil), *cur...) + next = append(next, ev) + if e.capLock.CompareAndSwap(cur, &next) { + break + } } + require.NoError(e.t, persistEvidence(ctx, st, ev)) + return true } - // Persist immediately so tests can verify round-trip decoding. - require.NoError(t, persistEvidence(ctx, st, ev)) + e.cache.SetPendingSignedHeader(header, source) + return false } - return env } func (e *dsTestEnv) captured() []*types.DoubleSignEvidence { @@ -125,7 +140,7 @@ func (e *dsTestEnv) saveHeader(hdr *types.SignedHeader) { require.NoError(e.t, batch.Commit()) } -func TestDetectDoubleSign_TwoValidHeadersSameHeight(t *testing.T) { +func TestFirstObservation_StoredHeaderProducesEvidence(t *testing.T) { env := newDSTestEnv(t) first := env.signHeaderAtHeight(5, 0x01) env.saveHeader(first) @@ -133,8 +148,12 @@ func TestDetectDoubleSign_TwoValidHeadersSameHeight(t *testing.T) { alt := env.signHeaderAtHeight(5, 0x02) require.NotEqual(t, first.Hash().String(), alt.Hash().String()) - ev, err := detectDoubleSign(context.Background(), env.store, env.cache, alt, types.EvidenceSourceP2P) + prior, priorSource, err := firstObservation(context.Background(), env.store, env.cache, alt.Height()) require.NoError(t, err) + require.Equal(t, first.Hash().String(), prior.Hash().String()) + require.Equal(t, types.EvidenceSourceStored, priorSource) + + ev := buildEvidenceFromPair(prior, alt, priorSource, types.EvidenceSourceP2P) require.NotNil(t, ev) require.Equal(t, uint64(5), ev.Height) require.Equal(t, first.Hash().String(), ev.FirstHeader.Hash().String()) @@ -153,22 +172,23 @@ func TestDetectDoubleSign_TwoValidHeadersSameHeight(t *testing.T) { require.Equal(t, ev.AlternateSource, decoded.AlternateSource) } -func TestDetectDoubleSign_IdenticalHashNoEvidence(t *testing.T) { +func TestFirstObservation_IdenticalHashNoEvidence(t *testing.T) { env := newDSTestEnv(t) first := env.signHeaderAtHeight(5, 0x01) env.saveHeader(first) - ev, err := detectDoubleSign(context.Background(), env.store, env.cache, first, types.EvidenceSourceP2P) + prior, priorSource, err := firstObservation(context.Background(), env.store, env.cache, first.Height()) require.NoError(t, err) - require.Nil(t, ev) + require.NotNil(t, prior) + require.Nil(t, buildEvidenceFromPair(prior, first, priorSource, types.EvidenceSourceP2P)) } -func TestDetectDoubleSign_NoPriorRecordReturnsNil(t *testing.T) { +func TestFirstObservation_NoPriorRecordReturnsNil(t *testing.T) { env := newDSTestEnv(t) - alt := env.signHeaderAtHeight(5, 0x01) - ev, err := detectDoubleSign(context.Background(), env.store, env.cache, alt, types.EvidenceSourceP2P) + prior, priorSource, err := firstObservation(context.Background(), env.store, env.cache, 5) require.NoError(t, err) - require.Nil(t, ev) + require.Nil(t, prior) + require.Empty(t, priorSource) } func TestBuildEvidenceFromPair_ProposerMismatch(t *testing.T) { @@ -226,17 +246,14 @@ func TestReportDoubleSign_PersistsAndHalts(t *testing.T) { metrics := common.NopMetrics() seen := newDoubleSignDedup() - var halted atomic.Pointer[error] - crit := func(err error) { halted.Store(&err) } - halt1 := reportDoubleSign(context.Background(), env.store, metrics, zerolog.Nop(), seen, crit, ev) + halt1 := reportDoubleSign(context.Background(), env.store, metrics, zerolog.Nop(), seen, ev) require.Error(t, halt1) + require.ErrorIs(t, halt1, ErrDoubleSign) // Second call must be a no-op via dedup. - halted.Store(nil) - halt2 := reportDoubleSign(context.Background(), env.store, metrics, zerolog.Nop(), seen, crit, ev) + halt2 := reportDoubleSign(context.Background(), env.store, metrics, zerolog.Nop(), seen, ev) require.NoError(t, halt2) - require.Nil(t, halted.Load()) key := store.GetDoubleSignEvidenceKey(ev.Height, ev.AlternateHeader.Hash()) blob, err := env.store.GetMetadata(context.Background(), key) @@ -263,7 +280,7 @@ func TestP2PHandler_DoubleSignTriggersCriticalError(t *testing.T) { Return(&types.P2PSignedHeader{SignedHeader: alt}, nil). Once() - h := NewP2PHandler(headerStoreMock, dataStoreMock, env.cache, env.gen, zerolog.Nop(), env.store, env.onDouble) + h := NewP2PHandler(headerStoreMock, dataStoreMock, env.cache, env.gen, zerolog.Nop(), env.detectDoubleSign) ch := make(chan common.DAHeightEvent, 1) require.NoError(t, h.ProcessHeight(context.Background(), 5, ch)) @@ -300,7 +317,7 @@ func TestP2PHandler_ProposerMismatchIsNotEvidence(t *testing.T) { Return(&types.P2PSignedHeader{SignedHeader: badHdr}, nil). Once() - h := NewP2PHandler(headerStoreMock, dataStoreMock, env.cache, env.gen, zerolog.Nop(), env.store, env.onDouble) + h := NewP2PHandler(headerStoreMock, dataStoreMock, env.cache, env.gen, zerolog.Nop(), env.detectDoubleSign) ch := make(chan common.DAHeightEvent, 1) err := h.ProcessHeight(context.Background(), 5, ch) @@ -324,7 +341,7 @@ func TestDARetriever_DoubleSignSamePendingBatch(t *testing.T) { mockClient.On("GetHeaderNamespace").Return([]byte("ns")).Maybe() mockClient.On("GetDataNamespace").Return([]byte("ns")).Maybe() - r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.store, env.onDouble) + r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.detectDoubleSign) events := r.ProcessBlobs(context.Background(), [][]byte{firstBin, altBin}, 100) require.Empty(t, events) @@ -349,7 +366,7 @@ func TestDARetriever_DoubleSignAcrossBatches(t *testing.T) { mockClient.On("GetHeaderNamespace").Return([]byte("ns")).Maybe() mockClient.On("GetDataNamespace").Return([]byte("ns")).Maybe() - r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.store, env.onDouble) + r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.detectDoubleSign) events := r.ProcessBlobs(context.Background(), [][]byte{altBin}, 101) require.Empty(t, events) @@ -372,7 +389,7 @@ func TestDARetriever_BenignDuplicateAcrossBatchesDoesNotFire(t *testing.T) { mockClient.On("GetHeaderNamespace").Return([]byte("ns")).Maybe() mockClient.On("GetDataNamespace").Return([]byte("ns")).Maybe() - r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.store, env.onDouble) + r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.detectDoubleSign) _ = r.ProcessBlobs(context.Background(), [][]byte{sameBin}, 101) require.Empty(t, env.captured()) } @@ -396,7 +413,7 @@ func TestDARetriever_LegacyForgedSignatureRejected(t *testing.T) { mockClient.On("GetHeaderNamespace").Return([]byte("ns")).Maybe() mockClient.On("GetDataNamespace").Return([]byte("ns")).Maybe() - r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.store, env.onDouble) + r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.detectDoubleSign) require.Nil(t, r.tryDecodeHeader(bin, 100)) _, _, ok := env.cache.GetPendingSignedHeader(5) @@ -404,7 +421,7 @@ func TestDARetriever_LegacyForgedSignatureRejected(t *testing.T) { } // Detection must trigger from a pending cache entry too, before persistence. -func TestDetectDoubleSign_PendingCacheHitProducesEvidence(t *testing.T) { +func TestFirstObservation_PendingCacheHitProducesEvidence(t *testing.T) { env := newDSTestEnv(t) first := env.signHeaderAtHeight(5, 0x01) @@ -412,8 +429,12 @@ func TestDetectDoubleSign_PendingCacheHitProducesEvidence(t *testing.T) { // First header is in-flight, not yet on disk. alt := env.signHeaderAtHeight(5, 0x02) - ev, err := detectDoubleSign(context.Background(), env.store, env.cache, alt, types.EvidenceSourceP2P) + prior, priorSource, err := firstObservation(context.Background(), env.store, env.cache, alt.Height()) require.NoError(t, err) + require.Equal(t, first.Hash().String(), prior.Hash().String()) + require.Equal(t, types.EvidenceSourceDA, priorSource) + + ev := buildEvidenceFromPair(prior, alt, priorSource, types.EvidenceSourceP2P) require.NotNil(t, ev) require.Equal(t, first.Hash().String(), ev.FirstHeader.Hash().String()) require.Equal(t, alt.Hash().String(), ev.AlternateHeader.Hash().String()) @@ -421,28 +442,28 @@ func TestDetectDoubleSign_PendingCacheHitProducesEvidence(t *testing.T) { require.Equal(t, types.EvidenceSourceP2P, ev.AlternateSource) } -func TestDetectDoubleSign_PendingCacheBenignDuplicate(t *testing.T) { +func TestFirstObservation_PendingCacheBenignDuplicate(t *testing.T) { env := newDSTestEnv(t) first := env.signHeaderAtHeight(5, 0x01) env.cache.SetPendingSignedHeader(first, types.EvidenceSourceDA) - ev, err := detectDoubleSign(context.Background(), env.store, env.cache, first, types.EvidenceSourceP2P) + prior, priorSource, err := firstObservation(context.Background(), env.store, env.cache, first.Height()) require.NoError(t, err) - require.Nil(t, ev) + require.NotNil(t, prior) + require.Nil(t, buildEvidenceFromPair(prior, first, priorSource, types.EvidenceSourceP2P)) } -func TestDetectDoubleSign_PendingEvictedAfterRemoval(t *testing.T) { +func TestFirstObservation_PendingEvictedAfterRemoval(t *testing.T) { env := newDSTestEnv(t) first := env.signHeaderAtHeight(5, 0x01) env.cache.SetPendingSignedHeader(first, types.EvidenceSourceDA) env.cache.RemovePendingSignedHeader(5) - alt := env.signHeaderAtHeight(5, 0x02) - ev, err := detectDoubleSign(context.Background(), env.store, env.cache, alt, types.EvidenceSourceP2P) + prior, _, err := firstObservation(context.Background(), env.store, env.cache, first.Height()) require.NoError(t, err) - require.Nil(t, ev) + require.Nil(t, prior) } func TestDoubleSignEvidence_ValidateBasic(t *testing.T) { @@ -516,7 +537,7 @@ func TestDARetriever_DoubleSignEvidenceHasMatchingProposers(t *testing.T) { mockClient.On("GetHeaderNamespace").Return([]byte("ns")).Maybe() mockClient.On("GetDataNamespace").Return([]byte("ns")).Maybe() - r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.store, env.onDouble) + r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.detectDoubleSign) _ = r.ProcessBlobs(context.Background(), [][]byte{firstBin, altBin}, 100) captured := env.captured() diff --git a/block/internal/syncing/p2p_handler.go b/block/internal/syncing/p2p_handler.go index 1fc839f150..412e3509e3 100644 --- a/block/internal/syncing/p2p_handler.go +++ b/block/internal/syncing/p2p_handler.go @@ -12,7 +12,6 @@ import ( "github.com/evstack/ev-node/block/internal/cache" "github.com/evstack/ev-node/block/internal/common" "github.com/evstack/ev-node/pkg/genesis" - "github.com/evstack/ev-node/pkg/store" "github.com/evstack/ev-node/types" ) @@ -34,31 +33,28 @@ type P2PHandler struct { genesis genesis.Genesis logger zerolog.Logger - store store.Store - onDoubleSign doubleSignHandler // nil disables detection + // detectDoubleSign returns true on a confirmed double-sign. Nil disables. + detectDoubleSign doubleSignDetector processedHeight atomic.Uint64 } -// NewP2PHandler creates a new P2P handler. Double-sign detection is disabled -// when st or onDoubleSign is nil. +// NewP2PHandler creates a new P2P handler. func NewP2PHandler( headerStore header.Store[*types.P2PSignedHeader], dataStore header.Store[*types.P2PData], cache cache.CacheManager, genesis genesis.Genesis, logger zerolog.Logger, - st store.Store, - onDoubleSign doubleSignHandler, + detectDoubleSign doubleSignDetector, ) *P2PHandler { return &P2PHandler{ - headerStore: headerStore, - dataStore: dataStore, - cache: cache, - genesis: genesis, - logger: logger.With().Str("component", "p2p_handler").Logger(), - store: st, - onDoubleSign: onDoubleSign, + headerStore: headerStore, + dataStore: dataStore, + cache: cache, + genesis: genesis, + logger: logger.With().Str("component", "p2p_handler").Logger(), + detectDoubleSign: detectDoubleSign, } } @@ -82,7 +78,7 @@ func (h *P2PHandler) SetProcessedHeight(height uint64) { // When double-sign detection is enabled, the processedHeight short-circuit is // deferred so alternates at already-processed heights still trigger detection. func (h *P2PHandler) ProcessHeight(ctx context.Context, height uint64, heightInCh chan<- common.DAHeightEvent) error { - if h.store == nil || h.onDoubleSign == nil { + if h.detectDoubleSign == nil { if height <= h.processedHeight.Load() { return nil } @@ -101,18 +97,14 @@ func (h *P2PHandler) ProcessHeight(ctx context.Context, height uint64, heightInC } // ValidateBasic is the precondition for treating an alternate as evidence. - if h.store != nil && h.onDoubleSign != nil { + if h.detectDoubleSign != nil { if err := p2pHeader.SignedHeader.ValidateBasic(); err != nil { h.logger.Debug().Uint64("height", height).Err(err).Msg("invalid signed header from P2P") return err } - if ev, derr := detectDoubleSign(ctx, h.store, h.cache, p2pHeader.SignedHeader, types.EvidenceSourceP2P); derr == nil && ev != nil { - h.onDoubleSign(ctx, ev) + if h.detectDoubleSign(ctx, p2pHeader.SignedHeader, types.EvidenceSourceP2P) { return nil - } else if derr != nil { - h.logger.Warn().Err(derr).Uint64("height", height).Msg("double-sign detection error") } - h.cache.SetPendingSignedHeader(p2pHeader.SignedHeader, types.EvidenceSourceP2P) if height <= h.processedHeight.Load() { return nil } diff --git a/block/internal/syncing/p2p_handler_doublesign_test.go b/block/internal/syncing/p2p_handler_doublesign_test.go index dc5669687b..55ab3e8a8a 100644 --- a/block/internal/syncing/p2p_handler_doublesign_test.go +++ b/block/internal/syncing/p2p_handler_doublesign_test.go @@ -33,7 +33,7 @@ func TestP2PHandler_DetectsAtAlreadyProcessedHeight(t *testing.T) { Once() h := NewP2PHandler(headerStoreMock, dataStoreMock, env.cache, env.gen, - zerolog.Nop(), env.store, env.onDouble) + zerolog.Nop(), env.detectDoubleSign) h.SetProcessedHeight(5) @@ -60,7 +60,7 @@ func TestP2PHandler_LegacyShortCircuitWhenDetectionDisabled(t *testing.T) { dataStoreMock := extmocks.NewMockStore[*types.P2PData](t) h := NewP2PHandler(headerStoreMock, dataStoreMock, env.cache, env.gen, - zerolog.Nop(), nil, nil) + zerolog.Nop(), nil) h.SetProcessedHeight(5) @@ -97,7 +97,7 @@ func TestP2PHandler_InvalidSigRejectedBeforeDetector(t *testing.T) { Once() h := NewP2PHandler(headerStoreMock, dataStoreMock, env.cache, env.gen, - zerolog.Nop(), env.store, env.onDouble) + zerolog.Nop(), env.detectDoubleSign) ch := make(chan common.DAHeightEvent, 1) err = h.ProcessHeight(context.Background(), 5, ch) @@ -132,7 +132,7 @@ func TestP2PHandler_SetsPendingSignedHeaderOnFirstObservation(t *testing.T) { Once() h := NewP2PHandler(headerStoreMock, dataStoreMock, env.cache, env.gen, - zerolog.Nop(), env.store, env.onDouble) + zerolog.Nop(), env.detectDoubleSign) ch := make(chan common.DAHeightEvent, 1) require.NoError(t, h.ProcessHeight(context.Background(), 5, ch)) diff --git a/block/internal/syncing/p2p_handler_test.go b/block/internal/syncing/p2p_handler_test.go index 8feda8c2d6..016e69fbb8 100644 --- a/block/internal/syncing/p2p_handler_test.go +++ b/block/internal/syncing/p2p_handler_test.go @@ -90,7 +90,7 @@ func setupP2P(t *testing.T) *P2PTestData { cacheManager, err := cache.NewManager(cfg, st, zerolog.Nop()) require.NoError(t, err, "failed to create cache manager") - handler := NewP2PHandler(headerStoreMock, dataStoreMock, cacheManager, gen, zerolog.Nop(), nil, nil) + handler := NewP2PHandler(headerStoreMock, dataStoreMock, cacheManager, gen, zerolog.Nop(), nil) return &P2PTestData{ Handler: handler, HeaderStore: headerStoreMock, diff --git a/block/internal/syncing/syncer.go b/block/internal/syncing/syncer.go index f9d4570a2b..ad8856189d 100644 --- a/block/internal/syncing/syncer.go +++ b/block/internal/syncing/syncer.go @@ -183,18 +183,17 @@ func (s *Syncer) Start(ctx context.Context) (err error) { return fmt.Errorf("failed to initialize syncer state: %w", err) } - // Initialize handlers. DA and P2P share dsHandler so cross-path duplicates - // are deduped through doubleSignSeen and only reported once. + // Initialize handlers. DA and P2P share s.detectDoubleSign so cross-path + // duplicates are deduped through doubleSignSeen and only reported once. s.doubleSignSeen = newDoubleSignDedup() - dsHandler := s.handleDoubleSign - s.daRetriever = NewDARetriever(s.daClient, s.cache, s.genesis, s.logger, s.store, dsHandler) + s.daRetriever = NewDARetriever(s.daClient, s.cache, s.genesis, s.logger, s.detectDoubleSign) if s.config.Instrumentation.IsTracingEnabled() { s.daRetriever = WithTracingDARetriever(s.daRetriever) } s.fiRetriever = da.NewForcedInclusionRetriever(s.daClient, s.logger, s.config.DA.BlockTime.Duration, s.config.Instrumentation.IsTracingEnabled(), s.genesis.DAStartHeight, s.genesis.DAEpochForcedInclusion) s.fiRetriever.Start(ctx) - s.p2pHandler = NewP2PHandler(s.headerStore, s.dataStore, s.cache, s.genesis, s.logger, s.store, dsHandler) + s.p2pHandler = NewP2PHandler(s.headerStore, s.dataStore, s.cache, s.genesis, s.logger, s.detectDoubleSign) currentHeight, initErr := s.store.Height(ctx) if initErr != nil { @@ -1074,9 +1073,28 @@ func (s *Syncer) sendCriticalError(err error) { } // handleDoubleSign persists evidence, bumps the metric, and halts the syncer -// via sendCriticalError. Wired into the DA retriever and P2P handler. +// via sendCriticalError on the first equivocation sighting. func (s *Syncer) handleDoubleSign(ctx context.Context, ev *types.DoubleSignEvidence) { - _ = reportDoubleSign(ctx, s.store, s.metrics, s.logger, s.doubleSignSeen, s.sendCriticalError, ev) + if err := reportDoubleSign(ctx, s.store, s.metrics, s.logger, s.doubleSignSeen, ev); err != nil { + s.sendCriticalError(err) + } +} + +// detectDoubleSign records the observation and returns true when header equivocates +// with a prior observation at the same height, halting the syncer as a side effect. +func (s *Syncer) detectDoubleSign(ctx context.Context, header *types.SignedHeader, source string) bool { + if header == nil { + return false + } + prior, priorSource, err := firstObservation(ctx, s.store, s.cache, header.Height()) + if err != nil { + s.logger.Warn().Err(err).Uint64("height", header.Height()).Msg("double-sign detection error") + } else if ev := buildEvidenceFromPair(prior, header, priorSource, source); ev != nil { + s.handleDoubleSign(ctx, ev) + return true + } + s.cache.SetPendingSignedHeader(header, source) + return false } // processPendingEvents fetches and processes pending events from cache diff --git a/block/internal/syncing/syncer_forced_inclusion_test.go b/block/internal/syncing/syncer_forced_inclusion_test.go index fe8372996b..553198db34 100644 --- a/block/internal/syncing/syncer_forced_inclusion_test.go +++ b/block/internal/syncing/syncer_forced_inclusion_test.go @@ -77,7 +77,7 @@ func newForcedInclusionSyncer(t *testing.T, daStart, epochSize uint64) (*Syncer, subCh := make(chan datypes.SubscriptionEvent) client.On("Subscribe", mock.Anything, mock.Anything, mock.Anything).Return((<-chan datypes.SubscriptionEvent)(subCh), nil).Maybe() - daRetriever := NewDARetriever(client, cm, gen, zerolog.Nop(), nil, nil) + daRetriever := NewDARetriever(client, cm, gen, zerolog.Nop(), nil) fiRetriever := da.NewForcedInclusionRetriever(client, zerolog.Nop(), cfg.DA.BlockTime.Duration, false, gen.DAStartHeight, gen.DAEpochForcedInclusion) t.Cleanup(fiRetriever.Stop) diff --git a/block/internal/syncing/syncer_test.go b/block/internal/syncing/syncer_test.go index e8fcd0aac7..fe441cafff 100644 --- a/block/internal/syncing/syncer_test.go +++ b/block/internal/syncing/syncer_test.go @@ -1070,7 +1070,7 @@ func TestProcessHeightEvent_TriggersAsyncDARetrieval(t *testing.T) { s.ctx = context.Background() // Create a real daRetriever to test priority queue - s.daRetriever = NewDARetriever(nil, cm, gen, zerolog.Nop(), nil, nil) + s.daRetriever = NewDARetriever(nil, cm, gen, zerolog.Nop(), nil) s.daFollower = NewDAFollower(DAFollowerConfig{ Retriever: s.daRetriever, Logger: zerolog.Nop(), @@ -1139,7 +1139,7 @@ func TestProcessHeightEvent_RejectsUnreasonableDAHint(t *testing.T) { ) require.NoError(t, s.initializeState()) s.ctx = context.Background() - s.daRetriever = NewDARetriever(nil, cm, gen, zerolog.Nop(), nil, nil) + s.daRetriever = NewDARetriever(nil, cm, gen, zerolog.Nop(), nil) s.daFollower = NewDAFollower(DAFollowerConfig{ Retriever: s.daRetriever, Logger: zerolog.Nop(), @@ -1208,7 +1208,7 @@ func TestProcessHeightEvent_AcceptsValidDAHint(t *testing.T) { ) require.NoError(t, s.initializeState()) s.ctx = context.Background() - s.daRetriever = NewDARetriever(nil, cm, gen, zerolog.Nop(), nil, nil) + s.daRetriever = NewDARetriever(nil, cm, gen, zerolog.Nop(), nil) s.daFollower = NewDAFollower(DAFollowerConfig{ Retriever: s.daRetriever, Logger: zerolog.Nop(), @@ -1278,7 +1278,7 @@ func TestProcessHeightEvent_SkipsDAHintWhenAlreadyDAIncluded(t *testing.T) { ) require.NoError(t, s.initializeState()) s.ctx = context.Background() - s.daRetriever = NewDARetriever(nil, cm, gen, zerolog.Nop(), nil, nil) + s.daRetriever = NewDARetriever(nil, cm, gen, zerolog.Nop(), nil) s.daFollower = NewDAFollower(DAFollowerConfig{ Retriever: s.daRetriever, Logger: zerolog.Nop(), @@ -1377,7 +1377,7 @@ func TestProcessHeightEvent_SkipsDAHintWhenBelowRetrieverCursor(t *testing.T) { s.ctx = context.Background() // Create a real daRetriever to test priority queue - s.daRetriever = NewDARetriever(nil, cm, gen, zerolog.Nop(), nil, nil) + s.daRetriever = NewDARetriever(nil, cm, gen, zerolog.Nop(), nil) s.daFollower = NewDAFollower(DAFollowerConfig{ Retriever: s.daRetriever, Logger: zerolog.Nop(), From da54c68377826771ec7d44848fb5ac9298ad2873 Mon Sep 17 00:00:00 2001 From: Cael Rowley Date: Fri, 8 May 2026 11:51:28 +0200 Subject: [PATCH 06/10] test(syncing): add integration coverage for double-sign halt pipeline --- .../doublesign_syncer_integration_test.go | 317 ++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 block/internal/syncing/doublesign_syncer_integration_test.go diff --git a/block/internal/syncing/doublesign_syncer_integration_test.go b/block/internal/syncing/doublesign_syncer_integration_test.go new file mode 100644 index 0000000000..104661b4a2 --- /dev/null +++ b/block/internal/syncing/doublesign_syncer_integration_test.go @@ -0,0 +1,317 @@ +package syncing + +import ( + "bytes" + "context" + "sync/atomic" + "testing" + "time" + + ds "github.com/ipfs/go-datastore" + dssync "github.com/ipfs/go-datastore/sync" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/rs/zerolog" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/evstack/ev-node/block/internal/cache" + "github.com/evstack/ev-node/block/internal/common" + "github.com/evstack/ev-node/pkg/config" + "github.com/evstack/ev-node/pkg/genesis" + signerpkg "github.com/evstack/ev-node/pkg/signer" + "github.com/evstack/ev-node/pkg/store" + testmocks "github.com/evstack/ev-node/test/mocks" + extmocks "github.com/evstack/ev-node/test/mocks/external" + "github.com/evstack/ev-node/types" +) + +// integrationHarness wires the Syncer the way Start() does for tests +// to drive headers through the real entry points and assert on the halt pipeline. +type integrationHarness struct { + t *testing.T + syncer *Syncer + store store.Store + cache cache.CacheManager + gen genesis.Genesis + addr []byte + pub crypto.PubKey + signer signerpkg.Signer + errCh chan error + dsCount *atomic.Int64 +} + +func newIntegrationHarness(t *testing.T) *integrationHarness { + t.Helper() + memDS := dssync.MutexWrap(ds.NewMapDatastore()) + st := store.New(memDS) + + cm, err := cache.NewManager(config.DefaultConfig(), st, zerolog.Nop()) + require.NoError(t, err) + + addr, pub, signer := buildSyncTestSigner(t) + cfg := config.DefaultConfig() + gen := genesis.Genesis{ + ChainID: "syncer-ds-integration", InitialHeight: 1, + StartTime: time.Now().Add(-time.Second), ProposerAddress: addr, + } + + mockExec := testmocks.NewMockExecutor(t) + mockExec.EXPECT(). + InitChain(mock.Anything, mock.Anything, uint64(1), gen.ChainID). + Return([]byte("app0"), nil).Once() + + mockHeaderStore := extmocks.NewMockStore[*types.P2PSignedHeader](t) + mockHeaderStore.EXPECT().Height().Return(uint64(0)).Maybe() + mockDataStore := extmocks.NewMockStore[*types.P2PData](t) + mockDataStore.EXPECT().Height().Return(uint64(0)).Maybe() + + metrics := common.NopMetrics() + var dsCount atomic.Int64 + metrics.DoubleSignsDetected = &counterCtr{n: &dsCount} + + errCh := make(chan error, 4) + s := NewSyncer( + st, mockExec, nil, cm, metrics, cfg, gen, + mockHeaderStore, mockDataStore, zerolog.Nop(), + common.DefaultBlockOptions(), errCh, nil, + ) + require.NoError(t, s.initializeState()) + s.doubleSignSeen = newDoubleSignDedup() // normally set up by Start() + + return &integrationHarness{ + t: t, + syncer: s, + store: st, + cache: cm, + gen: gen, + addr: addr, + pub: pub, + signer: signer, + errCh: errCh, + dsCount: &dsCount, + } +} + +func (h *integrationHarness) sign(height uint64, variant byte) *types.SignedHeader { + h.t.Helper() + // Vary AppHash AND LastHeaderHash by variant so distinct variants always + // produce distinct hashes regardless of timing or pool state. + _, hdr := makeSignedHeaderBytes(h.t, h.gen.ChainID, height, h.addr, h.pub, h.signer, + []byte{variant, variant, variant}, nil, []byte{variant}) + return hdr +} + +// persistHeader writes the header to the store as if it had been synced. +func (h *integrationHarness) persistHeader(hdr *types.SignedHeader) { + h.t.Helper() + batch, err := h.store.NewBatch(context.Background()) + require.NoError(h.t, err) + require.NoError(h.t, batch.SaveBlockData(hdr, &types.Data{ + Metadata: &types.Metadata{ChainID: h.gen.ChainID, Height: hdr.Height(), Time: hdr.BaseHeader.Time}, + }, &hdr.Signature)) + require.NoError(h.t, batch.SetHeight(hdr.Height())) + require.NoError(h.t, batch.Commit()) +} + +// newDARetriever builds a daRetriever wired to the harness's syncer. +func (h *integrationHarness) newDARetriever() *daRetriever { + mockClient := testmocks.NewMockClient(h.t) + mockClient.On("GetHeaderNamespace").Return([]byte("ns")).Maybe() + mockClient.On("GetDataNamespace").Return([]byte("ns")).Maybe() + return NewDARetriever(mockClient, h.cache, h.gen, zerolog.Nop(), h.syncer.detectDoubleSign) +} + +// newP2PHandler builds a P2PHandler with the header store mocked to return hdr at the given height. +func (h *integrationHarness) newP2PHandler(height uint64, hdr *types.SignedHeader) (*P2PHandler, chan common.DAHeightEvent) { + headerStore := extmocks.NewMockStore[*types.P2PSignedHeader](h.t) + dataStore := extmocks.NewMockStore[*types.P2PData](h.t) + headerStore.EXPECT(). + GetByHeight(mock.Anything, height). + Return(&types.P2PSignedHeader{SignedHeader: hdr}, nil). + Once() + p2p := NewP2PHandler(headerStore, dataStore, h.cache, h.gen, zerolog.Nop(), h.syncer.detectDoubleSign) + return p2p, make(chan common.DAHeightEvent, 1) +} + +// requireHalted asserts the full halt pipeline fired exactly once. +func (h *integrationHarness) requireHalted(altHash []byte, height uint64) { + h.t.Helper() + select { + case got := <-h.errCh: + require.ErrorIs(h.t, got, ErrDoubleSign) + case <-time.After(time.Second): + h.t.Fatal("timed out waiting for critical error on errCh") + } + require.True(h.t, h.syncer.hasCriticalError.Load()) + require.Equal(h.t, int64(1), h.dsCount.Load()) + + blob, err := h.store.GetMetadata(context.Background(), store.GetDoubleSignEvidenceKey(height, altHash)) + require.NoError(h.t, err) + require.NotEmpty(h.t, blob) +} + +// Two conflicting headers in the same DA blob batch must halt the syncer. +func TestSyncerIntegration_InBatchDA_HaltsViaRealRetrieverPipeline(t *testing.T) { + h := newIntegrationHarness(t) + r := h.newDARetriever() + + first := h.sign(5, 0x01) + alt := h.sign(5, 0x02) + require.NotEqual(t, first.Hash().String(), alt.Hash().String()) + + firstBin, err := first.MarshalBinary() + require.NoError(t, err) + altBin, err := alt.MarshalBinary() + require.NoError(t, err) + + events := r.ProcessBlobs(context.Background(), [][]byte{firstBin, altBin}, 100) + require.Empty(t, events) + + h.requireHalted(alt.Hash(), 5) +} + +// An alternate in a later DA batch is detected against the persisted canonical. +func TestSyncerIntegration_CrossBatchDA_HaltsViaStoreLookup(t *testing.T) { + h := newIntegrationHarness(t) + r := h.newDARetriever() + + first := h.sign(5, 0x01) + h.persistHeader(first) + + alt := h.sign(5, 0x02) + altBin, err := alt.MarshalBinary() + require.NoError(t, err) + + events := r.ProcessBlobs(context.Background(), [][]byte{altBin}, 101) + require.Empty(t, events) + + h.requireHalted(alt.Hash(), 5) +} + +// An alternate via P2P must halt when the canonical was first observed via DA. +func TestSyncerIntegration_CrossSource_DAFirstThenP2P_Halts(t *testing.T) { + h := newIntegrationHarness(t) + r := h.newDARetriever() + + first := h.sign(7, 0x01) + firstBin, err := first.MarshalBinary() + require.NoError(t, err) + _ = r.ProcessBlobs(context.Background(), [][]byte{firstBin}, 100) + + cached, source, ok := h.cache.GetPendingSignedHeader(7) + require.True(t, ok) + require.True(t, bytes.Equal(cached.Hash(), first.Hash())) + require.Equal(t, types.EvidenceSourceDA, source) + + alt := h.sign(7, 0x02) + p2p, ch := h.newP2PHandler(7, alt) + require.NoError(t, p2p.ProcessHeight(context.Background(), 7, ch)) + require.Empty(t, ch) + + h.requireHalted(alt.Hash(), 7) +} + +// An alternate via DA must halt when the canonical was first observed via P2P. +func TestSyncerIntegration_CrossSource_P2PFirstThenDA_Halts(t *testing.T) { + h := newIntegrationHarness(t) + + first := h.sign(7, 0x01) + p2p, ch := h.newP2PHandler(7, first) + p2p.SetProcessedHeight(7) + require.NoError(t, p2p.ProcessHeight(context.Background(), 7, ch)) + require.Empty(t, ch) + + cached, source, ok := h.cache.GetPendingSignedHeader(7) + require.True(t, ok) + require.True(t, bytes.Equal(cached.Hash(), first.Hash())) + require.Equal(t, types.EvidenceSourceP2P, source) + + alt := h.sign(7, 0x02) + altBin, err := alt.MarshalBinary() + require.NoError(t, err) + + r := h.newDARetriever() + events := r.ProcessBlobs(context.Background(), [][]byte{altBin}, 100) + require.Empty(t, events) + + h.requireHalted(alt.Hash(), 7) +} + +// An alternate via P2P must halt when the canonical was already persisted. +func TestSyncerIntegration_CrossSource_StoreFirstThenP2P_Halts(t *testing.T) { + h := newIntegrationHarness(t) + + first := h.sign(5, 0x01) + h.persistHeader(first) + + alt := h.sign(5, 0x02) + p2p, ch := h.newP2PHandler(5, alt) + require.NoError(t, p2p.ProcessHeight(context.Background(), 5, ch)) + require.Empty(t, ch) + + h.requireHalted(alt.Hash(), 5) +} + +// Identical bytes seen twice in the same DA batch must not halt. +func TestSyncerIntegration_BenignDuplicate_InBatch_DoesNotHalt(t *testing.T) { + h := newIntegrationHarness(t) + r := h.newDARetriever() + + hdr := h.sign(5, 0x01) + bin, err := hdr.MarshalBinary() + require.NoError(t, err) + + _ = r.ProcessBlobs(context.Background(), [][]byte{bin, bin}, 100) + + require.False(t, h.syncer.hasCriticalError.Load()) + require.Equal(t, int64(0), h.dsCount.Load()) + require.Empty(t, h.errCh) +} + +// Re-publishing the canonical at a different DA height must not halt. +func TestSyncerIntegration_BenignDuplicate_AcrossBatches_DoesNotHalt(t *testing.T) { + h := newIntegrationHarness(t) + r := h.newDARetriever() + + first := h.sign(5, 0x01) + h.persistHeader(first) + + bin, err := first.MarshalBinary() + require.NoError(t, err) + _ = r.ProcessBlobs(context.Background(), [][]byte{bin}, 101) + + require.False(t, h.syncer.hasCriticalError.Load()) + require.Equal(t, int64(0), h.dsCount.Load()) + require.Empty(t, h.errCh) +} + +// The same (height, altHash) seen twice must report only once. +func TestSyncerIntegration_DuplicateAlternates_DedupedToOneHalt(t *testing.T) { + h := newIntegrationHarness(t) + r := h.newDARetriever() + + first := h.sign(11, 0x01) + alt := h.sign(11, 0x02) + firstBin, err := first.MarshalBinary() + require.NoError(t, err) + altBin, err := alt.MarshalBinary() + require.NoError(t, err) + + _ = r.ProcessBlobs(context.Background(), [][]byte{firstBin, altBin}, 100) + _ = r.ProcessBlobs(context.Background(), [][]byte{altBin}, 101) + + require.Equal(t, int64(1), h.dsCount.Load()) + + timeout := time.After(50 * time.Millisecond) + count := 0 +loop: + for { + select { + case <-h.errCh: + count++ + case <-timeout: + break loop + } + } + require.Equal(t, 1, count) +} From 778e487d329c709d15e2b42be97ceec276bfca8d Mon Sep 17 00:00:00 2001 From: Cael Rowley Date: Fri, 8 May 2026 12:56:21 +0200 Subject: [PATCH 07/10] refactor: address double-sign review feedback --- block/internal/syncing/doublesign.go | 8 ++------ block/internal/syncing/syncer.go | 3 ++- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/block/internal/syncing/doublesign.go b/block/internal/syncing/doublesign.go index 15ed46195b..af2d29ae62 100644 --- a/block/internal/syncing/doublesign.go +++ b/block/internal/syncing/doublesign.go @@ -105,16 +105,12 @@ func reportDoubleSign( firstHashStr := ev.FirstHeader.Hash().String() key := store.GetDoubleSignEvidenceKey(ev.Height, ev.AlternateHeader.Hash()) - // Persist on every call: idempotent, and a retry covers a transient - // failure on the first attempt. - persistErr := persistEvidence(ctx, st, ev) - if seen != nil && !seen.markSeen(ev.Height, altHashStr) { return nil } - if persistErr != nil { - logger.Error().Err(persistErr). + if err := persistEvidence(ctx, st, ev); err != nil { + logger.Error().Err(err). Uint64("height", ev.Height). Str("first_hash", firstHashStr). Str("alternate_hash", altHashStr). diff --git a/block/internal/syncing/syncer.go b/block/internal/syncing/syncer.go index ad8856189d..d167b39dc4 100644 --- a/block/internal/syncing/syncer.go +++ b/block/internal/syncing/syncer.go @@ -1088,7 +1088,8 @@ func (s *Syncer) detectDoubleSign(ctx context.Context, header *types.SignedHeade } prior, priorSource, err := firstObservation(ctx, s.store, s.cache, header.Height()) if err != nil { - s.logger.Warn().Err(err).Uint64("height", header.Height()).Msg("double-sign detection error") + // Detection bypassed for this observation, still cached below so a later arrival can match against this header + s.logger.Error().Err(err).Uint64("height", header.Height()).Msg("double-sign detection bypassed") } else if ev := buildEvidenceFromPair(prior, header, priorSource, source); ev != nil { s.handleDoubleSign(ctx, ev) return true From dacba211a7e4e46810b13b6f556d7c44a17aedd1 Mon Sep 17 00:00:00 2001 From: Cael Rowley Date: Fri, 8 May 2026 16:00:16 +0200 Subject: [PATCH 08/10] fix: go lint issues --- block/internal/cache/manager_test.go | 6 +++--- block/internal/syncing/doublesign_test.go | 4 ++-- block/internal/syncing/p2p_handler.go | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/block/internal/cache/manager_test.go b/block/internal/cache/manager_test.go index 2222ac6e6a..9c68cad5ed 100644 --- a/block/internal/cache/manager_test.go +++ b/block/internal/cache/manager_test.go @@ -563,11 +563,11 @@ func TestManager_PendingSignedHeader_NilHeaderIgnored(t *testing.T) { _, _, ok := m.GetPendingSignedHeader(5) require.False(t, ok) - real := signedHeaderForHeight(5, 0x01) - m.SetPendingSignedHeader(real, "p2p") + hdr := signedHeaderForHeight(5, 0x01) + m.SetPendingSignedHeader(hdr, "p2p") got, _, ok := m.GetPendingSignedHeader(5) require.True(t, ok) - require.Equal(t, real.Hash().String(), got.Hash().String()) + require.Equal(t, hdr.Hash().String(), got.Hash().String()) } func TestManager_GetPendingSignedHeader_Miss(t *testing.T) { diff --git a/block/internal/syncing/doublesign_test.go b/block/internal/syncing/doublesign_test.go index d2cd191fdf..ed6255472b 100644 --- a/block/internal/syncing/doublesign_test.go +++ b/block/internal/syncing/doublesign_test.go @@ -542,8 +542,8 @@ func TestDARetriever_DoubleSignEvidenceHasMatchingProposers(t *testing.T) { captured := env.captured() require.Len(t, captured, 1) - require.Equal(t, env.gen.ProposerAddress, []byte(captured[0].FirstHeader.ProposerAddress)) - require.Equal(t, env.gen.ProposerAddress, []byte(captured[0].AlternateHeader.ProposerAddress)) + require.Equal(t, env.gen.ProposerAddress, captured[0].FirstHeader.ProposerAddress) + require.Equal(t, env.gen.ProposerAddress, captured[0].AlternateHeader.ProposerAddress) } func TestSyncer_EvictsPendingHeaderOnPersist(t *testing.T) { diff --git a/block/internal/syncing/p2p_handler.go b/block/internal/syncing/p2p_handler.go index 412e3509e3..085542d5aa 100644 --- a/block/internal/syncing/p2p_handler.go +++ b/block/internal/syncing/p2p_handler.go @@ -98,7 +98,7 @@ func (h *P2PHandler) ProcessHeight(ctx context.Context, height uint64, heightInC // ValidateBasic is the precondition for treating an alternate as evidence. if h.detectDoubleSign != nil { - if err := p2pHeader.SignedHeader.ValidateBasic(); err != nil { + if err := p2pHeader.ValidateBasic(); err != nil { h.logger.Debug().Uint64("height", height).Err(err).Msg("invalid signed header from P2P") return err } From f4b1520bed9cc478d6a56845a3361509b19a06cc Mon Sep 17 00:00:00 2001 From: Cael Rowley Date: Wed, 20 May 2026 15:52:34 +0200 Subject: [PATCH 09/10] test(P2P): skip TestSequencerRecoveryFromP2P due to recovery race (#3330) --- node/sequencer_recovery_integration_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/node/sequencer_recovery_integration_test.go b/node/sequencer_recovery_integration_test.go index 03e4972416..7803d0302e 100644 --- a/node/sequencer_recovery_integration_test.go +++ b/node/sequencer_recovery_integration_test.go @@ -111,6 +111,14 @@ func TestSequencerRecoveryFromDA(t *testing.T) { // 4. Starts a recovery sequencer with P2P peer pointing to the fullnode // 5. Verifies the recovery node catches up from both DA and P2P before producing new blocks func TestSequencerRecoveryFromP2P(t *testing.T) { + // Skip: the recovery flow has a race condition where the recovery sequencer may start + // producing blocks before P2P catchup completes. When the DA retriever later receives + // the original blocks, double-sign detection correctly identifies equivocation (same key + // signed different headers at the same height). The fix belongs in the recovery flow + // (ensuring catchup completes before block production), not in double-sign detection. + // TODO(#3330): fix the recovery race condition and re-enable this test. + t.Skip("skipped: recovery flow race triggers legitimate double-sign detection") + genesis, genesisValidatorKey, _ := types.GetGenesisWithPrivkey("test-chain") remoteSigner, err := signer.NewNoopSigner(genesisValidatorKey) require.NoError(t, err) From 47c84a03ff83a62cdead96ff636ca56049625dca Mon Sep 17 00:00:00 2001 From: Cael Rowley Date: Fri, 5 Jun 2026 19:09:39 +0200 Subject: [PATCH 10/10] refactor(syncing): simplify double-sign detection by removing evidence persistence and checking against applied headers --- block/internal/cache/manager.go | 95 +- block/internal/cache/manager_test.go | 201 ---- block/internal/syncing/da_retriever.go | 57 +- block/internal/syncing/doublesign.go | 135 +-- .../syncing/doublesign_branches_test.go | 338 ------- .../doublesign_syncer_integration_test.go | 317 ------- block/internal/syncing/doublesign_test.go | 872 +++++++----------- block/internal/syncing/p2p_handler.go | 38 +- .../syncing/p2p_handler_doublesign_test.go | 144 --- block/internal/syncing/p2p_handler_test.go | 2 +- block/internal/syncing/syncer.go | 102 +- node/sequencer_recovery_integration_test.go | 21 +- pkg/config/config.go | 6 + pkg/config/config_test.go | 3 +- pkg/config/defaults.go | 1 + pkg/store/keys.go | 10 - proto/evnode/v1/evnode.proto | 12 - types/double_sign_evidence.go | 120 --- types/pb/evnode/v1/evnode.pb.go | 118 +-- 19 files changed, 476 insertions(+), 2116 deletions(-) delete mode 100644 block/internal/syncing/doublesign_branches_test.go delete mode 100644 block/internal/syncing/doublesign_syncer_integration_test.go delete mode 100644 block/internal/syncing/p2p_handler_doublesign_test.go delete mode 100644 types/double_sign_evidence.go diff --git a/block/internal/cache/manager.go b/block/internal/cache/manager.go index 2a19f7104c..50759b904a 100644 --- a/block/internal/cache/manager.go +++ b/block/internal/cache/manager.go @@ -44,11 +44,6 @@ type CacheManager interface { SetHeaderDAIncluded(hash string, daHeight uint64, blockHeight uint64) RemoveHeaderDAIncluded(hash string) - // Pending signed header operations (in-flight, pre-persistence) - SetPendingSignedHeader(h *types.SignedHeader, source string) - GetPendingSignedHeader(blockHeight uint64) (*types.SignedHeader, string, bool) - RemovePendingSignedHeader(blockHeight uint64) - // Data operations IsDataSeen(hash string) bool SetDataSeen(hash string, blockHeight uint64) @@ -98,24 +93,17 @@ type Manager interface { var _ Manager = (*implementation)(nil) type implementation struct { - headerCache *Cache - dataCache *Cache - txCache *Cache - txTimestamps *sync.Map // map[string]time.Time - pendingEvents map[uint64]*common.DAHeightEvent - pendingMu sync.Mutex - pendingHeaders *PendingHeaders - pendingData *PendingData - pendingSignedHeaders map[uint64]pendingSignedHeader - pendingSignedHeadersMu sync.RWMutex - store store.Store - config config.Config - logger zerolog.Logger -} - -type pendingSignedHeader struct { - header *types.SignedHeader - source string + headerCache *Cache + dataCache *Cache + txCache *Cache + txTimestamps *sync.Map // map[string]time.Time + pendingEvents map[uint64]*common.DAHeightEvent + pendingMu sync.Mutex + pendingHeaders *PendingHeaders + pendingData *PendingData + store store.Store + config config.Config + logger zerolog.Logger } // NewManager creates a new Manager, restoring or clearing persisted state as configured. @@ -135,17 +123,16 @@ func NewManager(cfg config.Config, st store.Store, logger zerolog.Logger) (Manag } impl := &implementation{ - headerCache: headerCache, - dataCache: dataCache, - txCache: txCache, - txTimestamps: new(sync.Map), - pendingEvents: make(map[uint64]*common.DAHeightEvent), - pendingHeaders: pendingHeaders, - pendingData: pendingData, - pendingSignedHeaders: make(map[uint64]pendingSignedHeader), - store: st, - config: cfg, - logger: logger, + headerCache: headerCache, + dataCache: dataCache, + txCache: txCache, + txTimestamps: new(sync.Map), + pendingEvents: make(map[uint64]*common.DAHeightEvent), + pendingHeaders: pendingHeaders, + pendingData: pendingData, + store: st, + config: cfg, + logger: logger, } if cfg.ClearCache { @@ -192,42 +179,6 @@ func (m *implementation) RemoveHeaderDAIncluded(hash string) { m.headerCache.removeDAIncluded(hash) } -// SetPendingSignedHeader records the first SignedHeader seen at this height. -// First-write-wins: later writes at the same height are ignored so the -// double-sign detector can match alternates against the original observation. -func (m *implementation) SetPendingSignedHeader(h *types.SignedHeader, source string) { - if h == nil { - return - } - height := h.Height() - m.pendingSignedHeadersMu.Lock() - defer m.pendingSignedHeadersMu.Unlock() - if _, exists := m.pendingSignedHeaders[height]; exists { - return - } - m.pendingSignedHeaders[height] = pendingSignedHeader{header: h, source: source} -} - -// GetPendingSignedHeader returns the first-seen SignedHeader and the source -// ("da" or "p2p") it was observed from. -func (m *implementation) GetPendingSignedHeader(blockHeight uint64) (*types.SignedHeader, string, bool) { - m.pendingSignedHeadersMu.RLock() - defer m.pendingSignedHeadersMu.RUnlock() - entry, ok := m.pendingSignedHeaders[blockHeight] - if !ok { - return nil, "", false - } - return entry.header, entry.source, true -} - -// RemovePendingSignedHeader evicts the entry once the height is persisted, so -// the store becomes the authoritative source for double-sign comparison. -func (m *implementation) RemovePendingSignedHeader(blockHeight uint64) { - m.pendingSignedHeadersMu.Lock() - delete(m.pendingSignedHeaders, blockHeight) - m.pendingSignedHeadersMu.Unlock() -} - // DaHeight returns the highest DA height seen across header and data caches. func (m *implementation) DaHeight() uint64 { return max(m.headerCache.daHeight(), m.dataCache.daHeight()) @@ -318,7 +269,6 @@ func (m *implementation) DeleteHeight(blockHeight uint64) { m.pendingMu.Lock() delete(m.pendingEvents, blockHeight) m.pendingMu.Unlock() - m.RemovePendingSignedHeader(blockHeight) // Note: txCache is intentionally NOT deleted here because: // 1. Transactions are tracked by hash, not by block height (they use height 0) @@ -464,9 +414,6 @@ func (m *implementation) ClearFromStore() error { m.dataCache = NewCache(m.store, DataDAIncludedPrefix) m.txCache = NewCache(nil, "") m.pendingEvents = make(map[uint64]*common.DAHeightEvent) - m.pendingSignedHeadersMu.Lock() - m.pendingSignedHeaders = make(map[uint64]pendingSignedHeader) - m.pendingSignedHeadersMu.Unlock() // Initialize DA height from store metadata to ensure DaHeight() is never 0. m.initDAHeightFromStore(ctx) diff --git a/block/internal/cache/manager_test.go b/block/internal/cache/manager_test.go index 9c68cad5ed..fa5aebf34b 100644 --- a/block/internal/cache/manager_test.go +++ b/block/internal/cache/manager_test.go @@ -3,8 +3,6 @@ package cache import ( "context" "encoding/binary" - "sync" - "sync/atomic" "testing" "time" @@ -520,205 +518,6 @@ func TestManager_DaHeightAfterCacheClear(t *testing.T) { "DaHeight should be seeded from finalized-tip metadata even after ClearCache") } -// builds a minimal SignedHeader; variant differentiates hashes at the same height. -func signedHeaderForHeight(height uint64, variant byte) *types.SignedHeader { - return &types.SignedHeader{ - Header: types.Header{ - BaseHeader: types.BaseHeader{ChainID: "pending-signed", Height: height, Time: 1}, - AppHash: []byte{variant, variant, variant}, - }, - } -} - -func TestManager_PendingSignedHeader_FirstWriteWins(t *testing.T) { - t.Parallel() - cfg := tempConfig(t) - st := testMemStore(t) - - m, err := NewManager(cfg, st, zerolog.Nop()) - require.NoError(t, err) - - first := signedHeaderForHeight(5, 0x01) - second := signedHeaderForHeight(5, 0x02) - require.NotEqual(t, first.Hash().String(), second.Hash().String()) - - m.SetPendingSignedHeader(first, "p2p") - m.SetPendingSignedHeader(second, "da") - - got, source, ok := m.GetPendingSignedHeader(5) - require.True(t, ok) - require.Equal(t, first.Hash().String(), got.Hash().String()) - require.Equal(t, "p2p", source) -} - -func TestManager_PendingSignedHeader_NilHeaderIgnored(t *testing.T) { - t.Parallel() - cfg := tempConfig(t) - st := testMemStore(t) - - m, err := NewManager(cfg, st, zerolog.Nop()) - require.NoError(t, err) - - m.SetPendingSignedHeader(nil, "p2p") - _, _, ok := m.GetPendingSignedHeader(5) - require.False(t, ok) - - hdr := signedHeaderForHeight(5, 0x01) - m.SetPendingSignedHeader(hdr, "p2p") - got, _, ok := m.GetPendingSignedHeader(5) - require.True(t, ok) - require.Equal(t, hdr.Hash().String(), got.Hash().String()) -} - -func TestManager_GetPendingSignedHeader_Miss(t *testing.T) { - t.Parallel() - cfg := tempConfig(t) - st := testMemStore(t) - - m, err := NewManager(cfg, st, zerolog.Nop()) - require.NoError(t, err) - - hdr, source, ok := m.GetPendingSignedHeader(99) - require.False(t, ok) - require.Nil(t, hdr) - require.Empty(t, source) -} - -func TestManager_RemovePendingSignedHeader_Idempotent(t *testing.T) { - t.Parallel() - cfg := tempConfig(t) - st := testMemStore(t) - - m, err := NewManager(cfg, st, zerolog.Nop()) - require.NoError(t, err) - - require.NotPanics(t, func() { m.RemovePendingSignedHeader(123) }) - - hdr := signedHeaderForHeight(5, 0x01) - m.SetPendingSignedHeader(hdr, "p2p") - m.RemovePendingSignedHeader(5) - m.RemovePendingSignedHeader(5) - _, _, ok := m.GetPendingSignedHeader(5) - require.False(t, ok) -} - -func TestManager_DeleteHeight_EvictsPendingSignedHeader(t *testing.T) { - t.Parallel() - cfg := tempConfig(t) - st := testMemStore(t) - - m, err := NewManager(cfg, st, zerolog.Nop()) - require.NoError(t, err) - - hdr := signedHeaderForHeight(5, 0x01) - m.SetPendingSignedHeader(hdr, "p2p") - _, _, ok := m.GetPendingSignedHeader(5) - require.True(t, ok) - - m.DeleteHeight(5) - - _, _, ok = m.GetPendingSignedHeader(5) - require.False(t, ok) -} - -func TestManager_ClearFromStore_ResetsPendingSignedHeaders(t *testing.T) { - t.Parallel() - cfg := tempConfig(t) - st := testMemStore(t) - - m, err := NewManager(cfg, st, zerolog.Nop()) - require.NoError(t, err) - - m.SetPendingSignedHeader(signedHeaderForHeight(5, 0x01), "p2p") - m.SetPendingSignedHeader(signedHeaderForHeight(6, 0x02), "da") - - impl, ok := m.(*implementation) - require.True(t, ok) - require.NoError(t, impl.ClearFromStore()) - - for _, h := range []uint64{5, 6} { - _, _, present := m.GetPendingSignedHeader(h) - require.False(t, present, "pending entry at %d must be cleared", h) - } -} - -// Race-detector coverage for the pending-signed-header map. Run with -race. -func TestManager_PendingSignedHeader_Concurrency(t *testing.T) { - t.Parallel() - cfg := tempConfig(t) - st := testMemStore(t) - - m, err := NewManager(cfg, st, zerolog.Nop()) - require.NoError(t, err) - - const ( - writers = 8 - readers = 8 - removers = 4 - heightsPerRun = 200 - ) - - headers := make([]*types.SignedHeader, heightsPerRun) - for i := range headers { - headers[i] = signedHeaderForHeight(uint64(i+1), byte(i&0xff)) - } - - var ( - wg sync.WaitGroup - startCh = make(chan struct{}) - writerHit atomic.Int64 - readerHit atomic.Int64 - ) - - for w := range writers { - wg.Add(1) - go func(seed int) { - defer wg.Done() - <-startCh - for i := range heightsPerRun { - idx := (seed*7 + i) % heightsPerRun - m.SetPendingSignedHeader(headers[idx], "p2p") - writerHit.Add(1) - } - }(w) - } - - for r := range readers { - wg.Add(1) - go func(seed int) { - defer wg.Done() - <-startCh - for i := range heightsPerRun { - h := uint64((seed*11+i)%heightsPerRun + 1) - _, _, _ = m.GetPendingSignedHeader(h) - readerHit.Add(1) - } - }(r) - } - - for d := range removers { - wg.Add(1) - go func(seed int) { - defer wg.Done() - <-startCh - for i := range heightsPerRun { - h := uint64((seed*13+i)%heightsPerRun + 1) - if i%2 == 0 { - m.RemovePendingSignedHeader(h) - } else { - m.DeleteHeight(h) - } - } - }(d) - } - - close(startCh) - wg.Wait() - - require.Equal(t, int64(writers*heightsPerRun), writerHit.Load()) - require.Equal(t, int64(readers*heightsPerRun), readerHit.Load()) -} - func TestManager_DaHeightFromStoreOnRestore(t *testing.T) { t.Parallel() diff --git a/block/internal/syncing/da_retriever.go b/block/internal/syncing/da_retriever.go index a446ff7c8e..5fa17c1ed2 100644 --- a/block/internal/syncing/da_retriever.go +++ b/block/internal/syncing/da_retriever.go @@ -34,8 +34,10 @@ type daRetriever struct { cache cache.CacheManager genesis genesis.Genesis logger zerolog.Logger - // detectDoubleSign aborts the batch when it returns true. Nil disables. - detectDoubleSign doubleSignDetector + + // reportInBatchDoubleSign halts/warns on two distinct sequencer-signed + // headers seen at the same height before either is applied. Set by the syncer. + reportInBatchDoubleSign inBatchDoubleSignReporter mu sync.Mutex // transient cache, only full event need to be passed to the syncer @@ -54,17 +56,17 @@ func NewDARetriever( cache cache.CacheManager, genesis genesis.Genesis, logger zerolog.Logger, - detectDoubleSign doubleSignDetector, + reportInBatchDoubleSign inBatchDoubleSignReporter, ) *daRetriever { return &daRetriever{ - client: client, - cache: cache, - genesis: genesis, - logger: logger.With().Str("component", "da_retriever").Logger(), - detectDoubleSign: detectDoubleSign, - pendingHeaders: make(map[uint64]*types.SignedHeader), - pendingData: make(map[uint64]*types.Data), - strictMode: false, + client: client, + cache: cache, + genesis: genesis, + logger: logger.With().Str("component", "da_retriever").Logger(), + reportInBatchDoubleSign: reportInBatchDoubleSign, + pendingHeaders: make(map[uint64]*types.SignedHeader), + pendingData: make(map[uint64]*types.Data), + strictMode: false, } } @@ -176,12 +178,19 @@ func (r *daRetriever) processBlobs(ctx context.Context, blobs [][]byte, daHeight } if header := r.tryDecodeHeader(bz, daHeight); header != nil { - // Catches both in-batch alternates and alternates of already-persisted heights. - if r.detectDoubleSign != nil && r.detectDoubleSign(ctx, header, types.EvidenceSourceDA) { - return nil - } - - if _, ok := r.pendingHeaders[header.Height()]; ok { + // First-write-wins per height. A second, distinct header for a height + // already in flight is in-flight equivocation when both are provably + // sequencer-authored from their DA envelope signatures (verified in + // tryDecodeHeader; execution-independent, so no need to wait for block + // n-1). Cross-batch and already-applied alternates are handled + // centrally in Syncer.checkDoubleSign. + if existing, ok := r.pendingHeaders[header.Height()]; ok { + if r.reportInBatchDoubleSign != nil && + !bytes.Equal(existing.Hash(), header.Hash()) && + r.envelopeAuthoredBySequencer(existing) && + r.envelopeAuthoredBySequencer(header) { + r.reportInBatchDoubleSign(header.Height(), existing, header) + } r.logger.Debug().Uint64("height", header.Height()).Uint64("da_height", daHeight).Msg("header blob already exists for height, discarding") continue } @@ -311,15 +320,6 @@ func (r *daRetriever) tryDecodeHeader(bz []byte, daHeight uint64) *types.SignedH return nil } - // Precondition for the double-sign detector: a forged blob must never - // reach the pending cache or be persisted as equivocation evidence. - // Required even in strict envelope mode — the inner SignedHeader - // signature is a separate commitment from the envelope signature. - if err := header.ValidateBasic(); err != nil { - r.logger.Debug().Err(err).Msg("signed header failed validation") - return nil - } - if isValidEnvelope && !r.strictMode { r.logger.Info().Uint64("height", header.Height()).Msg("valid DA envelope detected, switching to STRICT MODE") r.strictMode = true @@ -340,6 +340,11 @@ func (r *daRetriever) tryDecodeHeader(bz []byte, daHeight uint64) *types.SignedH return header } +// envelopeAuthoredBySequencer reports whether h is proven to be authored by the genesis sequencer +func (r *daRetriever) envelopeAuthoredBySequencer(h *types.SignedHeader) bool { + return r.strictMode && bytes.Equal(h.Signer.Address, h.ProposerAddress) +} + // tryDecodeData attempts to decode a blob as signed data func (r *daRetriever) tryDecodeData(bz []byte, daHeight uint64) *types.Data { var signedData types.SignedData diff --git a/block/internal/syncing/doublesign.go b/block/internal/syncing/doublesign.go index af2d29ae62..af53f0d83c 100644 --- a/block/internal/syncing/doublesign.go +++ b/block/internal/syncing/doublesign.go @@ -1,144 +1,17 @@ package syncing import ( - "bytes" - "context" - "errors" "fmt" "sync" - "time" - "github.com/rs/zerolog" - - "github.com/evstack/ev-node/block/internal/cache" - "github.com/evstack/ev-node/block/internal/common" - "github.com/evstack/ev-node/pkg/store" "github.com/evstack/ev-node/types" ) -// ErrDoubleSign is returned when two validly-signed SignedHeaders are observed at the same height. -var ErrDoubleSign = errors.New("double-sign detected") - -// doubleSignDetector reports an observed header for equivocation detection. -// Returns true on a confirmed double-sign so the caller can abort. -type doubleSignDetector func(ctx context.Context, header *types.SignedHeader, source string) bool - -// firstObservation returns the first-seen SignedHeader at this height, -// preferring the cache over the store. Returns (nil, "", nil) when none. -func firstObservation( - ctx context.Context, - st store.Store, - cm cache.CacheManager, - height uint64, -) (*types.SignedHeader, string, error) { - if cached, source, ok := cm.GetPendingSignedHeader(height); ok { - return cached, source, nil - } - - storedHeader, err := st.GetHeader(ctx, height) - if err != nil { - if store.IsNotFound(err) { - return nil, "", nil - } - return nil, "", fmt.Errorf("lookup stored header at %d: %w", height, err) - } - if storedHeader == nil { - return nil, "", nil - } - return storedHeader, types.EvidenceSourceStored, nil -} - -// buildEvidenceFromPair returns evidence for two SignedHeaders at the same -// height with different hashes and matching proposer. Returns nil otherwise. -func buildEvidenceFromPair(first, alternate *types.SignedHeader, firstSource, altSource string) *types.DoubleSignEvidence { - if first == nil || alternate == nil { - return nil - } - if first.Height() != alternate.Height() { - return nil - } - if bytes.Equal(first.Hash(), alternate.Hash()) { - return nil - } - if !bytes.Equal(first.ProposerAddress, alternate.ProposerAddress) { - return nil - } - return &types.DoubleSignEvidence{ - Height: first.Height(), - FirstHeader: first, - AlternateHeader: alternate, - DetectedAt: time.Now().UTC(), - FirstSource: firstSource, - AlternateSource: altSource, - } -} - -// persistEvidence writes evidence to its canonical metadata key. Idempotent. -func persistEvidence(ctx context.Context, st store.Store, ev *types.DoubleSignEvidence) error { - if err := ev.ValidateBasic(); err != nil { - return fmt.Errorf("invalid evidence: %w", err) - } - blob, err := ev.MarshalBinary() - if err != nil { - return fmt.Errorf("marshal evidence: %w", err) - } - altHash := ev.AlternateHeader.Hash() - key := store.GetDoubleSignEvidenceKey(ev.Height, altHash) - if err := st.SetMetadata(ctx, key, blob); err != nil { - return fmt.Errorf("persist evidence at %s: %w", key, err) - } - return nil -} - -// reportDoubleSign persists evidence and bumps the metric, deduping by -// (height, altHash). Returns a wrapped ErrDoubleSign on first sighting, -// nil when already seen. -func reportDoubleSign( - ctx context.Context, - st store.Store, - metrics *common.Metrics, - logger zerolog.Logger, - seen *doubleSignDedup, - ev *types.DoubleSignEvidence, -) error { - altHashStr := ev.AlternateHeader.Hash().String() - firstHashStr := ev.FirstHeader.Hash().String() - key := store.GetDoubleSignEvidenceKey(ev.Height, ev.AlternateHeader.Hash()) - - if seen != nil && !seen.markSeen(ev.Height, altHashStr) { - return nil - } - - if err := persistEvidence(ctx, st, ev); err != nil { - logger.Error().Err(err). - Uint64("height", ev.Height). - Str("first_hash", firstHashStr). - Str("alternate_hash", altHashStr). - Msg("failed to persist double-sign evidence") - } - - if metrics != nil && metrics.DoubleSignsDetected != nil { - metrics.DoubleSignsDetected.Add(1) - } - - logger.Error(). - Uint64("height", ev.Height). - Str("first_hash", firstHashStr). - Str("first_source", ev.FirstSource). - Str("alternate_hash", altHashStr). - Str("alternate_source", ev.AlternateSource). - Str("evidence_key", key). - Msg("DOUBLE-SIGN DETECTED — sequencer equivocation; halting syncer") - - return fmt.Errorf( - "double-sign detected at height %d: sequencer signed conflicting headers %s and %s. "+ - "Evidence persisted at metadata key %s. Manual intervention required: %w", - ev.Height, firstHashStr, altHashStr, key, ErrDoubleSign, - ) -} +// inBatchDoubleSignReporter reports two distinct, sequencer-signed headers observed at the same height +type inBatchDoubleSignReporter func(height uint64, canonical, alt *types.SignedHeader) -// doubleSignDedup collapses (height, altHash) duplicates so the same -// equivocation arriving from both P2P and DA is only reported once. +// doubleSignDedup collapses repeated (height, altHash) sightings so the same equivocation +// arriving from multiple batches or sources is only warned and counted once. type doubleSignDedup struct { mu sync.Mutex seen map[string]struct{} diff --git a/block/internal/syncing/doublesign_branches_test.go b/block/internal/syncing/doublesign_branches_test.go deleted file mode 100644 index 1acbf0cecf..0000000000 --- a/block/internal/syncing/doublesign_branches_test.go +++ /dev/null @@ -1,338 +0,0 @@ -package syncing - -import ( - "context" - "errors" - "sync/atomic" - "testing" - - "github.com/rs/zerolog" - "github.com/stretchr/testify/require" - - "github.com/evstack/ev-node/block/internal/common" - "github.com/evstack/ev-node/block/internal/da" - "github.com/evstack/ev-node/pkg/store" - testmocks "github.com/evstack/ev-node/test/mocks" - "github.com/evstack/ev-node/types" - pb "github.com/evstack/ev-node/types/pb/evnode/v1" -) - -// DA client stub with shared namespace mocks. -func newMockDAClient(t *testing.T) da.Client { - t.Helper() - c := testmocks.NewMockClient(t) - c.On("GetHeaderNamespace").Return([]byte("ns")).Maybe() - c.On("GetDataNamespace").Return([]byte("ns")).Maybe() - return c -} - -// errStore wraps a store and injects errors on selected reads/writes to -// exercise error-handling branches an in-memory store can't hit. -type errStore struct { - store.Store - getHeaderErr error - setMetadataErr error -} - -func (e *errStore) GetHeader(ctx context.Context, height uint64) (*types.SignedHeader, error) { - if e.getHeaderErr != nil { - return nil, e.getHeaderErr - } - return e.Store.GetHeader(ctx, height) -} - -func (e *errStore) SetMetadata(ctx context.Context, key string, value []byte) error { - if e.setMetadataErr != nil { - return e.setMetadataErr - } - return e.Store.SetMetadata(ctx, key, value) -} - -// nilHeaderStore returns (nil, nil) from GetHeader; the detector must treat -// that as "no record" rather than crashing. -type nilHeaderStore struct{ store.Store } - -func (nilHeaderStore) GetHeader(context.Context, uint64) (*types.SignedHeader, error) { - return nil, nil -} - -// A non-NotFound store failure must be surfaced, not swallowed. -func TestFirstObservation_StoreErrorWrapped(t *testing.T) { - env := newDSTestEnv(t) - wrapped := &errStore{Store: env.store, getHeaderErr: errors.New("backend down")} - - prior, priorSource, err := firstObservation(context.Background(), wrapped, env.cache, 5) - require.Error(t, err) - require.Nil(t, prior) - require.Empty(t, priorSource) - require.Contains(t, err.Error(), "lookup stored header") - require.ErrorContains(t, err, "backend down") -} - -func TestFirstObservation_StoredHeaderNilDefensive(t *testing.T) { - env := newDSTestEnv(t) - wrapped := nilHeaderStore{Store: env.store} - - prior, priorSource, err := firstObservation(context.Background(), wrapped, env.cache, 5) - require.NoError(t, err) - require.Nil(t, prior) - require.Empty(t, priorSource) -} - -// Store-path observations must use the "stored" sentinel as the source so -// downstream consumers can disambiguate them from in-flight observations. -func TestFirstObservation_StoredSourceSentinel(t *testing.T) { - env := newDSTestEnv(t) - first := env.signHeaderAtHeight(5, 0x01) - env.saveHeader(first) - - prior, priorSource, err := firstObservation(context.Background(), env.store, env.cache, first.Height()) - require.NoError(t, err) - require.NotNil(t, prior) - require.Equal(t, types.EvidenceSourceStored, priorSource) -} - -// SetMetadata failures must include the canonical key so an operator can -// recover the persistence target from logs alone. -func TestPersistEvidence_StoreError(t *testing.T) { - env := newDSTestEnv(t) - wrapped := &errStore{Store: env.store, setMetadataErr: errors.New("disk full")} - - first := env.signHeaderAtHeight(5, 0x01) - alt := env.signHeaderAtHeight(5, 0x02) - ev := buildEvidenceFromPair(first, alt, types.EvidenceSourceP2P, types.EvidenceSourceDA) - require.NotNil(t, ev) - - err := persistEvidence(context.Background(), wrapped, ev) - require.Error(t, err) - require.ErrorContains(t, err, "disk full") - require.Contains(t, err.Error(), store.GetDoubleSignEvidenceKey(ev.Height, ev.AlternateHeader.Hash())) -} - -// Persistence failure must not break the halt contract: metric still -// increments and the returned error still wraps ErrDoubleSign. -func TestReportDoubleSign_PersistFailureLoggedNotBlocking(t *testing.T) { - env := newDSTestEnv(t) - wrapped := &errStore{Store: env.store, setMetadataErr: errors.New("disk full")} - - first := env.signHeaderAtHeight(5, 0x01) - alt := env.signHeaderAtHeight(5, 0x02) - ev := buildEvidenceFromPair(first, alt, types.EvidenceSourceP2P, types.EvidenceSourceDA) - require.NotNil(t, ev) - - var dsCount atomic.Int64 - metrics := common.NopMetrics() - metrics.DoubleSignsDetected = &counterCtr{n: &dsCount} - - halt := reportDoubleSign(context.Background(), wrapped, metrics, zerolog.Nop(), - newDoubleSignDedup(), ev) - require.Error(t, halt) - require.ErrorIs(t, halt, ErrDoubleSign) - - require.Equal(t, int64(1), dsCount.Load()) -} - -// Dedup is keyed on (height, altHash), so two distinct alts at the same -// height must each produce evidence. -func TestReportDoubleSign_TwoDistinctAltsAtSameHeight(t *testing.T) { - env := newDSTestEnv(t) - first := env.signHeaderAtHeight(5, 0x01) - alt1 := env.signHeaderAtHeight(5, 0x02) - alt2 := env.signHeaderAtHeight(5, 0x03) - require.NotEqual(t, alt1.Hash().String(), alt2.Hash().String()) - - ev1 := buildEvidenceFromPair(first, alt1, types.EvidenceSourceP2P, types.EvidenceSourceDA) - ev2 := buildEvidenceFromPair(first, alt2, types.EvidenceSourceP2P, types.EvidenceSourceDA) - require.NotNil(t, ev1) - require.NotNil(t, ev2) - - var dsCount atomic.Int64 - metrics := common.NopMetrics() - metrics.DoubleSignsDetected = &counterCtr{n: &dsCount} - - seen := newDoubleSignDedup() - - require.Error(t, reportDoubleSign(context.Background(), env.store, metrics, - zerolog.Nop(), seen, ev1)) - require.Error(t, reportDoubleSign(context.Background(), env.store, metrics, - zerolog.Nop(), seen, ev2)) - - require.Equal(t, int64(2), dsCount.Load()) - - for _, ev := range []*types.DoubleSignEvidence{ev1, ev2} { - key := store.GetDoubleSignEvidenceKey(ev.Height, ev.AlternateHeader.Hash()) - blob, err := env.store.GetMetadata(context.Background(), key) - require.NoError(t, err) - require.NotEmpty(t, blob) - } -} - -func TestReportDoubleSign_NilSeenAndNilGuards(t *testing.T) { - env := newDSTestEnv(t) - first := env.signHeaderAtHeight(5, 0x01) - alt := env.signHeaderAtHeight(5, 0x02) - ev := buildEvidenceFromPair(first, alt, types.EvidenceSourceP2P, types.EvidenceSourceDA) - require.NotNil(t, ev) - - t.Run("nil seen still halts", func(t *testing.T) { - halt := reportDoubleSign(context.Background(), env.store, common.NopMetrics(), - zerolog.Nop(), nil, ev) - require.Error(t, halt) - require.ErrorIs(t, halt, ErrDoubleSign) - }) - - t.Run("nil metrics still halts", func(t *testing.T) { - halt := reportDoubleSign(context.Background(), env.store, nil, - zerolog.Nop(), newDoubleSignDedup(), ev) - require.Error(t, halt) - require.ErrorIs(t, halt, ErrDoubleSign) - }) - - t.Run("nil counter inside metrics still halts", func(t *testing.T) { - m := common.NopMetrics() - m.DoubleSignsDetected = nil - halt := reportDoubleSign(context.Background(), env.store, m, - zerolog.Nop(), newDoubleSignDedup(), ev) - require.Error(t, halt) - require.ErrorIs(t, halt, ErrDoubleSign) - }) -} - -func TestDoubleSignEvidence_FromProtoNil(t *testing.T) { - dst := new(types.DoubleSignEvidence) - require.Error(t, dst.FromProto(nil)) -} - -func TestDoubleSignEvidence_FromProtoInnerHeaderError(t *testing.T) { - // Both inner SignedHeader fields nil — the wrapper must surface the error. - p := &pb.DoubleSignEvidence{Height: 5} - dst := new(types.DoubleSignEvidence) - require.Error(t, dst.FromProto(p)) -} - -// FromProto must reject partial-nil sub-messages (one set, one nil) to keep -// the (FirstHeader, AlternateHeader) pair invariant after deserialization. -func TestDoubleSignEvidence_FromProtoPartialNilHeader(t *testing.T) { - env := newDSTestEnv(t) - hdr := env.signHeaderAtHeight(5, 0x01) - hdrPB, err := hdr.ToProto() - require.NoError(t, err) - - t.Run("alternate nil", func(t *testing.T) { - dst := new(types.DoubleSignEvidence) - require.Error(t, dst.FromProto(&pb.DoubleSignEvidence{Height: 5, FirstHeader: hdrPB})) - }) - t.Run("first nil", func(t *testing.T) { - dst := new(types.DoubleSignEvidence) - require.Error(t, dst.FromProto(&pb.DoubleSignEvidence{Height: 5, AlternateHeader: hdrPB})) - }) -} - -func TestDoubleSignEvidence_UnmarshalBinaryGarbage(t *testing.T) { - dst := new(types.DoubleSignEvidence) - require.Error(t, dst.UnmarshalBinary([]byte{0xff, 0xff, 0xff, 0xff})) -} - -// Once equivocation is detected, the rest of the batch must be dropped. -func TestDARetriever_AbortsBatchOnDetection(t *testing.T) { - env := newDSTestEnv(t) - - first := env.signHeaderAtHeight(5, 0x01) - alt := env.signHeaderAtHeight(5, 0x02) - next := env.signHeaderAtHeight(6, 0x01) - - firstBin, err := first.MarshalBinary() - require.NoError(t, err) - altBin, err := alt.MarshalBinary() - require.NoError(t, err) - nextBin, err := next.MarshalBinary() - require.NoError(t, err) - - mockClient := newMockDAClient(t) - - r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.detectDoubleSign) - - events := r.ProcessBlobs(context.Background(), - [][]byte{firstBin, altBin, nextBin}, 100) - require.Empty(t, events) - require.NotContains(t, r.pendingHeaders, uint64(6)) -} - -// On a detector error, the retriever still caches the header so a later -// alternate can be matched once the store recovers. -func TestDARetriever_DetectorErrorWarnAndContinue(t *testing.T) { - env := newDSTestEnv(t) - wrapped := &errStore{Store: env.store, getHeaderErr: errors.New("flapping disk")} - - hdr := env.signHeaderAtHeight(5, 0x01) - bin, err := hdr.MarshalBinary() - require.NoError(t, err) - - mockClient := newMockDAClient(t) - - r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.makeDetectDoubleSign(wrapped)) - - _ = r.ProcessBlobs(context.Background(), [][]byte{bin}, 100) - - require.Empty(t, env.captured()) - - got, src, ok := env.cache.GetPendingSignedHeader(hdr.Height()) - require.True(t, ok) - require.Equal(t, hdr.Hash().String(), got.Hash().String()) - require.Equal(t, types.EvidenceSourceDA, src) -} - -// Double-sign detection through the envelope (strict-mode) path. -func TestDARetriever_StrictModeEnvelopeDoubleSign(t *testing.T) { - env := newDSTestEnv(t) - - first := env.signHeaderAtHeight(5, 0x01) - alt := env.signHeaderAtHeight(5, 0x02) - - mkEnvelope := func(h *types.SignedHeader) []byte { - content, err := h.MarshalBinary() - require.NoError(t, err) - envSig, err := env.signer.Sign(t.Context(), content) - require.NoError(t, err) - envBin, err := h.MarshalDAEnvelope(envSig) - require.NoError(t, err) - return envBin - } - - firstBin := mkEnvelope(first) - altBin := mkEnvelope(alt) - - mockClient := newMockDAClient(t) - - r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.detectDoubleSign) - - events := r.ProcessBlobs(context.Background(), [][]byte{firstBin, altBin}, 100) - require.Empty(t, events) - require.True(t, r.strictMode) - - captured := env.captured() - require.Len(t, captured, 1) - require.Equal(t, uint64(5), captured[0].Height) - require.Equal(t, types.EvidenceSourceDA, captured[0].FirstSource) - require.Equal(t, types.EvidenceSourceDA, captured[0].AlternateSource) -} - -// First DA observation must populate the pending cache so a later cross-source -// alternate can be matched against it. -func TestDARetriever_SetsPendingSignedHeaderOnFirstObservation(t *testing.T) { - env := newDSTestEnv(t) - - first := env.signHeaderAtHeight(5, 0x01) - bin, err := first.MarshalBinary() - require.NoError(t, err) - - mockClient := newMockDAClient(t) - - r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.detectDoubleSign) - _ = r.ProcessBlobs(context.Background(), [][]byte{bin}, 100) - - got, src, ok := env.cache.GetPendingSignedHeader(5) - require.True(t, ok) - require.Equal(t, first.Hash().String(), got.Hash().String()) - require.Equal(t, types.EvidenceSourceDA, src) -} diff --git a/block/internal/syncing/doublesign_syncer_integration_test.go b/block/internal/syncing/doublesign_syncer_integration_test.go deleted file mode 100644 index 104661b4a2..0000000000 --- a/block/internal/syncing/doublesign_syncer_integration_test.go +++ /dev/null @@ -1,317 +0,0 @@ -package syncing - -import ( - "bytes" - "context" - "sync/atomic" - "testing" - "time" - - ds "github.com/ipfs/go-datastore" - dssync "github.com/ipfs/go-datastore/sync" - "github.com/libp2p/go-libp2p/core/crypto" - "github.com/rs/zerolog" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - - "github.com/evstack/ev-node/block/internal/cache" - "github.com/evstack/ev-node/block/internal/common" - "github.com/evstack/ev-node/pkg/config" - "github.com/evstack/ev-node/pkg/genesis" - signerpkg "github.com/evstack/ev-node/pkg/signer" - "github.com/evstack/ev-node/pkg/store" - testmocks "github.com/evstack/ev-node/test/mocks" - extmocks "github.com/evstack/ev-node/test/mocks/external" - "github.com/evstack/ev-node/types" -) - -// integrationHarness wires the Syncer the way Start() does for tests -// to drive headers through the real entry points and assert on the halt pipeline. -type integrationHarness struct { - t *testing.T - syncer *Syncer - store store.Store - cache cache.CacheManager - gen genesis.Genesis - addr []byte - pub crypto.PubKey - signer signerpkg.Signer - errCh chan error - dsCount *atomic.Int64 -} - -func newIntegrationHarness(t *testing.T) *integrationHarness { - t.Helper() - memDS := dssync.MutexWrap(ds.NewMapDatastore()) - st := store.New(memDS) - - cm, err := cache.NewManager(config.DefaultConfig(), st, zerolog.Nop()) - require.NoError(t, err) - - addr, pub, signer := buildSyncTestSigner(t) - cfg := config.DefaultConfig() - gen := genesis.Genesis{ - ChainID: "syncer-ds-integration", InitialHeight: 1, - StartTime: time.Now().Add(-time.Second), ProposerAddress: addr, - } - - mockExec := testmocks.NewMockExecutor(t) - mockExec.EXPECT(). - InitChain(mock.Anything, mock.Anything, uint64(1), gen.ChainID). - Return([]byte("app0"), nil).Once() - - mockHeaderStore := extmocks.NewMockStore[*types.P2PSignedHeader](t) - mockHeaderStore.EXPECT().Height().Return(uint64(0)).Maybe() - mockDataStore := extmocks.NewMockStore[*types.P2PData](t) - mockDataStore.EXPECT().Height().Return(uint64(0)).Maybe() - - metrics := common.NopMetrics() - var dsCount atomic.Int64 - metrics.DoubleSignsDetected = &counterCtr{n: &dsCount} - - errCh := make(chan error, 4) - s := NewSyncer( - st, mockExec, nil, cm, metrics, cfg, gen, - mockHeaderStore, mockDataStore, zerolog.Nop(), - common.DefaultBlockOptions(), errCh, nil, - ) - require.NoError(t, s.initializeState()) - s.doubleSignSeen = newDoubleSignDedup() // normally set up by Start() - - return &integrationHarness{ - t: t, - syncer: s, - store: st, - cache: cm, - gen: gen, - addr: addr, - pub: pub, - signer: signer, - errCh: errCh, - dsCount: &dsCount, - } -} - -func (h *integrationHarness) sign(height uint64, variant byte) *types.SignedHeader { - h.t.Helper() - // Vary AppHash AND LastHeaderHash by variant so distinct variants always - // produce distinct hashes regardless of timing or pool state. - _, hdr := makeSignedHeaderBytes(h.t, h.gen.ChainID, height, h.addr, h.pub, h.signer, - []byte{variant, variant, variant}, nil, []byte{variant}) - return hdr -} - -// persistHeader writes the header to the store as if it had been synced. -func (h *integrationHarness) persistHeader(hdr *types.SignedHeader) { - h.t.Helper() - batch, err := h.store.NewBatch(context.Background()) - require.NoError(h.t, err) - require.NoError(h.t, batch.SaveBlockData(hdr, &types.Data{ - Metadata: &types.Metadata{ChainID: h.gen.ChainID, Height: hdr.Height(), Time: hdr.BaseHeader.Time}, - }, &hdr.Signature)) - require.NoError(h.t, batch.SetHeight(hdr.Height())) - require.NoError(h.t, batch.Commit()) -} - -// newDARetriever builds a daRetriever wired to the harness's syncer. -func (h *integrationHarness) newDARetriever() *daRetriever { - mockClient := testmocks.NewMockClient(h.t) - mockClient.On("GetHeaderNamespace").Return([]byte("ns")).Maybe() - mockClient.On("GetDataNamespace").Return([]byte("ns")).Maybe() - return NewDARetriever(mockClient, h.cache, h.gen, zerolog.Nop(), h.syncer.detectDoubleSign) -} - -// newP2PHandler builds a P2PHandler with the header store mocked to return hdr at the given height. -func (h *integrationHarness) newP2PHandler(height uint64, hdr *types.SignedHeader) (*P2PHandler, chan common.DAHeightEvent) { - headerStore := extmocks.NewMockStore[*types.P2PSignedHeader](h.t) - dataStore := extmocks.NewMockStore[*types.P2PData](h.t) - headerStore.EXPECT(). - GetByHeight(mock.Anything, height). - Return(&types.P2PSignedHeader{SignedHeader: hdr}, nil). - Once() - p2p := NewP2PHandler(headerStore, dataStore, h.cache, h.gen, zerolog.Nop(), h.syncer.detectDoubleSign) - return p2p, make(chan common.DAHeightEvent, 1) -} - -// requireHalted asserts the full halt pipeline fired exactly once. -func (h *integrationHarness) requireHalted(altHash []byte, height uint64) { - h.t.Helper() - select { - case got := <-h.errCh: - require.ErrorIs(h.t, got, ErrDoubleSign) - case <-time.After(time.Second): - h.t.Fatal("timed out waiting for critical error on errCh") - } - require.True(h.t, h.syncer.hasCriticalError.Load()) - require.Equal(h.t, int64(1), h.dsCount.Load()) - - blob, err := h.store.GetMetadata(context.Background(), store.GetDoubleSignEvidenceKey(height, altHash)) - require.NoError(h.t, err) - require.NotEmpty(h.t, blob) -} - -// Two conflicting headers in the same DA blob batch must halt the syncer. -func TestSyncerIntegration_InBatchDA_HaltsViaRealRetrieverPipeline(t *testing.T) { - h := newIntegrationHarness(t) - r := h.newDARetriever() - - first := h.sign(5, 0x01) - alt := h.sign(5, 0x02) - require.NotEqual(t, first.Hash().String(), alt.Hash().String()) - - firstBin, err := first.MarshalBinary() - require.NoError(t, err) - altBin, err := alt.MarshalBinary() - require.NoError(t, err) - - events := r.ProcessBlobs(context.Background(), [][]byte{firstBin, altBin}, 100) - require.Empty(t, events) - - h.requireHalted(alt.Hash(), 5) -} - -// An alternate in a later DA batch is detected against the persisted canonical. -func TestSyncerIntegration_CrossBatchDA_HaltsViaStoreLookup(t *testing.T) { - h := newIntegrationHarness(t) - r := h.newDARetriever() - - first := h.sign(5, 0x01) - h.persistHeader(first) - - alt := h.sign(5, 0x02) - altBin, err := alt.MarshalBinary() - require.NoError(t, err) - - events := r.ProcessBlobs(context.Background(), [][]byte{altBin}, 101) - require.Empty(t, events) - - h.requireHalted(alt.Hash(), 5) -} - -// An alternate via P2P must halt when the canonical was first observed via DA. -func TestSyncerIntegration_CrossSource_DAFirstThenP2P_Halts(t *testing.T) { - h := newIntegrationHarness(t) - r := h.newDARetriever() - - first := h.sign(7, 0x01) - firstBin, err := first.MarshalBinary() - require.NoError(t, err) - _ = r.ProcessBlobs(context.Background(), [][]byte{firstBin}, 100) - - cached, source, ok := h.cache.GetPendingSignedHeader(7) - require.True(t, ok) - require.True(t, bytes.Equal(cached.Hash(), first.Hash())) - require.Equal(t, types.EvidenceSourceDA, source) - - alt := h.sign(7, 0x02) - p2p, ch := h.newP2PHandler(7, alt) - require.NoError(t, p2p.ProcessHeight(context.Background(), 7, ch)) - require.Empty(t, ch) - - h.requireHalted(alt.Hash(), 7) -} - -// An alternate via DA must halt when the canonical was first observed via P2P. -func TestSyncerIntegration_CrossSource_P2PFirstThenDA_Halts(t *testing.T) { - h := newIntegrationHarness(t) - - first := h.sign(7, 0x01) - p2p, ch := h.newP2PHandler(7, first) - p2p.SetProcessedHeight(7) - require.NoError(t, p2p.ProcessHeight(context.Background(), 7, ch)) - require.Empty(t, ch) - - cached, source, ok := h.cache.GetPendingSignedHeader(7) - require.True(t, ok) - require.True(t, bytes.Equal(cached.Hash(), first.Hash())) - require.Equal(t, types.EvidenceSourceP2P, source) - - alt := h.sign(7, 0x02) - altBin, err := alt.MarshalBinary() - require.NoError(t, err) - - r := h.newDARetriever() - events := r.ProcessBlobs(context.Background(), [][]byte{altBin}, 100) - require.Empty(t, events) - - h.requireHalted(alt.Hash(), 7) -} - -// An alternate via P2P must halt when the canonical was already persisted. -func TestSyncerIntegration_CrossSource_StoreFirstThenP2P_Halts(t *testing.T) { - h := newIntegrationHarness(t) - - first := h.sign(5, 0x01) - h.persistHeader(first) - - alt := h.sign(5, 0x02) - p2p, ch := h.newP2PHandler(5, alt) - require.NoError(t, p2p.ProcessHeight(context.Background(), 5, ch)) - require.Empty(t, ch) - - h.requireHalted(alt.Hash(), 5) -} - -// Identical bytes seen twice in the same DA batch must not halt. -func TestSyncerIntegration_BenignDuplicate_InBatch_DoesNotHalt(t *testing.T) { - h := newIntegrationHarness(t) - r := h.newDARetriever() - - hdr := h.sign(5, 0x01) - bin, err := hdr.MarshalBinary() - require.NoError(t, err) - - _ = r.ProcessBlobs(context.Background(), [][]byte{bin, bin}, 100) - - require.False(t, h.syncer.hasCriticalError.Load()) - require.Equal(t, int64(0), h.dsCount.Load()) - require.Empty(t, h.errCh) -} - -// Re-publishing the canonical at a different DA height must not halt. -func TestSyncerIntegration_BenignDuplicate_AcrossBatches_DoesNotHalt(t *testing.T) { - h := newIntegrationHarness(t) - r := h.newDARetriever() - - first := h.sign(5, 0x01) - h.persistHeader(first) - - bin, err := first.MarshalBinary() - require.NoError(t, err) - _ = r.ProcessBlobs(context.Background(), [][]byte{bin}, 101) - - require.False(t, h.syncer.hasCriticalError.Load()) - require.Equal(t, int64(0), h.dsCount.Load()) - require.Empty(t, h.errCh) -} - -// The same (height, altHash) seen twice must report only once. -func TestSyncerIntegration_DuplicateAlternates_DedupedToOneHalt(t *testing.T) { - h := newIntegrationHarness(t) - r := h.newDARetriever() - - first := h.sign(11, 0x01) - alt := h.sign(11, 0x02) - firstBin, err := first.MarshalBinary() - require.NoError(t, err) - altBin, err := alt.MarshalBinary() - require.NoError(t, err) - - _ = r.ProcessBlobs(context.Background(), [][]byte{firstBin, altBin}, 100) - _ = r.ProcessBlobs(context.Background(), [][]byte{altBin}, 101) - - require.Equal(t, int64(1), h.dsCount.Load()) - - timeout := time.After(50 * time.Millisecond) - count := 0 -loop: - for { - select { - case <-h.errCh: - count++ - case <-timeout: - break loop - } - } - require.Equal(t, 1, count) -} diff --git a/block/internal/syncing/doublesign_test.go b/block/internal/syncing/doublesign_test.go index ed6255472b..5e301db139 100644 --- a/block/internal/syncing/doublesign_test.go +++ b/block/internal/syncing/doublesign_test.go @@ -13,7 +13,6 @@ import ( "github.com/rs/zerolog" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "google.golang.org/protobuf/proto" "github.com/evstack/ev-node/block/internal/cache" "github.com/evstack/ev-node/block/internal/common" @@ -24,668 +23,415 @@ import ( testmocks "github.com/evstack/ev-node/test/mocks" extmocks "github.com/evstack/ev-node/test/mocks/external" "github.com/evstack/ev-node/types" - pb "github.com/evstack/ev-node/types/pb/evnode/v1" ) -// dsTestEnv bundles the store, cache, genesis and signer used by the -// double-sign tests. -type dsTestEnv struct { - t *testing.T - store store.Store - cache cache.CacheManager - gen genesis.Genesis - addr []byte - pub crypto.PubKey - signer signerpkg.Signer - chainID string - capLock atomic.Pointer[[]*types.DoubleSignEvidence] - detectDoubleSign doubleSignDetector -} - -func newDSTestEnv(t *testing.T) *dsTestEnv { +// dsHarness wires a Syncer the way Start() does so tests can drive headers +// through processHeightEvent and assert on the halt pipeline. Equivocation is +// detected centrally in processHeightEvent by comparing an alternate header for +// an already-applied height against the canonical header in the store. +type dsHarness struct { + t *testing.T + syncer *Syncer + store store.Store + cache cache.CacheManager + gen genesis.Genesis + addr []byte + pub crypto.PubKey + signer signerpkg.Signer + exec *testmocks.MockExecutor + errCh chan error + dsCount *atomic.Int64 +} + +func newDSHarness(t *testing.T, halt bool) *dsHarness { t.Helper() memDS := dssync.MutexWrap(ds.NewMapDatastore()) st := store.New(memDS) + cm, err := cache.NewManager(config.DefaultConfig(), st, zerolog.Nop()) require.NoError(t, err) addr, pub, signer := buildSyncTestSigner(t) + cfg := config.DefaultConfig() + cfg.Node.HaltOnDoubleSign = halt gen := genesis.Genesis{ - ChainID: "ds-test", - InitialHeight: 1, - StartTime: time.Now().Add(-time.Second), - ProposerAddress: addr, + ChainID: "ds-test", InitialHeight: 1, + StartTime: time.Now().Add(-time.Second), ProposerAddress: addr, } - env := &dsTestEnv{ - t: t, - store: st, - cache: cm, - gen: gen, - addr: addr, - pub: pub, - signer: signer, - chainID: gen.ChainID, - } - empty := []*types.DoubleSignEvidence{} - env.capLock.Store(&empty) - env.detectDoubleSign = env.makeDetectDoubleSign(st) - return env -} + mockExec := testmocks.NewMockExecutor(t) + mockExec.EXPECT(). + InitChain(mock.Anything, mock.Anything, uint64(1), gen.ChainID). + Return([]byte("app0"), nil).Once() -// makeDetectDoubleSign mimics Syncer.detectDoubleSign for tests, capturing -// evidence into env.capLock instead of halting. -func (e *dsTestEnv) makeDetectDoubleSign(st store.Store) doubleSignDetector { - return func(ctx context.Context, header *types.SignedHeader, source string) bool { - prior, priorSource, err := firstObservation(ctx, st, e.cache, header.Height()) - if err != nil { - e.cache.SetPendingSignedHeader(header, source) - return false - } - if ev := buildEvidenceFromPair(prior, header, priorSource, source); ev != nil { - require.NoError(e.t, ev.ValidateBasic()) - for { - cur := e.capLock.Load() - next := append([]*types.DoubleSignEvidence(nil), *cur...) - next = append(next, ev) - if e.capLock.CompareAndSwap(cur, &next) { - break - } - } - require.NoError(e.t, persistEvidence(ctx, st, ev)) - return true - } - e.cache.SetPendingSignedHeader(header, source) - return false - } -} + mockHeaderStore := extmocks.NewMockStore[*types.P2PSignedHeader](t) + mockHeaderStore.EXPECT().Height().Return(uint64(0)).Maybe() + mockDataStore := extmocks.NewMockStore[*types.P2PData](t) + mockDataStore.EXPECT().Height().Return(uint64(0)).Maybe() -func (e *dsTestEnv) captured() []*types.DoubleSignEvidence { - return *e.capLock.Load() -} + metrics := common.NopMetrics() + var dsCount atomic.Int64 + metrics.DoubleSignsDetected = &counterCtr{n: &dsCount} -// signs a header at height by the genesis proposer; variant differentiates hashes. -func (e *dsTestEnv) signHeaderAtHeight(height uint64, variant byte) *types.SignedHeader { - e.t.Helper() - _, hdr := makeSignedHeaderBytes( - e.t, e.chainID, height, e.addr, e.pub, e.signer, - []byte{variant, variant, variant}, - nil, - nil, + errCh := make(chan error, 4) + s := NewSyncer( + st, mockExec, nil, cm, metrics, cfg, gen, + mockHeaderStore, mockDataStore, zerolog.Nop(), + common.DefaultBlockOptions(), errCh, nil, ) - return hdr + require.NoError(t, s.initializeState()) + s.doubleSignSeen = newDoubleSignDedup() // normally set up by Start() + s.ctx = context.Background() // normally set up by Start() + + return &dsHarness{ + t: t, syncer: s, store: st, cache: cm, gen: gen, + addr: addr, pub: pub, signer: signer, exec: mockExec, errCh: errCh, dsCount: &dsCount, + } } -// signs a header by a fresh (non-genesis) signer. -func (e *dsTestEnv) signHeaderWithOtherProposer(height uint64, variant byte) *types.SignedHeader { - e.t.Helper() - otherAddr, otherPub, otherSigner := buildSyncTestSigner(e.t) +// header builds a validly-signed header + matching data at height; variant +// differentiates the header hash via AppHash/LastHeaderHash. +func (h *dsHarness) header(height uint64, variant byte) (*types.SignedHeader, *types.Data) { + h.t.Helper() + data := makeData(h.gen.ChainID, height, 2) _, hdr := makeSignedHeaderBytes( - e.t, e.chainID, height, otherAddr, otherPub, otherSigner, - []byte{variant, variant, variant}, - nil, - nil, + h.t, h.gen.ChainID, height, h.addr, h.pub, h.signer, + []byte{variant, variant, variant}, data, []byte{variant}, ) - return hdr -} - -func (e *dsTestEnv) saveHeader(hdr *types.SignedHeader) { - e.t.Helper() - batch, err := e.store.NewBatch(context.Background()) - require.NoError(e.t, err) - require.NoError(e.t, batch.SaveBlockData(hdr, &types.Data{ - Metadata: &types.Metadata{ChainID: e.chainID, Height: hdr.Height(), Time: hdr.BaseHeader.Time}, - }, &hdr.Signature)) - require.NoError(e.t, batch.SetHeight(hdr.Height())) - require.NoError(e.t, batch.Commit()) + return hdr, data } -func TestFirstObservation_StoredHeaderProducesEvidence(t *testing.T) { - env := newDSTestEnv(t) - first := env.signHeaderAtHeight(5, 0x01) - env.saveHeader(first) - - alt := env.signHeaderAtHeight(5, 0x02) - require.NotEqual(t, first.Hash().String(), alt.Hash().String()) - - prior, priorSource, err := firstObservation(context.Background(), env.store, env.cache, alt.Height()) - require.NoError(t, err) - require.Equal(t, first.Hash().String(), prior.Hash().String()) - require.Equal(t, types.EvidenceSourceStored, priorSource) - - ev := buildEvidenceFromPair(prior, alt, priorSource, types.EvidenceSourceP2P) - require.NotNil(t, ev) - require.Equal(t, uint64(5), ev.Height) - require.Equal(t, first.Hash().String(), ev.FirstHeader.Hash().String()) - require.Equal(t, alt.Hash().String(), ev.AlternateHeader.Hash().String()) - require.Equal(t, types.EvidenceSourceP2P, ev.AlternateSource) - - // Round-trip through marshal/unmarshal. - blob, err := ev.MarshalBinary() - require.NoError(t, err) - decoded := new(types.DoubleSignEvidence) - require.NoError(t, decoded.UnmarshalBinary(blob)) - require.Equal(t, ev.Height, decoded.Height) - require.Equal(t, ev.FirstHeader.Hash().String(), decoded.FirstHeader.Hash().String()) - require.Equal(t, ev.AlternateHeader.Hash().String(), decoded.AlternateHeader.Hash().String()) - require.Equal(t, ev.FirstSource, decoded.FirstSource) - require.Equal(t, ev.AlternateSource, decoded.AlternateSource) +// headerOtherProposer builds a validly-signed header for a non-genesis proposer. +func (h *dsHarness) headerOtherProposer(height uint64, variant byte) (*types.SignedHeader, *types.Data) { + h.t.Helper() + otherAddr, otherPub, otherSigner := buildSyncTestSigner(h.t) + data := makeData(h.gen.ChainID, height, 2) + _, hdr := makeSignedHeaderBytes( + h.t, h.gen.ChainID, height, otherAddr, otherPub, otherSigner, + []byte{variant, variant, variant}, data, []byte{variant}, + ) + return hdr, data } -func TestFirstObservation_IdenticalHashNoEvidence(t *testing.T) { - env := newDSTestEnv(t) - first := env.signHeaderAtHeight(5, 0x01) - env.saveHeader(first) - - prior, priorSource, err := firstObservation(context.Background(), env.store, env.cache, first.Height()) - require.NoError(t, err) - require.NotNil(t, prior) - require.Nil(t, buildEvidenceFromPair(prior, first, priorSource, types.EvidenceSourceP2P)) +// persist writes the header to the store as the canonical block at its height. +func (h *dsHarness) persist(hdr *types.SignedHeader, data *types.Data) { + h.t.Helper() + batch, err := h.store.NewBatch(context.Background()) + require.NoError(h.t, err) + require.NoError(h.t, batch.SaveBlockData(hdr, data, &hdr.Signature)) + require.NoError(h.t, batch.SetHeight(hdr.Height())) + require.NoError(h.t, batch.Commit()) } -func TestFirstObservation_NoPriorRecordReturnsNil(t *testing.T) { - env := newDSTestEnv(t) - prior, priorSource, err := firstObservation(context.Background(), env.store, env.cache, 5) - require.NoError(t, err) - require.Nil(t, prior) - require.Empty(t, priorSource) +// feed drives a header/data pair through the real processHeightEvent entry point. +func (h *dsHarness) feed(hdr *types.SignedHeader, data *types.Data, source common.EventSource) { + ev := common.DAHeightEvent{Header: hdr, Data: data, Source: source} + h.syncer.processHeightEvent(context.Background(), &ev) } -func TestBuildEvidenceFromPair_ProposerMismatch(t *testing.T) { - env := newDSTestEnv(t) - first := env.signHeaderAtHeight(5, 0x01) - alt := env.signHeaderWithOtherProposer(5, 0x02) - require.Nil(t, buildEvidenceFromPair(first, alt, types.EvidenceSourceDA, types.EvidenceSourceDA)) +// newDARetriever builds a daRetriever wired to the harness syncer's in-batch reporter. +func (h *dsHarness) newDARetriever() *daRetriever { + h.t.Helper() + mockClient := testmocks.NewMockClient(h.t) + mockClient.On("GetHeaderNamespace").Return([]byte("ns")).Maybe() + mockClient.On("GetDataNamespace").Return([]byte("ns")).Maybe() + return NewDARetriever(mockClient, h.cache, h.gen, zerolog.Nop(), h.syncer.reportInBatchDoubleSign) } -func TestBuildEvidenceFromPair_HappyPath(t *testing.T) { - env := newDSTestEnv(t) - first := env.signHeaderAtHeight(5, 0x01) - alt := env.signHeaderAtHeight(5, 0x02) - ev := buildEvidenceFromPair(first, alt, types.EvidenceSourceDA, types.EvidenceSourceDA) - require.NotNil(t, ev) - require.NoError(t, ev.ValidateBasic()) +// envelopeBlob builds a DA-envelope blob (sequencer-signed) for an empty-data header. +func (h *dsHarness) envelopeBlob(height uint64, variant byte) []byte { + h.t.Helper() + _, hdr := makeSignedHeaderBytes( + h.t, h.gen.ChainID, height, h.addr, h.pub, h.signer, + []byte{variant, variant, variant}, nil, []byte{variant}, + ) + content, err := hdr.MarshalBinary() + require.NoError(h.t, err) + envSig, err := h.signer.Sign(h.t.Context(), content) + require.NoError(h.t, err) + blob, err := hdr.MarshalDAEnvelope(envSig) + require.NoError(h.t, err) + return blob +} + +// applyNext builds a chain-valid block at currentHeight+1 and drives it through +// the real validate + apply pipeline (execution mocked), returning the applied +// header. This is how a canonical block becomes "finalized" the way production does. +func (h *dsHarness) applyNext(variant byte) *types.SignedHeader { + h.t.Helper() + st := h.syncer.getLastState() + height := st.LastBlockHeight + 1 + data := makeData(h.gen.ChainID, height, 1) + _, hdr := makeSignedHeaderBytes( + h.t, h.gen.ChainID, height, h.addr, h.pub, h.signer, + st.AppHash, data, st.LastHeaderHash, + ) + h.exec.EXPECT(). + ExecuteTxs(mock.Anything, mock.Anything, height, mock.Anything, st.AppHash). + Return([]byte{0xab, variant}, nil).Once() + ev := common.DAHeightEvent{Header: hdr, Data: data, DaHeight: 10 + height, Source: common.SourceDA} + h.syncer.processHeightEvent(context.Background(), &ev) + return hdr } -func TestBuildEvidenceFromPair_EdgeCases(t *testing.T) { - env := newDSTestEnv(t) - a := env.signHeaderAtHeight(5, 0x01) - b := env.signHeaderAtHeight(6, 0x02) - - require.Nil(t, buildEvidenceFromPair(nil, a, types.EvidenceSourceDA, types.EvidenceSourceDA)) - require.Nil(t, buildEvidenceFromPair(a, nil, types.EvidenceSourceDA, types.EvidenceSourceDA)) - require.Nil(t, buildEvidenceFromPair(a, b, types.EvidenceSourceDA, types.EvidenceSourceDA)) - require.Nil(t, buildEvidenceFromPair(a, a, types.EvidenceSourceDA, types.EvidenceSourceDA)) +// envelopeBlobNonEmptyData builds a DA-envelope blob whose header expects data. +// With no matching data blob supplied, it stays in the retriever's in-flight set, +// which lets a later batch's conflicting header be compared against it. +func (h *dsHarness) envelopeBlobNonEmptyData(height uint64, variant byte) []byte { + h.t.Helper() + data := makeData(h.gen.ChainID, height, 2) + _, hdr := makeSignedHeaderBytes( + h.t, h.gen.ChainID, height, h.addr, h.pub, h.signer, + []byte{variant, variant, variant}, data, []byte{variant}, + ) + content, err := hdr.MarshalBinary() + require.NoError(h.t, err) + envSig, err := h.signer.Sign(h.t.Context(), content) + require.NoError(h.t, err) + blob, err := hdr.MarshalDAEnvelope(envSig) + require.NoError(h.t, err) + return blob +} + +// legacyBlob builds a raw (non-envelope) header blob — the pre-upgrade DA format, +// which carries no envelope signature. +func (h *dsHarness) legacyBlob(height uint64, variant byte) []byte { + h.t.Helper() + bin, _ := makeSignedHeaderBytes( + h.t, h.gen.ChainID, height, h.addr, h.pub, h.signer, + []byte{variant, variant, variant}, nil, []byte{variant}, + ) + return bin } -func TestPersistEvidence_RejectsInvalid(t *testing.T) { - env := newDSTestEnv(t) - first := env.signHeaderAtHeight(5, 0x01) - bad := &types.DoubleSignEvidence{ - Height: 5, - FirstHeader: first, - AlternateHeader: first, +func (h *dsHarness) requireHalted() { + h.t.Helper() + select { + case got := <-h.errCh: + require.ErrorIs(h.t, got, errMaliciousProposer) + case <-time.After(time.Second): + h.t.Fatal("timed out waiting for critical error on errCh") } - require.Error(t, persistEvidence(context.Background(), env.store, bad)) + require.True(h.t, h.syncer.hasCriticalError.Load()) + require.Equal(h.t, int64(1), h.dsCount.Load()) } -func TestDoubleSignDedup(t *testing.T) { - d := newDoubleSignDedup() - require.True(t, d.markSeen(7, "abc")) - require.False(t, d.markSeen(7, "abc")) - require.True(t, d.markSeen(7, "def")) - require.True(t, d.markSeen(8, "abc")) +func (h *dsHarness) requireNoHalt() { + h.t.Helper() + require.False(h.t, h.syncer.hasCriticalError.Load()) + require.Equal(h.t, int64(0), h.dsCount.Load()) + require.Empty(h.t, h.errCh) } -func TestReportDoubleSign_PersistsAndHalts(t *testing.T) { - env := newDSTestEnv(t) - first := env.signHeaderAtHeight(5, 0x01) - alt := env.signHeaderAtHeight(5, 0x02) - ev := buildEvidenceFromPair(first, alt, types.EvidenceSourceP2P, types.EvidenceSourceDA) - require.NotNil(t, ev) - - metrics := common.NopMetrics() - seen := newDoubleSignDedup() +// A validly-signed alternate at an already-applied height halts the syncer. +func TestDoubleSign_AlternateAtAppliedHeight_Halts(t *testing.T) { + h := newDSHarness(t, true) - halt1 := reportDoubleSign(context.Background(), env.store, metrics, zerolog.Nop(), seen, ev) - require.Error(t, halt1) - require.ErrorIs(t, halt1, ErrDoubleSign) + canonical, cdata := h.header(5, 0x01) + h.persist(canonical, cdata) - // Second call must be a no-op via dedup. - halt2 := reportDoubleSign(context.Background(), env.store, metrics, zerolog.Nop(), seen, ev) - require.NoError(t, halt2) + alt, adata := h.header(5, 0x02) + require.NotEqual(t, canonical.Hash().String(), alt.Hash().String()) - key := store.GetDoubleSignEvidenceKey(ev.Height, ev.AlternateHeader.Hash()) - blob, err := env.store.GetMetadata(context.Background(), key) - require.NoError(t, err) - decoded := new(types.DoubleSignEvidence) - require.NoError(t, decoded.UnmarshalBinary(blob)) - require.Equal(t, ev.Height, decoded.Height) - require.Equal(t, ev.AlternateHeader.Hash().String(), decoded.AlternateHeader.Hash().String()) + h.feed(alt, adata, common.SourceDA) + h.requireHalted() } -func TestP2PHandler_DoubleSignTriggersCriticalError(t *testing.T) { - env := newDSTestEnv(t) - - // Persist canonical header, then arrange a conflicting one to come in via P2P. - first := env.signHeaderAtHeight(5, 0x01) - env.saveHeader(first) - alt := env.signHeaderAtHeight(5, 0x02) - require.NotEqual(t, first.Hash().String(), alt.Hash().String()) - - headerStoreMock := extmocks.NewMockStore[*types.P2PSignedHeader](t) - dataStoreMock := extmocks.NewMockStore[*types.P2PData](t) - headerStoreMock.EXPECT(). - GetByHeight(mock.Anything, uint64(5)). - Return(&types.P2PSignedHeader{SignedHeader: alt}, nil). - Once() +// Detection is source-independent: a P2P-sourced alternate halts the same way. +func TestDoubleSign_CrossSource_P2PAlternate_Halts(t *testing.T) { + h := newDSHarness(t, true) - h := NewP2PHandler(headerStoreMock, dataStoreMock, env.cache, env.gen, zerolog.Nop(), env.detectDoubleSign) + canonical, cdata := h.header(7, 0x01) + h.persist(canonical, cdata) - ch := make(chan common.DAHeightEvent, 1) - require.NoError(t, h.ProcessHeight(context.Background(), 5, ch)) - - captured := env.captured() - require.Len(t, captured, 1) - require.Equal(t, alt.Hash().String(), captured[0].AlternateHeader.Hash().String()) - require.Equal(t, types.EvidenceSourceP2P, captured[0].AlternateSource) - - key := store.GetDoubleSignEvidenceKey(5, alt.Hash()) - blob, err := env.store.GetMetadata(context.Background(), key) - require.NoError(t, err) - require.NotEmpty(t, blob) - - select { - case evt := <-ch: - t.Fatalf("expected no event on double-sign; got %+v", evt) - default: - } + alt, adata := h.header(7, 0x02) + h.feed(alt, adata, common.SourceP2P) + h.requireHalted() } -func TestP2PHandler_ProposerMismatchIsNotEvidence(t *testing.T) { - env := newDSTestEnv(t) - first := env.signHeaderAtHeight(5, 0x01) - env.saveHeader(first) - - // A header from a different signer must be rejected before the detector runs. - badHdr := env.signHeaderWithOtherProposer(5, 0x02) - - headerStoreMock := extmocks.NewMockStore[*types.P2PSignedHeader](t) - dataStoreMock := extmocks.NewMockStore[*types.P2PData](t) - headerStoreMock.EXPECT(). - GetByHeight(mock.Anything, uint64(5)). - Return(&types.P2PSignedHeader{SignedHeader: badHdr}, nil). - Once() +// Re-observing the canonical header (identical hash) must not halt. +func TestDoubleSign_BenignDuplicate_DoesNotHalt(t *testing.T) { + h := newDSHarness(t, true) - h := NewP2PHandler(headerStoreMock, dataStoreMock, env.cache, env.gen, zerolog.Nop(), env.detectDoubleSign) + canonical, cdata := h.header(5, 0x01) + h.persist(canonical, cdata) - ch := make(chan common.DAHeightEvent, 1) - err := h.ProcessHeight(context.Background(), 5, ch) - require.Error(t, err) - require.Empty(t, env.captured()) + h.feed(canonical, cdata, common.SourceDA) + h.requireNoHalt() } -func TestDARetriever_DoubleSignSamePendingBatch(t *testing.T) { - env := newDSTestEnv(t) - - first := env.signHeaderAtHeight(5, 0x01) - alt := env.signHeaderAtHeight(5, 0x02) - require.NotEqual(t, first.Hash().String(), alt.Hash().String()) - - firstBin, err := first.MarshalBinary() - require.NoError(t, err) - altBin, err := alt.MarshalBinary() - require.NoError(t, err) +// With halting disabled, equivocation is counted but the node continues. +func TestDoubleSign_HaltFlagOff_WarnsButContinues(t *testing.T) { + h := newDSHarness(t, false) - mockClient := testmocks.NewMockClient(t) - mockClient.On("GetHeaderNamespace").Return([]byte("ns")).Maybe() - mockClient.On("GetDataNamespace").Return([]byte("ns")).Maybe() + canonical, cdata := h.header(5, 0x01) + h.persist(canonical, cdata) - r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.detectDoubleSign) - events := r.ProcessBlobs(context.Background(), [][]byte{firstBin, altBin}, 100) - require.Empty(t, events) + alt, adata := h.header(5, 0x02) + h.feed(alt, adata, common.SourceDA) - captured := env.captured() - require.Len(t, captured, 1) - require.Equal(t, uint64(5), captured[0].Height) - require.Equal(t, types.EvidenceSourceDA, captured[0].FirstSource) - require.Equal(t, types.EvidenceSourceDA, captured[0].AlternateSource) + require.Equal(t, int64(1), h.dsCount.Load()) + require.False(t, h.syncer.hasCriticalError.Load()) + require.Empty(t, h.errCh) } -func TestDARetriever_DoubleSignAcrossBatches(t *testing.T) { - env := newDSTestEnv(t) - - first := env.signHeaderAtHeight(5, 0x01) - env.saveHeader(first) +// The same (height, alternate-hash) seen twice is reported only once. +func TestDoubleSign_DuplicateAlternate_ReportedOnce(t *testing.T) { + h := newDSHarness(t, true) - alt := env.signHeaderAtHeight(5, 0x02) - altBin, err := alt.MarshalBinary() - require.NoError(t, err) + canonical, cdata := h.header(11, 0x01) + h.persist(canonical, cdata) - mockClient := testmocks.NewMockClient(t) - mockClient.On("GetHeaderNamespace").Return([]byte("ns")).Maybe() - mockClient.On("GetDataNamespace").Return([]byte("ns")).Maybe() + alt, adata := h.header(11, 0x02) + h.feed(alt, adata, common.SourceDA) + h.feed(alt, adata, common.SourceP2P) - r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.detectDoubleSign) - events := r.ProcessBlobs(context.Background(), [][]byte{altBin}, 101) - require.Empty(t, events) + require.Equal(t, int64(1), h.dsCount.Load()) - captured := env.captured() - require.Len(t, captured, 1) - require.Equal(t, alt.Hash().String(), captured[0].AlternateHeader.Hash().String()) + count := 0 + timeout := time.After(100 * time.Millisecond) +loop: + for { + select { + case <-h.errCh: + count++ + case <-timeout: + break loop + } + } + require.Equal(t, 1, count) } -func TestDARetriever_BenignDuplicateAcrossBatchesDoesNotFire(t *testing.T) { - env := newDSTestEnv(t) - - first := env.signHeaderAtHeight(5, 0x01) - env.saveHeader(first) +// A conflicting header from a different proposer is not sequencer equivocation. +func TestDoubleSign_ProposerMismatch_NotEvidence(t *testing.T) { + h := newDSHarness(t, true) - // Same header re-observed from DA (e.g. re-posted at a different DA height). - sameBin, err := first.MarshalBinary() - require.NoError(t, err) + canonical, cdata := h.header(5, 0x01) + h.persist(canonical, cdata) - mockClient := testmocks.NewMockClient(t) - mockClient.On("GetHeaderNamespace").Return([]byte("ns")).Maybe() - mockClient.On("GetDataNamespace").Return([]byte("ns")).Maybe() + alt, adata := h.headerOtherProposer(5, 0x02) + require.NotEqual(t, canonical.Hash().String(), alt.Hash().String()) - r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.detectDoubleSign) - _ = r.ProcessBlobs(context.Background(), [][]byte{sameBin}, 101) - require.Empty(t, env.captured()) + h.feed(alt, adata, common.SourceDA) + h.requireNoHalt() } -// A legacy blob with the correct proposer but a tampered signature must be -// rejected before reaching the detector or pending cache. -func TestDARetriever_LegacyForgedSignatureRejected(t *testing.T) { - env := newDSTestEnv(t) +// A forged alternate (genesis proposer address but invalid signature) must not +// halt the node — guards against a forged-header denial of service. +func TestDoubleSign_ForgedAlternate_NotEvidence(t *testing.T) { + h := newDSHarness(t, true) - // Tamper the signature byte to invalidate verification while preserving - // every other field (proposer address included). - good := env.signHeaderAtHeight(5, 0x01) - pbHdr, err := good.ToProto() - require.NoError(t, err) - pbHdr.Signature = append([]byte(nil), good.Signature...) - pbHdr.Signature[0] ^= 0xff - bin, err := proto.Marshal(pbHdr) - require.NoError(t, err) + canonical, cdata := h.header(5, 0x01) + h.persist(canonical, cdata) - mockClient := testmocks.NewMockClient(t) - mockClient.On("GetHeaderNamespace").Return([]byte("ns")).Maybe() - mockClient.On("GetDataNamespace").Return([]byte("ns")).Maybe() + alt, adata := h.header(5, 0x02) + require.NotEmpty(t, alt.Signature) + alt.Signature[0] ^= 0xFF // corrupt the signature, leaving the header hash intact - r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.detectDoubleSign) - require.Nil(t, r.tryDecodeHeader(bin, 100)) - - _, _, ok := env.cache.GetPendingSignedHeader(5) - require.False(t, ok) + h.feed(alt, adata, common.SourceDA) + h.requireNoHalt() } -// Detection must trigger from a pending cache entry too, before persistence. -func TestFirstObservation_PendingCacheHitProducesEvidence(t *testing.T) { - env := newDSTestEnv(t) +// Two distinct, envelope-signed headers for the same height in one DA batch are +// detected by the retriever before either is applied, and halt the syncer. +func TestDoubleSign_InBatchDA_EnvelopeAuthored_Halts(t *testing.T) { + h := newDSHarness(t, true) + r := h.newDARetriever() - first := env.signHeaderAtHeight(5, 0x01) - env.cache.SetPendingSignedHeader(first, types.EvidenceSourceDA) - // First header is in-flight, not yet on disk. + first := h.envelopeBlob(5, 0x01) + alt := h.envelopeBlob(5, 0x02) - alt := env.signHeaderAtHeight(5, 0x02) - prior, priorSource, err := firstObservation(context.Background(), env.store, env.cache, alt.Height()) - require.NoError(t, err) - require.Equal(t, first.Hash().String(), prior.Hash().String()) - require.Equal(t, types.EvidenceSourceDA, priorSource) - - ev := buildEvidenceFromPair(prior, alt, priorSource, types.EvidenceSourceP2P) - require.NotNil(t, ev) - require.Equal(t, first.Hash().String(), ev.FirstHeader.Hash().String()) - require.Equal(t, alt.Hash().String(), ev.AlternateHeader.Hash().String()) - require.Equal(t, types.EvidenceSourceDA, ev.FirstSource) - require.Equal(t, types.EvidenceSourceP2P, ev.AlternateSource) + r.ProcessBlobs(context.Background(), [][]byte{first, alt}, 100) + h.requireHalted() } -func TestFirstObservation_PendingCacheBenignDuplicate(t *testing.T) { - env := newDSTestEnv(t) +// Identical bytes seen twice in one DA batch are a benign duplicate, not equivocation. +func TestDoubleSign_InBatchDA_BenignDuplicate_DoesNotHalt(t *testing.T) { + h := newDSHarness(t, true) + r := h.newDARetriever() - first := env.signHeaderAtHeight(5, 0x01) - env.cache.SetPendingSignedHeader(first, types.EvidenceSourceDA) + blob := h.envelopeBlob(5, 0x01) + r.ProcessBlobs(context.Background(), [][]byte{blob, blob}, 100) - prior, priorSource, err := firstObservation(context.Background(), env.store, env.cache, first.Height()) - require.NoError(t, err) - require.NotNil(t, prior) - require.Nil(t, buildEvidenceFromPair(prior, first, priorSource, types.EvidenceSourceP2P)) + require.False(t, h.syncer.hasCriticalError.Load()) + require.Equal(t, int64(0), h.dsCount.Load()) + require.Empty(t, h.errCh) } -func TestFirstObservation_PendingEvictedAfterRemoval(t *testing.T) { - env := newDSTestEnv(t) - - first := env.signHeaderAtHeight(5, 0x01) - env.cache.SetPendingSignedHeader(first, types.EvidenceSourceDA) - env.cache.RemovePendingSignedHeader(5) - - prior, _, err := firstObservation(context.Background(), env.store, env.cache, first.Height()) - require.NoError(t, err) - require.Nil(t, prior) -} +// Tip race: when two conflicting headers target the next height, the first is +// validated and applied through the real pipeline; the second then arrives at the +// now-finalized height and is caught. (processHeightEvent is single-threaded, so +// this models how the race resolves.) +func TestDoubleSign_TipRace_FirstAppliesThenSecondCaught(t *testing.T) { + h := newDSHarness(t, true) -func TestDoubleSignEvidence_ValidateBasic(t *testing.T) { - env := newDSTestEnv(t) - first := env.signHeaderAtHeight(5, 0x01) - alt := env.signHeaderAtHeight(5, 0x02) - - t.Run("nil receiver", func(t *testing.T) { - var e *types.DoubleSignEvidence - require.Error(t, e.ValidateBasic()) - }) - t.Run("missing headers", func(t *testing.T) { - require.Error(t, (&types.DoubleSignEvidence{Height: 5}).ValidateBasic()) - }) - t.Run("height mismatch", func(t *testing.T) { - e := &types.DoubleSignEvidence{Height: 99, FirstHeader: first, AlternateHeader: alt} - require.Error(t, e.ValidateBasic()) - }) - t.Run("identical hashes", func(t *testing.T) { - e := &types.DoubleSignEvidence{Height: 5, FirstHeader: first, AlternateHeader: first} - require.Error(t, e.ValidateBasic()) - }) - t.Run("proposer mismatch", func(t *testing.T) { - other := env.signHeaderWithOtherProposer(5, 0x02) - e := &types.DoubleSignEvidence{Height: 5, FirstHeader: first, AlternateHeader: other} - require.ErrorContains(t, e.ValidateBasic(), "different proposers") - }) - t.Run("happy path", func(t *testing.T) { - e := &types.DoubleSignEvidence{Height: 5, FirstHeader: first, AlternateHeader: alt} - require.NoError(t, e.ValidateBasic()) - }) -} + canonical := h.applyNext(0x01) // validated + applied at height 1 via the real pipeline + require.Equal(t, uint64(1), h.syncer.getLastState().LastBlockHeight) -func TestPBDoubleSignEvidence_RoundTrip(t *testing.T) { - env := newDSTestEnv(t) - ev := &types.DoubleSignEvidence{ - Height: 7, - FirstHeader: env.signHeaderAtHeight(7, 0x01), - AlternateHeader: env.signHeaderAtHeight(7, 0x02), - DetectedAt: time.Unix(1_700_000_000, 500).UTC(), - FirstSource: types.EvidenceSourceDA, - AlternateSource: types.EvidenceSourceP2P, - } - p, err := ev.ToProto() - require.NoError(t, err) - blob, err := proto.Marshal(p) - require.NoError(t, err) + alt, adata := h.header(1, 0x02) + require.NotEqual(t, canonical.Hash().String(), alt.Hash().String()) - decoded := new(pb.DoubleSignEvidence) - require.NoError(t, proto.Unmarshal(blob, decoded)) - require.Equal(t, ev.Height, decoded.Height) - require.Equal(t, ev.DetectedAt.UnixNano(), decoded.DetectedAt) - require.Equal(t, ev.FirstSource, decoded.FirstSource) - require.Equal(t, ev.AlternateSource, decoded.AlternateSource) - require.NotNil(t, decoded.FirstHeader) - require.NotNil(t, decoded.AlternateHeader) - require.Equal(t, ev.FirstHeader.Height(), decoded.FirstHeader.Header.Height) + h.feed(alt, adata, common.SourceP2P) + h.requireHalted() } -func TestDARetriever_DoubleSignEvidenceHasMatchingProposers(t *testing.T) { - env := newDSTestEnv(t) - - first := env.signHeaderAtHeight(5, 0x01) - alt := env.signHeaderAtHeight(5, 0x02) - firstBin, err := first.MarshalBinary() - require.NoError(t, err) - altBin, err := alt.MarshalBinary() - require.NoError(t, err) - - mockClient := testmocks.NewMockClient(t) - mockClient.On("GetHeaderNamespace").Return([]byte("ns")).Maybe() - mockClient.On("GetDataNamespace").Return([]byte("ns")).Maybe() +// Two conflicting headers arriving in SEPARATE DA batches, both before either is +// applied (kept in flight via non-empty data), are still caught in-batch. +func TestDoubleSign_InBatchDA_CrossBatchInFlight_Halts(t *testing.T) { + h := newDSHarness(t, true) + r := h.newDARetriever() - r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.detectDoubleSign) - _ = r.ProcessBlobs(context.Background(), [][]byte{firstBin, altBin}, 100) + r.ProcessBlobs(context.Background(), [][]byte{h.envelopeBlobNonEmptyData(5, 0x01)}, 100) + r.ProcessBlobs(context.Background(), [][]byte{h.envelopeBlobNonEmptyData(5, 0x02)}, 101) - captured := env.captured() - require.Len(t, captured, 1) - require.Equal(t, env.gen.ProposerAddress, captured[0].FirstHeader.ProposerAddress) - require.Equal(t, env.gen.ProposerAddress, captured[0].AlternateHeader.ProposerAddress) + h.requireHalted() } -func TestSyncer_EvictsPendingHeaderOnPersist(t *testing.T) { +// Characterization of the Cut-2 gap: a conflicting header gossiped over P2P for a +// height we have already finalized is short-circuited by the P2P handler and never +// reaches detection — the header store is not even queried. If this changes, this +// test should fail and the gap (and its docs) revisited. +func TestDoubleSign_P2PReplayAtFinalizedHeight_NotDetected(t *testing.T) { + addr, _, _ := buildSyncTestSigner(t) + gen := genesis.Genesis{ChainID: "ds-test", InitialHeight: 1, ProposerAddress: addr} memDS := dssync.MutexWrap(ds.NewMapDatastore()) - st := store.New(memDS) - - cm, err := cache.NewManager(config.DefaultConfig(), st, zerolog.Nop()) + cm, err := cache.NewManager(config.DefaultConfig(), store.New(memDS), zerolog.Nop()) require.NoError(t, err) - addr, pub, signer := buildSyncTestSigner(t) - cfg := config.DefaultConfig() - gen := genesis.Genesis{ - ChainID: "syncer-evict", InitialHeight: 1, - StartTime: time.Now().Add(-time.Second), ProposerAddress: addr, - } - - mockExec := testmocks.NewMockExecutor(t) - mockExec.EXPECT().InitChain(mock.Anything, mock.Anything, uint64(1), gen.ChainID). - Return([]byte("app0"), nil).Once() - mockExec.EXPECT().ExecuteTxs(mock.Anything, mock.Anything, uint64(1), mock.Anything, mock.Anything). - Return([]byte("app1"), nil).Once() - - mockHeaderStore := extmocks.NewMockStore[*types.P2PSignedHeader](t) - mockHeaderStore.EXPECT().Height().Return(uint64(0)).Maybe() - mockDataStore := extmocks.NewMockStore[*types.P2PData](t) - mockDataStore.EXPECT().Height().Return(uint64(0)).Maybe() - - s := NewSyncer( - st, mockExec, nil, cm, common.NopMetrics(), cfg, gen, - mockHeaderStore, mockDataStore, zerolog.Nop(), - common.DefaultBlockOptions(), make(chan error, 1), nil, - ) - require.NoError(t, s.initializeState()) - s.ctx = t.Context() - - state := s.getLastState() - data := makeData(gen.ChainID, 1, 0) - _, hdr := makeSignedHeaderBytes(t, gen.ChainID, 1, addr, pub, signer, state.AppHash, data, nil) + // strict mocks: any GetByHeight call would fail the test + headerStore := extmocks.NewMockStore[*types.P2PSignedHeader](t) + dataStore := extmocks.NewMockStore[*types.P2PData](t) + handler := NewP2PHandler(headerStore, dataStore, cm, gen, zerolog.Nop()) + handler.SetProcessedHeight(5) // height 5 already finalized - cm.SetPendingSignedHeader(hdr, types.EvidenceSourceP2P) - _, _, ok := cm.GetPendingSignedHeader(1) - require.True(t, ok) - - evt := common.DAHeightEvent{Header: hdr, Data: data, DaHeight: 1} - s.processHeightEvent(s.ctx, &evt) - - _, _, ok = cm.GetPendingSignedHeader(1) - require.False(t, ok) + ch := make(chan common.DAHeightEvent, 1) + require.NoError(t, handler.ProcessHeight(context.Background(), 5, ch)) + require.Empty(t, ch) // no event emitted; header never fetched → conflict not surfaced } -// End-to-end: a double-sign through a real Syncer must halt on errorCh, -// flip hasCriticalError, and bump DoubleSignsDetected only once for duplicate evidence. -func TestSyncer_DoubleSignHaltsAndEmitsCriticalError(t *testing.T) { - memDS := dssync.MutexWrap(ds.NewMapDatastore()) - st := store.New(memDS) +// Characterization of the legacy gap: two conflicting non-envelope (legacy) headers +// in one batch are NOT detected, because the early envelope-signature check is +// unavailable in non-strict mode. (Such a conflict is only caught later via +// checkDoubleSign if it reappears on DA after one header is applied.) +func TestDoubleSign_InBatchDA_LegacyNotDetected(t *testing.T) { + h := newDSHarness(t, true) + r := h.newDARetriever() - cm, err := cache.NewManager(config.DefaultConfig(), st, zerolog.Nop()) - require.NoError(t, err) + events := r.ProcessBlobs(context.Background(), [][]byte{h.legacyBlob(5, 0x01), h.legacyBlob(5, 0x02)}, 100) - addr, pub, signer := buildSyncTestSigner(t) - cfg := config.DefaultConfig() - gen := genesis.Genesis{ - ChainID: "syncer-ds", InitialHeight: 1, - StartTime: time.Now().Add(-time.Second), ProposerAddress: addr, - } - - mockExec := testmocks.NewMockExecutor(t) - mockExec.EXPECT(). - InitChain(mock.Anything, mock.Anything, uint64(1), gen.ChainID). - Return([]byte("app0"), nil).Once() - - mockHeaderStore := extmocks.NewMockStore[*types.P2PSignedHeader](t) - mockHeaderStore.EXPECT().Height().Return(uint64(0)).Maybe() - mockDataStore := extmocks.NewMockStore[*types.P2PData](t) - mockDataStore.EXPECT().Height().Return(uint64(0)).Maybe() - - // Wire a counting metric so we can assert exact increments. - metrics := common.NopMetrics() - var dsCount atomic.Int64 - metrics.DoubleSignsDetected = &counterCtr{n: &dsCount} - - errCh := make(chan error, 4) - s := NewSyncer( - st, mockExec, nil, cm, metrics, cfg, gen, - mockHeaderStore, mockDataStore, zerolog.Nop(), - common.DefaultBlockOptions(), errCh, nil, - ) - require.NoError(t, s.initializeState()) - s.doubleSignSeen = newDoubleSignDedup() // normally set up by Start() - - // Fire two identical alternate events to simulate P2P + DA converging. - first := makeHeaderForSyncer(t, gen, addr, pub, signer, 1, 0x01) - saveHeaderViaBatch(t, st, gen, first) - - alt := makeHeaderForSyncer(t, gen, addr, pub, signer, 1, 0x02) - require.NotEqual(t, first.Hash().String(), alt.Hash().String()) - - p2pEv := &types.DoubleSignEvidence{ - Height: 1, FirstHeader: first, AlternateHeader: alt, - DetectedAt: time.Now(), FirstSource: types.EvidenceSourceStored, AlternateSource: types.EvidenceSourceP2P, - } - daEv := &types.DoubleSignEvidence{ - Height: 1, FirstHeader: first, AlternateHeader: alt, - DetectedAt: time.Now(), FirstSource: types.EvidenceSourceStored, AlternateSource: types.EvidenceSourceDA, - } - - s.handleDoubleSign(context.Background(), p2pEv) - s.handleDoubleSign(context.Background(), daEv) - - require.Equal(t, int64(1), dsCount.Load(), "duplicate evidence must not double-count") - require.True(t, s.hasCriticalError.Load()) - - select { - case got := <-errCh: - require.ErrorIs(t, got, ErrDoubleSign) - case <-time.After(time.Second): - t.Fatal("timed out waiting for critical error on errCh") - } - - key := store.GetDoubleSignEvidenceKey(1, alt.Hash()) - blob, err := st.GetMetadata(context.Background(), key) - require.NoError(t, err) - require.NotEmpty(t, blob) + // Non-vacuous: the legacy headers DID decode (one survives first-write-wins and + // is emitted), proving we exercised the in-batch path — it just didn't detect. + require.Len(t, events, 1) + h.requireNoHalt() } -func makeHeaderForSyncer(t *testing.T, gen genesis.Genesis, addr []byte, pub crypto.PubKey, signer signerpkg.Signer, height uint64, variant byte) *types.SignedHeader { - t.Helper() - _, hdr := makeSignedHeaderBytes(t, gen.ChainID, height, addr, pub, signer, - []byte{variant, variant, variant}, nil, nil) - return hdr -} - -// persists a signed header + empty data + signature and bumps the store height. -func saveHeaderViaBatch(t *testing.T, st store.Store, gen genesis.Genesis, hdr *types.SignedHeader) { - t.Helper() - batch, err := st.NewBatch(context.Background()) - require.NoError(t, err) - require.NoError(t, batch.SaveBlockData(hdr, &types.Data{ - Metadata: &types.Metadata{ChainID: gen.ChainID, Height: hdr.Height(), Time: hdr.BaseHeader.Time}, - }, &hdr.Signature)) - require.NoError(t, batch.SetHeight(hdr.Height())) - require.NoError(t, batch.Commit()) +func TestDoubleSignDedup(t *testing.T) { + d := newDoubleSignDedup() + require.True(t, d.markSeen(5, "a")) + require.False(t, d.markSeen(5, "a")) + require.True(t, d.markSeen(5, "b")) + require.True(t, d.markSeen(6, "a")) + require.False(t, d.markSeen(6, "a")) } // go-kit Counter backed by an atomic int64 so tests can read exact increments. diff --git a/block/internal/syncing/p2p_handler.go b/block/internal/syncing/p2p_handler.go index 085542d5aa..a3778757a1 100644 --- a/block/internal/syncing/p2p_handler.go +++ b/block/internal/syncing/p2p_handler.go @@ -33,9 +33,6 @@ type P2PHandler struct { genesis genesis.Genesis logger zerolog.Logger - // detectDoubleSign returns true on a confirmed double-sign. Nil disables. - detectDoubleSign doubleSignDetector - processedHeight atomic.Uint64 } @@ -46,15 +43,13 @@ func NewP2PHandler( cache cache.CacheManager, genesis genesis.Genesis, logger zerolog.Logger, - detectDoubleSign doubleSignDetector, ) *P2PHandler { return &P2PHandler{ - headerStore: headerStore, - dataStore: dataStore, - cache: cache, - genesis: genesis, - logger: logger.With().Str("component", "p2p_handler").Logger(), - detectDoubleSign: detectDoubleSign, + headerStore: headerStore, + dataStore: dataStore, + cache: cache, + genesis: genesis, + logger: logger.With().Str("component", "p2p_handler").Logger(), } } @@ -74,14 +69,9 @@ func (h *P2PHandler) SetProcessedHeight(height uint64) { // ProcessHeight retrieves and validates both header and data for the given height from P2P stores. // It blocks until both are available, validates consistency (proposer address and data hash match), // then emits the event to heightInCh or stores it as pending. Updates processedHeight on success. -// -// When double-sign detection is enabled, the processedHeight short-circuit is -// deferred so alternates at already-processed heights still trigger detection. func (h *P2PHandler) ProcessHeight(ctx context.Context, height uint64, heightInCh chan<- common.DAHeightEvent) error { - if h.detectDoubleSign == nil { - if height <= h.processedHeight.Load() { - return nil - } + if height <= h.processedHeight.Load() { + return nil } p2pHeader, err := h.headerStore.GetByHeight(ctx, height) @@ -96,20 +86,6 @@ func (h *P2PHandler) ProcessHeight(ctx context.Context, height uint64, heightInC return err } - // ValidateBasic is the precondition for treating an alternate as evidence. - if h.detectDoubleSign != nil { - if err := p2pHeader.ValidateBasic(); err != nil { - h.logger.Debug().Uint64("height", height).Err(err).Msg("invalid signed header from P2P") - return err - } - if h.detectDoubleSign(ctx, p2pHeader.SignedHeader, types.EvidenceSourceP2P) { - return nil - } - if height <= h.processedHeight.Load() { - return nil - } - } - p2pData, err := h.dataStore.GetByHeight(ctx, height) if err != nil { if ctx.Err() == nil { diff --git a/block/internal/syncing/p2p_handler_doublesign_test.go b/block/internal/syncing/p2p_handler_doublesign_test.go deleted file mode 100644 index 55ab3e8a8a..0000000000 --- a/block/internal/syncing/p2p_handler_doublesign_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package syncing - -import ( - "context" - "testing" - - "github.com/rs/zerolog" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - "google.golang.org/protobuf/proto" - - "github.com/evstack/ev-node/block/internal/common" - extmocks "github.com/evstack/ev-node/test/mocks/external" - "github.com/evstack/ev-node/types" -) - -// The processedHeight short-circuit must run AFTER the detector so an -// alternate at an already-applied height still triggers detection. -func TestP2PHandler_DetectsAtAlreadyProcessedHeight(t *testing.T) { - env := newDSTestEnv(t) - - first := env.signHeaderAtHeight(5, 0x01) - env.saveHeader(first) - - alt := env.signHeaderAtHeight(5, 0x02) - require.NotEqual(t, first.Hash().String(), alt.Hash().String()) - - headerStoreMock := extmocks.NewMockStore[*types.P2PSignedHeader](t) - dataStoreMock := extmocks.NewMockStore[*types.P2PData](t) - headerStoreMock.EXPECT(). - GetByHeight(mock.Anything, uint64(5)). - Return(&types.P2PSignedHeader{SignedHeader: alt}, nil). - Once() - - h := NewP2PHandler(headerStoreMock, dataStoreMock, env.cache, env.gen, - zerolog.Nop(), env.detectDoubleSign) - - h.SetProcessedHeight(5) - - ch := make(chan common.DAHeightEvent, 1) - require.NoError(t, h.ProcessHeight(context.Background(), 5, ch)) - - captured := env.captured() - require.Len(t, captured, 1) - require.Equal(t, alt.Hash().String(), captured[0].AlternateHeader.Hash().String()) - require.Equal(t, types.EvidenceSourceP2P, captured[0].AlternateSource) - - select { - case evt := <-ch: - t.Fatalf("expected no event when double-sign fires; got %+v", evt) - default: - } -} - -// When detection is disabled the legacy short-circuit must still fire. -func TestP2PHandler_LegacyShortCircuitWhenDetectionDisabled(t *testing.T) { - env := newDSTestEnv(t) - - headerStoreMock := extmocks.NewMockStore[*types.P2PSignedHeader](t) - dataStoreMock := extmocks.NewMockStore[*types.P2PData](t) - - h := NewP2PHandler(headerStoreMock, dataStoreMock, env.cache, env.gen, - zerolog.Nop(), nil) - - h.SetProcessedHeight(5) - - // Mock has no expectation set — a call to GetByHeight would panic. - ch := make(chan common.DAHeightEvent, 1) - require.NoError(t, h.ProcessHeight(context.Background(), 5, ch)) -} - -// A P2P header with the correct proposer but a tampered signature must be -// rejected before the detector runs. -func TestP2PHandler_InvalidSigRejectedBeforeDetector(t *testing.T) { - env := newDSTestEnv(t) - - good := env.signHeaderAtHeight(5, 0x01) - pbHdr, err := good.ToProto() - require.NoError(t, err) - pbHdr.Signature = append([]byte(nil), good.Signature...) - pbHdr.Signature[0] ^= 0xff - bin, err := proto.Marshal(pbHdr) - require.NoError(t, err) - - forged := new(types.SignedHeader) - { - var pbDecoded = pbHdr - require.NoError(t, proto.Unmarshal(bin, pbDecoded)) - require.NoError(t, forged.FromProto(pbDecoded)) - } - - headerStoreMock := extmocks.NewMockStore[*types.P2PSignedHeader](t) - dataStoreMock := extmocks.NewMockStore[*types.P2PData](t) - headerStoreMock.EXPECT(). - GetByHeight(mock.Anything, uint64(5)). - Return(&types.P2PSignedHeader{SignedHeader: forged}, nil). - Once() - - h := NewP2PHandler(headerStoreMock, dataStoreMock, env.cache, env.gen, - zerolog.Nop(), env.detectDoubleSign) - - ch := make(chan common.DAHeightEvent, 1) - err = h.ProcessHeight(context.Background(), 5, ch) - require.Error(t, err) - - require.Empty(t, env.captured()) - - _, _, ok := env.cache.GetPendingSignedHeader(5) - require.False(t, ok) -} - -// First P2P observation must populate the pending cache so a later DA blob -// at the same height can be matched against it. -func TestP2PHandler_SetsPendingSignedHeaderOnFirstObservation(t *testing.T) { - env := newDSTestEnv(t) - - // Provide both header and data so ProcessHeight reaches the emit step. - first := env.signHeaderAtHeight(5, 0x01) - first.DataHash = common.DataHashForEmptyTxs - - headerStoreMock := extmocks.NewMockStore[*types.P2PSignedHeader](t) - dataStoreMock := extmocks.NewMockStore[*types.P2PData](t) - headerStoreMock.EXPECT(). - GetByHeight(mock.Anything, uint64(5)). - Return(&types.P2PSignedHeader{SignedHeader: first}, nil). - Once() - dataStoreMock.EXPECT(). - GetByHeight(mock.Anything, uint64(5)). - Return(&types.P2PData{Data: &types.Data{ - Metadata: &types.Metadata{ChainID: env.chainID, Height: 5, Time: first.BaseHeader.Time}, - }}, nil). - Once() - - h := NewP2PHandler(headerStoreMock, dataStoreMock, env.cache, env.gen, - zerolog.Nop(), env.detectDoubleSign) - - ch := make(chan common.DAHeightEvent, 1) - require.NoError(t, h.ProcessHeight(context.Background(), 5, ch)) - - got, src, ok := env.cache.GetPendingSignedHeader(5) - require.True(t, ok) - require.Equal(t, first.Hash().String(), got.Hash().String()) - require.Equal(t, types.EvidenceSourceP2P, src) -} diff --git a/block/internal/syncing/p2p_handler_test.go b/block/internal/syncing/p2p_handler_test.go index 016e69fbb8..8bffc31ede 100644 --- a/block/internal/syncing/p2p_handler_test.go +++ b/block/internal/syncing/p2p_handler_test.go @@ -90,7 +90,7 @@ func setupP2P(t *testing.T) *P2PTestData { cacheManager, err := cache.NewManager(cfg, st, zerolog.Nop()) require.NoError(t, err, "failed to create cache manager") - handler := NewP2PHandler(headerStoreMock, dataStoreMock, cacheManager, gen, zerolog.Nop(), nil) + handler := NewP2PHandler(headerStoreMock, dataStoreMock, cacheManager, gen, zerolog.Nop()) return &P2PTestData{ Handler: handler, HeaderStore: headerStoreMock, diff --git a/block/internal/syncing/syncer.go b/block/internal/syncing/syncer.go index d167b39dc4..f0a548668d 100644 --- a/block/internal/syncing/syncer.go +++ b/block/internal/syncing/syncer.go @@ -183,17 +183,16 @@ func (s *Syncer) Start(ctx context.Context) (err error) { return fmt.Errorf("failed to initialize syncer state: %w", err) } - // Initialize handlers. DA and P2P share s.detectDoubleSign so cross-path - // duplicates are deduped through doubleSignSeen and only reported once. + // Initialize handlers s.doubleSignSeen = newDoubleSignDedup() - s.daRetriever = NewDARetriever(s.daClient, s.cache, s.genesis, s.logger, s.detectDoubleSign) + s.daRetriever = NewDARetriever(s.daClient, s.cache, s.genesis, s.logger, s.reportInBatchDoubleSign) if s.config.Instrumentation.IsTracingEnabled() { s.daRetriever = WithTracingDARetriever(s.daRetriever) } s.fiRetriever = da.NewForcedInclusionRetriever(s.daClient, s.logger, s.config.DA.BlockTime.Duration, s.config.Instrumentation.IsTracingEnabled(), s.genesis.DAStartHeight, s.genesis.DAEpochForcedInclusion) s.fiRetriever.Start(ctx) - s.p2pHandler = NewP2PHandler(s.headerStore, s.dataStore, s.cache, s.genesis, s.logger, s.detectDoubleSign) + s.p2pHandler = NewP2PHandler(s.headerStore, s.dataStore, s.cache, s.genesis, s.logger) currentHeight, initErr := s.store.Height(ctx) if initErr != nil { @@ -564,8 +563,20 @@ func (s *Syncer) processHeightEvent(ctx context.Context, event *common.DAHeightE return } - // Skip if already processed - if height <= currentHeight || s.cache.IsHeaderSeen(headerHash) { + if height <= currentHeight { + // Alternate header for an already-applied height: run the equivocation check. + if err := s.checkDoubleSign(ctx, event.Header, event.Data); err != nil { + s.sendCriticalError(err) + } + s.logger.Debug(). + Uint64("height", height). + Str("source", string(event.Source)). + Msg("height already processed") + return + } + + // Skip if already seen + if s.cache.IsHeaderSeen(headerHash) { s.logger.Debug(). Uint64("height", height). Str("source", string(event.Source)). @@ -803,8 +814,6 @@ func (s *Syncer) trySyncNextBlockWithState(ctx context.Context, event *common.DA if !bytes.Equal(header.DataHash, common.DataHashForEmptyTxs) { s.cache.SetDataSeen(data.DACommitment().String(), newState.LastBlockHeight) } - // Subsequent alternates resolve against the persisted header. - s.cache.RemovePendingSignedHeader(header.Height()) if s.p2pHandler != nil { s.p2pHandler.SetProcessedHeight(newState.LastBlockHeight) @@ -1072,30 +1081,69 @@ func (s *Syncer) sendCriticalError(err error) { } } -// handleDoubleSign persists evidence, bumps the metric, and halts the syncer -// via sendCriticalError on the first equivocation sighting. -func (s *Syncer) handleDoubleSign(ctx context.Context, ev *types.DoubleSignEvidence) { - if err := reportDoubleSign(ctx, s.store, s.metrics, s.logger, s.doubleSignSeen, ev); err != nil { - s.sendCriticalError(err) +// checkDoubleSign compares an alternate header against the canonical header +// already applied at the same height. Returns a halt error when +// node.halt_on_double_sign is set, otherwise nil (warn-only). +func (s *Syncer) checkDoubleSign(ctx context.Context, alt *types.SignedHeader, data *types.Data) error { + if alt == nil { + return nil + } + height := alt.Height() + canonical, err := s.store.GetHeader(ctx, height) + if err != nil { + if store.IsNotFound(err) { + return nil + } + s.logger.Error().Err(err).Uint64("height", height).Msg("double-sign check skipped: header lookup failed") + return nil } + if canonical == nil || bytes.Equal(canonical.Hash(), alt.Hash()) { + return nil // missing or benign duplicate + } + // Equivocation requires the same proposer to have signed both headers. + if !bytes.Equal(canonical.ProposerAddress, alt.ProposerAddress) { + return nil // not the sequencer equivocating + } + + // Verify the alternate's signature to avoid halting on forged headers. + alt.SetCustomVerifierForSyncNode(s.options.SyncNodeSignatureBytesProvider) + if err := alt.ValidateBasicWithData(data); err != nil { //nolint:contextcheck // validation API does not accept context + s.logger.Debug().Err(err).Uint64("height", height).Msg("conflicting header failed signature validation; ignoring") + return nil + } + + return s.reportDoubleSign(height, canonical.Hash().String(), alt.Hash().String()) } -// detectDoubleSign records the observation and returns true when header equivocates -// with a prior observation at the same height, halting the syncer as a side effect. -func (s *Syncer) detectDoubleSign(ctx context.Context, header *types.SignedHeader, source string) bool { - if header == nil { - return false +// reportDoubleSign warns and counts a confirmed equivocation (deduped). +// Returns a halt error when node.halt_on_double_sign is set. +func (s *Syncer) reportDoubleSign(height uint64, canonicalHash, altHash string) error { + if !s.doubleSignSeen.markSeen(height, altHash) { + return nil } - prior, priorSource, err := firstObservation(ctx, s.store, s.cache, header.Height()) - if err != nil { - // Detection bypassed for this observation, still cached below so a later arrival can match against this header - s.logger.Error().Err(err).Uint64("height", header.Height()).Msg("double-sign detection bypassed") - } else if ev := buildEvidenceFromPair(prior, header, priorSource, source); ev != nil { - s.handleDoubleSign(ctx, ev) - return true + s.metrics.DoubleSignsDetected.Add(1) + s.logger.Error(). + Uint64("height", height). + Str("canonical_hash", canonicalHash). + Str("alternate_hash", altHash). + Msg("DOUBLE-SIGN DETECTED: sequencer equivocation") + + if !s.config.Node.HaltOnDoubleSign { + return nil // warn-only mode + } + return errors.Join(errMaliciousProposer, + fmt.Errorf("double-sign detected at height %d: sequencer signed conflicting headers %s and %s. "+ + "Node halted for human resolution of the equivocation (the conflicting headers are permanently "+ + "recorded on DA and cannot be cleared). Once resolved, restart with --%s=false to resume", + height, canonicalHash, altHash, config.FlagHaltOnDoubleSign)) +} + +// reportInBatchDoubleSign handles equivocation detected by the DA retriever +// among in-flight headers before either is applied. +func (s *Syncer) reportInBatchDoubleSign(height uint64, canonical, alt *types.SignedHeader) { + if err := s.reportDoubleSign(height, canonical.Hash().String(), alt.Hash().String()); err != nil { + s.sendCriticalError(err) } - s.cache.SetPendingSignedHeader(header, source) - return false } // processPendingEvents fetches and processes pending events from cache diff --git a/node/sequencer_recovery_integration_test.go b/node/sequencer_recovery_integration_test.go index 7803d0302e..0d9e3d0d69 100644 --- a/node/sequencer_recovery_integration_test.go +++ b/node/sequencer_recovery_integration_test.go @@ -111,13 +111,14 @@ func TestSequencerRecoveryFromDA(t *testing.T) { // 4. Starts a recovery sequencer with P2P peer pointing to the fullnode // 5. Verifies the recovery node catches up from both DA and P2P before producing new blocks func TestSequencerRecoveryFromP2P(t *testing.T) { - // Skip: the recovery flow has a race condition where the recovery sequencer may start - // producing blocks before P2P catchup completes. When the DA retriever later receives - // the original blocks, double-sign detection correctly identifies equivocation (same key - // signed different headers at the same height). The fix belongs in the recovery flow - // (ensuring catchup completes before block production), not in double-sign detection. - // TODO(#3330): fix the recovery race condition and re-enable this test. - t.Skip("skipped: recovery flow race triggers legitimate double-sign detection") + // Skipped: the recovery flow has a race where the recovery sequencer starts producing + // blocks before P2P catchup completes, forking into its own chain at heights that already + // hold the original sequencer's blocks (same signing key, different headers = equivocation). + // With the strict assertion below, this reproduces 100% of the time: the recovery node never + // adopts the original chain. The fix belongs in the recovery flow (gate block production on + // catchup completion), not in double-sign detection. + // TODO(#3330): fix the recovery race condition, then remove this skip. + t.Skip("recovery flow forks before P2P catchup completes; see #3330") genesis, genesisValidatorKey, _ := types.GetGenesisWithPrivkey("test-chain") remoteSigner, err := signer.NewNoopSigner(genesisValidatorKey) @@ -219,11 +220,7 @@ func TestSequencerRecoveryFromP2P(t *testing.T) { break } } - if allMatch { - t.Log("recovery node synced original blocks from P2P — all hashes verified") - } else { - t.Log("recovery node produced its own blocks (P2P sync was not completed in time)") - } + require.True(t, allMatch, "recovery node must adopt the original chain from P2P, not fork (#3330)") } // Shutdown diff --git a/pkg/config/config.go b/pkg/config/config.go index 87e12f5597..e192b74839 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -50,6 +50,8 @@ const ( FlagReadinessWindowSeconds = FlagPrefixEvnode + "node.readiness_window_seconds" // FlagReadinessMaxBlocksBehind configures how many blocks behind best-known head is still considered ready FlagReadinessMaxBlocksBehind = FlagPrefixEvnode + "node.readiness_max_blocks_behind" + // FlagHaltOnDoubleSign halts the node when sequencer equivocation (double-signing) is detected + FlagHaltOnDoubleSign = FlagPrefixEvnode + "node.halt_on_double_sign" // FlagScrapeInterval is a flag for specifying the reaper scrape interval FlagScrapeInterval = FlagPrefixEvnode + "node.scrape_interval" // FlagCatchupTimeout is a flag for waiting for P2P catchup before starting block production @@ -305,6 +307,9 @@ type NodeConfig struct { // Readiness / health configuration ReadinessWindowSeconds uint64 `mapstructure:"readiness_window_seconds" yaml:"readiness_window_seconds" comment:"Time window in seconds used to calculate ReadinessMaxBlocksBehind based on block time. Default: 15 seconds."` ReadinessMaxBlocksBehind uint64 `mapstructure:"readiness_max_blocks_behind" yaml:"readiness_max_blocks_behind" comment:"How many blocks behind best-known head the node can be and still be considered ready. 0 means must be exactly at head."` + + // Equivocation handling + HaltOnDoubleSign bool `mapstructure:"halt_on_double_sign" yaml:"halt_on_double_sign" comment:"Halt the node when sequencer equivocation (double-signing) is detected. When false, it is logged and counted but the node continues. Default: true."` } // LogConfig contains all logging configuration parameters @@ -600,6 +605,7 @@ func AddFlags(cmd *cobra.Command) { cmd.Flags().Uint64(FlagReadinessMaxBlocksBehind, def.Node.ReadinessMaxBlocksBehind, "how many blocks behind best-known head the node can be and still be considered ready (0 = must be at head)") cmd.Flags().Duration(FlagScrapeInterval, def.Node.ScrapeInterval.Duration, "interval at which the reaper polls the execution layer for new transactions") cmd.Flags().Duration(FlagCatchupTimeout, def.Node.CatchupTimeout.Duration, "sync from DA and P2P before producing blocks. Value specifies time to wait for P2P catchup. Requires aggregator mode.") + cmd.Flags().Bool(FlagHaltOnDoubleSign, def.Node.HaltOnDoubleSign, "halt the node when sequencer equivocation (double-signing) is detected") // Data Availability configuration flags cmd.Flags().String(FlagDAAddress, def.DA.Address, "DA address (host:port)") diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 1f140e5656..cc3fde4d4e 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -69,6 +69,7 @@ func TestAddFlags(t *testing.T) { assertFlagValue(t, flags, FlagReadinessWindowSeconds, DefaultConfig().Node.ReadinessWindowSeconds) assertFlagValue(t, flags, FlagReadinessMaxBlocksBehind, DefaultConfig().Node.ReadinessMaxBlocksBehind) assertFlagValue(t, flags, FlagScrapeInterval, DefaultConfig().Node.ScrapeInterval) + assertFlagValue(t, flags, FlagHaltOnDoubleSign, DefaultConfig().Node.HaltOnDoubleSign) // DA flags assertFlagValue(t, flags, FlagDAAddress, DefaultConfig().DA.Address) @@ -148,7 +149,7 @@ func TestAddFlags(t *testing.T) { assertFlagValue(t, flags, FlagPruningInterval, DefaultConfig().Pruning.Interval.Duration) // Count the number of flags we're explicitly checking - expectedFlagCount := 82 // Update this number if you add more flag checks above + expectedFlagCount := 83 // Update this number if you add more flag checks above // Get the actual number of flags (both regular and persistent) actualFlagCount := 0 diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 233585e0c5..1fe9b69d10 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -71,6 +71,7 @@ func DefaultConfig() Config { ReadinessMaxBlocksBehind: calculateReadinessMaxBlocksBehind(defaultBlockTime.Duration, defaultReadinessWindowSeconds), ScrapeInterval: DurationWrapper{1 * time.Second}, CatchupTimeout: DurationWrapper{0}, + HaltOnDoubleSign: true, }, DA: DAConfig{ Address: "http://localhost:7980", diff --git a/pkg/store/keys.go b/pkg/store/keys.go index 578d300111..02053bb849 100644 --- a/pkg/store/keys.go +++ b/pkg/store/keys.go @@ -30,10 +30,6 @@ const ( // pruned state height in the store. LastPrunedStateHeightKey = "lst-prnd-s" - // DoubleSignEvidenceKey is the metadata key prefix for persisted double-sign - // evidence. Full keys are like: ds// - DoubleSignEvidenceKey = "ds" - headerPrefix = "h" dataPrefix = "d" signaturePrefix = "c" @@ -106,9 +102,3 @@ func GetHeightToDAHeightHeaderKey(height uint64) string { func GetHeightToDAHeightDataKey(height uint64) string { return HeightToDAHeightKey + "/" + strconv.FormatUint(height, 10) + "/d" } - -// GetDoubleSignEvidenceKey returns the metadata key for persisted double-sign -// evidence at the given height and alternate-header hash. -func GetDoubleSignEvidenceKey(height uint64, altHash types.Hash) string { - return DoubleSignEvidenceKey + "/" + strconv.FormatUint(height, 10) + "/" + altHash.String() -} diff --git a/proto/evnode/v1/evnode.proto b/proto/evnode/v1/evnode.proto index 39716126bd..e60bd56e0d 100644 --- a/proto/evnode/v1/evnode.proto +++ b/proto/evnode/v1/evnode.proto @@ -121,15 +121,3 @@ message P2PData { repeated bytes txs = 2; optional uint64 da_height_hint = 3; } - -// DoubleSignEvidence records two validly-signed SignedHeaders at the same -// height produced by the sequencer. Persisted as proof of equivocation. -message DoubleSignEvidence { - uint64 height = 1; - SignedHeader first_header = 2; - SignedHeader alternate_header = 3; - int64 detected_at = 4; - // Ingestion source for each header: "p2p", "da", or "stored". - string first_source = 5; - string alternate_source = 6; -} diff --git a/types/double_sign_evidence.go b/types/double_sign_evidence.go deleted file mode 100644 index e883d0cc64..0000000000 --- a/types/double_sign_evidence.go +++ /dev/null @@ -1,120 +0,0 @@ -package types - -import ( - "bytes" - "errors" - "fmt" - "time" - - "google.golang.org/protobuf/proto" - - pb "github.com/evstack/ev-node/types/pb/evnode/v1" -) - -// Ingestion source identifying which path observed a SignedHeader. -const ( - EvidenceSourceP2P = "p2p" - EvidenceSourceDA = "da" - EvidenceSourceStored = "stored" -) - -// DoubleSignEvidence records two validly-signed SignedHeaders at the same -// height produced by the sequencer. Persisted as proof of equivocation. -type DoubleSignEvidence struct { - Height uint64 - FirstHeader *SignedHeader - AlternateHeader *SignedHeader - DetectedAt time.Time - FirstSource string - AlternateSource string -} - -// ValidateBasic checks structural consistency of the evidence. -func (e *DoubleSignEvidence) ValidateBasic() error { - if e == nil { - return errors.New("evidence is nil") - } - if e.FirstHeader == nil || e.AlternateHeader == nil { - return errors.New("evidence requires both first and alternate headers") - } - if e.FirstHeader.Height() != e.Height || e.AlternateHeader.Height() != e.Height { - return fmt.Errorf("evidence height %d does not match both headers (%d, %d)", - e.Height, e.FirstHeader.Height(), e.AlternateHeader.Height()) - } - if bytes.Equal(e.FirstHeader.Hash(), e.AlternateHeader.Hash()) { - return errors.New("evidence headers have identical hash — no equivocation") - } - if !bytes.Equal(e.FirstHeader.ProposerAddress, e.AlternateHeader.ProposerAddress) { - return errors.New("evidence headers have different proposers — not an equivocation") - } - return nil -} - -// ToProto converts DoubleSignEvidence to protobuf representation. -func (e *DoubleSignEvidence) ToProto() (*pb.DoubleSignEvidence, error) { - if e == nil { - return nil, errors.New("evidence is nil") - } - if e.FirstHeader == nil || e.AlternateHeader == nil { - return nil, errors.New("evidence requires both first and alternate headers") - } - first, err := e.FirstHeader.ToProto() - if err != nil { - return nil, fmt.Errorf("marshal first header: %w", err) - } - alt, err := e.AlternateHeader.ToProto() - if err != nil { - return nil, fmt.Errorf("marshal alternate header: %w", err) - } - return &pb.DoubleSignEvidence{ - Height: e.Height, - FirstHeader: first, - AlternateHeader: alt, - DetectedAt: e.DetectedAt.UnixNano(), - FirstSource: e.FirstSource, - AlternateSource: e.AlternateSource, - }, nil -} - -// FromProto fills DoubleSignEvidence from protobuf representation. -func (e *DoubleSignEvidence) FromProto(p *pb.DoubleSignEvidence) error { - if p == nil { - return errors.New("proto evidence is nil") - } - if p.FirstHeader == nil || p.AlternateHeader == nil { - return errors.New("proto evidence missing first or alternate header") - } - first := new(SignedHeader) - if err := first.FromProto(p.FirstHeader); err != nil { - return fmt.Errorf("unmarshal first header: %w", err) - } - alt := new(SignedHeader) - if err := alt.FromProto(p.AlternateHeader); err != nil { - return fmt.Errorf("unmarshal alternate header: %w", err) - } - e.Height = p.Height - e.FirstHeader = first - e.AlternateHeader = alt - e.DetectedAt = time.Unix(0, p.DetectedAt).UTC() - e.FirstSource = p.FirstSource - e.AlternateSource = p.AlternateSource - return nil -} - -// MarshalBinary encodes DoubleSignEvidence to protobuf bytes. -func (e *DoubleSignEvidence) MarshalBinary() ([]byte, error) { - p, err := e.ToProto() - if err != nil { - return nil, err - } - return proto.Marshal(p) -} - -// UnmarshalBinary decodes DoubleSignEvidence from protobuf bytes. -func (e *DoubleSignEvidence) UnmarshalBinary(data []byte) error { - p := new(pb.DoubleSignEvidence) - if err := proto.Unmarshal(data, p); err != nil { - return fmt.Errorf("proto unmarshal double sign evidence: %w", err) - } - return e.FromProto(p) -} diff --git a/types/pb/evnode/v1/evnode.pb.go b/types/pb/evnode/v1/evnode.pb.go index 8f34134fed..b0a866e76e 100644 --- a/types/pb/evnode/v1/evnode.pb.go +++ b/types/pb/evnode/v1/evnode.pb.go @@ -785,93 +785,6 @@ func (x *P2PData) GetDaHeightHint() uint64 { return 0 } -// DoubleSignEvidence records two validly-signed SignedHeaders at the same -// height produced by the sequencer. Persisted as proof of equivocation. -type DoubleSignEvidence struct { - state protoimpl.MessageState `protogen:"open.v1"` - Height uint64 `protobuf:"varint,1,opt,name=height,proto3" json:"height,omitempty"` - FirstHeader *SignedHeader `protobuf:"bytes,2,opt,name=first_header,json=firstHeader,proto3" json:"first_header,omitempty"` - AlternateHeader *SignedHeader `protobuf:"bytes,3,opt,name=alternate_header,json=alternateHeader,proto3" json:"alternate_header,omitempty"` - DetectedAt int64 `protobuf:"varint,4,opt,name=detected_at,json=detectedAt,proto3" json:"detected_at,omitempty"` - // Ingestion source for each header: "p2p", "da", or "stored". - FirstSource string `protobuf:"bytes,5,opt,name=first_source,json=firstSource,proto3" json:"first_source,omitempty"` - AlternateSource string `protobuf:"bytes,6,opt,name=alternate_source,json=alternateSource,proto3" json:"alternate_source,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DoubleSignEvidence) Reset() { - *x = DoubleSignEvidence{} - mi := &file_evnode_v1_evnode_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DoubleSignEvidence) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DoubleSignEvidence) ProtoMessage() {} - -func (x *DoubleSignEvidence) ProtoReflect() protoreflect.Message { - mi := &file_evnode_v1_evnode_proto_msgTypes[11] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DoubleSignEvidence.ProtoReflect.Descriptor instead. -func (*DoubleSignEvidence) Descriptor() ([]byte, []int) { - return file_evnode_v1_evnode_proto_rawDescGZIP(), []int{11} -} - -func (x *DoubleSignEvidence) GetHeight() uint64 { - if x != nil { - return x.Height - } - return 0 -} - -func (x *DoubleSignEvidence) GetFirstHeader() *SignedHeader { - if x != nil { - return x.FirstHeader - } - return nil -} - -func (x *DoubleSignEvidence) GetAlternateHeader() *SignedHeader { - if x != nil { - return x.AlternateHeader - } - return nil -} - -func (x *DoubleSignEvidence) GetDetectedAt() int64 { - if x != nil { - return x.DetectedAt - } - return 0 -} - -func (x *DoubleSignEvidence) GetFirstSource() string { - if x != nil { - return x.FirstSource - } - return "" -} - -func (x *DoubleSignEvidence) GetAlternateSource() string { - if x != nil { - return x.AlternateSource - } - return "" -} - var File_evnode_v1_evnode_proto protoreflect.FileDescriptor const file_evnode_v1_evnode_proto_rawDesc = "" + @@ -933,15 +846,7 @@ const file_evnode_v1_evnode_proto_rawDesc = "" + "\bmetadata\x18\x01 \x01(\v2\x13.evnode.v1.MetadataR\bmetadata\x12\x10\n" + "\x03txs\x18\x02 \x03(\fR\x03txs\x12)\n" + "\x0eda_height_hint\x18\x03 \x01(\x04H\x00R\fdaHeightHint\x88\x01\x01B\x11\n" + - "\x0f_da_height_hint\"\x9b\x02\n" + - "\x12DoubleSignEvidence\x12\x16\n" + - "\x06height\x18\x01 \x01(\x04R\x06height\x12:\n" + - "\ffirst_header\x18\x02 \x01(\v2\x17.evnode.v1.SignedHeaderR\vfirstHeader\x12B\n" + - "\x10alternate_header\x18\x03 \x01(\v2\x17.evnode.v1.SignedHeaderR\x0falternateHeader\x12\x1f\n" + - "\vdetected_at\x18\x04 \x01(\x03R\n" + - "detectedAt\x12!\n" + - "\ffirst_source\x18\x05 \x01(\tR\vfirstSource\x12)\n" + - "\x10alternate_source\x18\x06 \x01(\tR\x0falternateSourceB/Z-github.com/evstack/ev-node/types/pb/evnode/v1b\x06proto3" + "\x0f_da_height_hintB/Z-github.com/evstack/ev-node/types/pb/evnode/v1b\x06proto3" var ( file_evnode_v1_evnode_proto_rawDescOnce sync.Once @@ -955,7 +860,7 @@ func file_evnode_v1_evnode_proto_rawDescGZIP() []byte { return file_evnode_v1_evnode_proto_rawDescData } -var file_evnode_v1_evnode_proto_msgTypes = make([]protoimpl.MessageInfo, 12) +var file_evnode_v1_evnode_proto_msgTypes = make([]protoimpl.MessageInfo, 11) var file_evnode_v1_evnode_proto_goTypes = []any{ (*Version)(nil), // 0: evnode.v1.Version (*Header)(nil), // 1: evnode.v1.Header @@ -968,8 +873,7 @@ var file_evnode_v1_evnode_proto_goTypes = []any{ (*Vote)(nil), // 8: evnode.v1.Vote (*P2PSignedHeader)(nil), // 9: evnode.v1.P2PSignedHeader (*P2PData)(nil), // 10: evnode.v1.P2PData - (*DoubleSignEvidence)(nil), // 11: evnode.v1.DoubleSignEvidence - (*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp + (*timestamppb.Timestamp)(nil), // 11: google.protobuf.Timestamp } var file_evnode_v1_evnode_proto_depIdxs = []int32{ 0, // 0: evnode.v1.Header.version:type_name -> evnode.v1.Version @@ -980,17 +884,15 @@ var file_evnode_v1_evnode_proto_depIdxs = []int32{ 5, // 5: evnode.v1.Data.metadata:type_name -> evnode.v1.Metadata 6, // 6: evnode.v1.SignedData.data:type_name -> evnode.v1.Data 4, // 7: evnode.v1.SignedData.signer:type_name -> evnode.v1.Signer - 12, // 8: evnode.v1.Vote.timestamp:type_name -> google.protobuf.Timestamp + 11, // 8: evnode.v1.Vote.timestamp:type_name -> google.protobuf.Timestamp 1, // 9: evnode.v1.P2PSignedHeader.header:type_name -> evnode.v1.Header 4, // 10: evnode.v1.P2PSignedHeader.signer:type_name -> evnode.v1.Signer 5, // 11: evnode.v1.P2PData.metadata:type_name -> evnode.v1.Metadata - 2, // 12: evnode.v1.DoubleSignEvidence.first_header:type_name -> evnode.v1.SignedHeader - 2, // 13: evnode.v1.DoubleSignEvidence.alternate_header:type_name -> evnode.v1.SignedHeader - 14, // [14:14] is the sub-list for method output_type - 14, // [14:14] is the sub-list for method input_type - 14, // [14:14] is the sub-list for extension type_name - 14, // [14:14] is the sub-list for extension extendee - 0, // [0:14] is the sub-list for field type_name + 12, // [12:12] is the sub-list for method output_type + 12, // [12:12] is the sub-list for method input_type + 12, // [12:12] is the sub-list for extension type_name + 12, // [12:12] is the sub-list for extension extendee + 0, // [0:12] is the sub-list for field type_name } func init() { file_evnode_v1_evnode_proto_init() } @@ -1006,7 +908,7 @@ func file_evnode_v1_evnode_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_evnode_v1_evnode_proto_rawDesc), len(file_evnode_v1_evnode_proto_rawDesc)), NumEnums: 0, - NumMessages: 12, + NumMessages: 11, NumExtensions: 0, NumServices: 0, },