Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
821a0ef
feat(syncing): detect sequencer double-signs and halt with persisted …
CaelRowley May 4, 2026
f4bd83d
chore(types): tighten DoubleSignEvidence validation and proto nil checks
CaelRowley May 5, 2026
efcd4d9
refactor(types): add EvidenceSourceStored constant to replace magic s…
CaelRowley May 5, 2026
e810cf7
chore(types): harden DoubleSignEvidence (de)serialization
CaelRowley May 6, 2026
428a074
Merge branch 'main' into cael/feat/double-sign-detection
CaelRowley May 6, 2026
6f3125a
Merge branch 'main' into cael/feat/double-sign-detection
CaelRowley May 6, 2026
a9dcc90
Merge branch 'main' into cael/feat/double-sign-detection
CaelRowley May 7, 2026
300adc2
refactor(syncing): centralize double-sign detection, drop reportDoubl…
CaelRowley May 7, 2026
da54c68
test(syncing): add integration coverage for double-sign halt pipeline
CaelRowley May 8, 2026
778e487
refactor: address double-sign review feedback
CaelRowley May 8, 2026
dacba21
fix: go lint issues
CaelRowley May 8, 2026
e1f1e78
Merge branch 'main' into cael/feat/double-sign-detection
CaelRowley May 11, 2026
e804556
Merge branch 'main' into cael/feat/double-sign-detection
CaelRowley May 11, 2026
66e2f0b
Merge branch 'main' into cael/feat/double-sign-detection
CaelRowley May 12, 2026
5a321b4
Merge branch 'main' into cael/feat/double-sign-detection
CaelRowley May 12, 2026
32428f3
Merge branch 'main' into cael/feat/double-sign-detection
CaelRowley May 18, 2026
543efe6
Merge branch 'main' into cael/feat/double-sign-detection
CaelRowley May 20, 2026
f4b1520
test(P2P): skip TestSequencerRecoveryFromP2P due to recovery race (#3…
CaelRowley May 20, 2026
e2e520b
Merge branch 'main' into cael/feat/double-sign-detection
CaelRowley May 20, 2026
93ee385
Merge branch 'main' into cael/feat/double-sign-detection
CaelRowley May 25, 2026
ab15875
Merge branch 'main' into cael/feat/double-sign-detection
CaelRowley May 28, 2026
47c84a0
refactor(syncing): simplify double-sign detection by removing evidenc…
CaelRowley Jun 5, 2026
4999a4e
Merge branch 'main' into cael/feat/double-sign-detection
CaelRowley Jun 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions block/internal/cache/generic_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
23 changes: 23 additions & 0 deletions block/internal/cache/generic_cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
6 changes: 6 additions & 0 deletions block/internal/cache/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ 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)
Expand Down Expand Up @@ -157,6 +158,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)
}
Expand Down
13 changes: 13 additions & 0 deletions block/internal/common/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
}
Expand Down
43 changes: 32 additions & 11 deletions block/internal/syncing/da_retriever.go
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should handle this in the syncer.go directly, no need to duplicate it between the da retriever and p2p handler

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review, I refactored out the logic into Syncer.detectDoubleSign but the retriever/handler calls must stay because of an in batch DA case if two conflicting headers from a malicious sequencer land at the same DA height, the retriever fetches both in one batch and drops the second one before it emits DAHeightEvent. So the syncer would never see the alternate.

Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ type daRetriever struct {
genesis genesis.Genesis
logger zerolog.Logger

// 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
// on restart, will be refetch as da height is updated by syncer
Expand All @@ -46,21 +50,23 @@ type daRetriever struct {
strictMode bool
}

// NewDARetriever creates a new DA retriever
// NewDARetriever creates a new DA retriever.
func NewDARetriever(
client da.Client,
cache cache.CacheManager,
genesis genesis.Genesis,
logger zerolog.Logger,
reportInBatchDoubleSign inBatchDoubleSignReporter,
) *daRetriever {
return &daRetriever{
client: client,
cache: cache,
genesis: genesis,
logger: logger.With().Str("component", "da_retriever").Logger(),
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,
}
}

Expand Down Expand Up @@ -172,9 +178,19 @@ func (r *daRetriever) processBlobs(ctx context.Context, blobs [][]byte, daHeight
}

if header := r.tryDecodeHeader(bz, daHeight); header != nil {
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
// 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
}
Expand Down Expand Up @@ -324,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
Expand Down
2 changes: 1 addition & 1 deletion block/internal/syncing/da_retriever_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

// makeSignedDataBytes builds SignedData containing the provided Data and returns its binary encoding
Expand Down
34 changes: 34 additions & 0 deletions block/internal/syncing/doublesign.go
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is great! checks should always be performed, so doubleSignDetector should not be ever nil. what the operator should tweak is skipping halting via a flag.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright detection always runs now, but I added a node.halt_on_double_sign flag when false it warns only instead of halting. Let me know if I should remove this too

Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package syncing

import (
"fmt"
"sync"

"github.com/evstack/ev-node/types"
)

// inBatchDoubleSignReporter reports two distinct, sequencer-signed headers observed at the same height
type inBatchDoubleSignReporter func(height uint64, canonical, alt *types.SignedHeader)

// 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{}
}

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
}
Loading
Loading