Skip to content

Commit 821a0ef

Browse files
committed
feat(syncing): detect sequencer double-signs and halt with persisted evidence
1 parent f36fbd2 commit 821a0ef

20 files changed

Lines changed: 1993 additions & 53 deletions

block/internal/cache/generic_cache.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@ func (c *Cache) setSeenBatch(hashes []string, height uint64) {
113113
}
114114
}
115115

116+
func (c *Cache) getHashByHeight(height uint64) (string, bool) {
117+
c.mu.RLock()
118+
defer c.mu.RUnlock()
119+
h, ok := c.hashByHeight[height]
120+
return h, ok
121+
}
122+
116123
func (c *Cache) getDAIncluded(hash string) (uint64, bool) {
117124
c.mu.RLock()
118125
defer c.mu.RUnlock()

block/internal/cache/generic_cache_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,3 +391,26 @@ func TestCache_DeleteAllForHeight_CleansHashAndDA(t *testing.T) {
391391
_, ok = c.getDAIncludedByHeight(2)
392392
assert.True(t, ok)
393393
}
394+
395+
func TestCache_getHashByHeight(t *testing.T) {
396+
c := NewCache(nil, "")
397+
398+
h, ok := c.getHashByHeight(42)
399+
assert.False(t, ok)
400+
assert.Empty(t, h)
401+
402+
c.setSeen("abc", 42)
403+
h, ok = c.getHashByHeight(42)
404+
assert.True(t, ok)
405+
assert.Equal(t, "abc", h)
406+
407+
// setDAIncluded also maintains hashByHeight.
408+
c.setDAIncluded("def", 7, 100)
409+
h, ok = c.getHashByHeight(100)
410+
assert.True(t, ok)
411+
assert.Equal(t, "def", h)
412+
413+
c.deleteAllForHeight(42)
414+
_, ok = c.getHashByHeight(42)
415+
assert.False(t, ok)
416+
}

block/internal/cache/manager.go

Lines changed: 80 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,17 @@ type CacheManager interface {
3838
// Header operations
3939
IsHeaderSeen(hash string) bool
4040
SetHeaderSeen(hash string, blockHeight uint64)
41+
GetHeaderHashByHeight(blockHeight uint64) (string, bool)
4142
GetHeaderDAIncludedByHash(hash string) (uint64, bool)
4243
GetHeaderDAIncludedByHeight(blockHeight uint64) (uint64, bool)
4344
SetHeaderDAIncluded(hash string, daHeight uint64, blockHeight uint64)
4445
RemoveHeaderDAIncluded(hash string)
4546

47+
// Pending signed header operations (in-flight, pre-persistence)
48+
SetPendingSignedHeader(h *types.SignedHeader, source string)
49+
GetPendingSignedHeader(blockHeight uint64) (*types.SignedHeader, string, bool)
50+
RemovePendingSignedHeader(blockHeight uint64)
51+
4652
// Data operations
4753
IsDataSeen(hash string) bool
4854
SetDataSeen(hash string, blockHeight uint64)
@@ -92,17 +98,24 @@ type Manager interface {
9298
var _ Manager = (*implementation)(nil)
9399

94100
type implementation struct {
95-
headerCache *Cache
96-
dataCache *Cache
97-
txCache *Cache
98-
txTimestamps *sync.Map // map[string]time.Time
99-
pendingEvents map[uint64]*common.DAHeightEvent
100-
pendingMu sync.Mutex
101-
pendingHeaders *PendingHeaders
102-
pendingData *PendingData
103-
store store.Store
104-
config config.Config
105-
logger zerolog.Logger
101+
headerCache *Cache
102+
dataCache *Cache
103+
txCache *Cache
104+
txTimestamps *sync.Map // map[string]time.Time
105+
pendingEvents map[uint64]*common.DAHeightEvent
106+
pendingMu sync.Mutex
107+
pendingHeaders *PendingHeaders
108+
pendingData *PendingData
109+
pendingSignedHeaders map[uint64]pendingSignedHeader
110+
pendingSignedHeadersMu sync.RWMutex
111+
store store.Store
112+
config config.Config
113+
logger zerolog.Logger
114+
}
115+
116+
type pendingSignedHeader struct {
117+
header *types.SignedHeader
118+
source string
106119
}
107120

108121
// 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
122135
}
123136

124137
impl := &implementation{
125-
headerCache: headerCache,
126-
dataCache: dataCache,
127-
txCache: txCache,
128-
txTimestamps: new(sync.Map),
129-
pendingEvents: make(map[uint64]*common.DAHeightEvent),
130-
pendingHeaders: pendingHeaders,
131-
pendingData: pendingData,
132-
store: st,
133-
config: cfg,
134-
logger: logger,
138+
headerCache: headerCache,
139+
dataCache: dataCache,
140+
txCache: txCache,
141+
txTimestamps: new(sync.Map),
142+
pendingEvents: make(map[uint64]*common.DAHeightEvent),
143+
pendingHeaders: pendingHeaders,
144+
pendingData: pendingData,
145+
pendingSignedHeaders: make(map[uint64]pendingSignedHeader),
146+
store: st,
147+
config: cfg,
148+
logger: logger,
135149
}
136150

137151
if cfg.ClearCache {
@@ -157,6 +171,11 @@ func (m *implementation) SetHeaderSeen(hash string, blockHeight uint64) {
157171
m.headerCache.setSeen(hash, blockHeight)
158172
}
159173

174+
// GetHeaderHashByHeight returns the first-seen header hash at the given height.
175+
func (m *implementation) GetHeaderHashByHeight(blockHeight uint64) (string, bool) {
176+
return m.headerCache.getHashByHeight(blockHeight)
177+
}
178+
160179
func (m *implementation) GetHeaderDAIncludedByHash(hash string) (uint64, bool) {
161180
return m.headerCache.getDAIncluded(hash)
162181
}
@@ -173,6 +192,42 @@ func (m *implementation) RemoveHeaderDAIncluded(hash string) {
173192
m.headerCache.removeDAIncluded(hash)
174193
}
175194

195+
// SetPendingSignedHeader records the first SignedHeader seen at this height.
196+
// First-write-wins: later writes at the same height are ignored so the
197+
// double-sign detector can match alternates against the original observation.
198+
func (m *implementation) SetPendingSignedHeader(h *types.SignedHeader, source string) {
199+
if h == nil {
200+
return
201+
}
202+
height := h.Height()
203+
m.pendingSignedHeadersMu.Lock()
204+
defer m.pendingSignedHeadersMu.Unlock()
205+
if _, exists := m.pendingSignedHeaders[height]; exists {
206+
return
207+
}
208+
m.pendingSignedHeaders[height] = pendingSignedHeader{header: h, source: source}
209+
}
210+
211+
// GetPendingSignedHeader returns the first-seen SignedHeader and the source
212+
// ("da" or "p2p") it was observed from.
213+
func (m *implementation) GetPendingSignedHeader(blockHeight uint64) (*types.SignedHeader, string, bool) {
214+
m.pendingSignedHeadersMu.RLock()
215+
defer m.pendingSignedHeadersMu.RUnlock()
216+
entry, ok := m.pendingSignedHeaders[blockHeight]
217+
if !ok {
218+
return nil, "", false
219+
}
220+
return entry.header, entry.source, true
221+
}
222+
223+
// RemovePendingSignedHeader evicts the entry once the height is persisted, so
224+
// the store becomes the authoritative source for double-sign comparison.
225+
func (m *implementation) RemovePendingSignedHeader(blockHeight uint64) {
226+
m.pendingSignedHeadersMu.Lock()
227+
delete(m.pendingSignedHeaders, blockHeight)
228+
m.pendingSignedHeadersMu.Unlock()
229+
}
230+
176231
// DaHeight returns the highest DA height seen across header and data caches.
177232
func (m *implementation) DaHeight() uint64 {
178233
return max(m.headerCache.daHeight(), m.dataCache.daHeight())
@@ -263,6 +318,7 @@ func (m *implementation) DeleteHeight(blockHeight uint64) {
263318
m.pendingMu.Lock()
264319
delete(m.pendingEvents, blockHeight)
265320
m.pendingMu.Unlock()
321+
m.RemovePendingSignedHeader(blockHeight)
266322

267323
// Note: txCache is intentionally NOT deleted here because:
268324
// 1. Transactions are tracked by hash, not by block height (they use height 0)
@@ -408,6 +464,9 @@ func (m *implementation) ClearFromStore() error {
408464
m.dataCache = NewCache(m.store, DataDAIncludedPrefix)
409465
m.txCache = NewCache(nil, "")
410466
m.pendingEvents = make(map[uint64]*common.DAHeightEvent)
467+
m.pendingSignedHeadersMu.Lock()
468+
m.pendingSignedHeaders = make(map[uint64]pendingSignedHeader)
469+
m.pendingSignedHeadersMu.Unlock()
411470

412471
// Initialize DA height from store metadata to ensure DaHeight() is never 0.
413472
m.initDAHeightFromStore(ctx)

0 commit comments

Comments
 (0)