Skip to content

Commit 300adc2

Browse files
committed
refactor(syncing): centralize double-sign detection, drop reportDoubleSign callback
1 parent a9dcc90 commit 300adc2

11 files changed

Lines changed: 183 additions & 197 deletions

block/internal/syncing/da_retriever.go

Lines changed: 14 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import (
1515
"github.com/evstack/ev-node/block/internal/da"
1616
datypes "github.com/evstack/ev-node/pkg/da/types"
1717
"github.com/evstack/ev-node/pkg/genesis"
18-
"github.com/evstack/ev-node/pkg/store"
1918
"github.com/evstack/ev-node/types"
2019
pb "github.com/evstack/ev-node/types/pb/evnode/v1"
2120
)
@@ -35,8 +34,8 @@ type daRetriever struct {
3534
cache cache.CacheManager
3635
genesis genesis.Genesis
3736
logger zerolog.Logger
38-
store store.Store
39-
onDoubleSign doubleSignHandler // nil disables detection; the retriever aborts the batch on a positive
37+
// detectDoubleSign aborts the batch when it returns true. Nil disables.
38+
detectDoubleSign doubleSignDetector
4039

4140
mu sync.Mutex
4241
// transient cache, only full event need to be passed to the syncer
@@ -49,26 +48,23 @@ type daRetriever struct {
4948
strictMode bool
5049
}
5150

52-
// NewDARetriever creates a new DA retriever. Double-sign detection is disabled
53-
// when st or onDoubleSign is nil.
51+
// NewDARetriever creates a new DA retriever.
5452
func NewDARetriever(
5553
client da.Client,
5654
cache cache.CacheManager,
5755
genesis genesis.Genesis,
5856
logger zerolog.Logger,
59-
st store.Store,
60-
onDoubleSign doubleSignHandler,
57+
detectDoubleSign doubleSignDetector,
6158
) *daRetriever {
6259
return &daRetriever{
63-
client: client,
64-
cache: cache,
65-
genesis: genesis,
66-
logger: logger.With().Str("component", "da_retriever").Logger(),
67-
store: st,
68-
onDoubleSign: onDoubleSign,
69-
pendingHeaders: make(map[uint64]*types.SignedHeader),
70-
pendingData: make(map[uint64]*types.Data),
71-
strictMode: false,
60+
client: client,
61+
cache: cache,
62+
genesis: genesis,
63+
logger: logger.With().Str("component", "da_retriever").Logger(),
64+
detectDoubleSign: detectDoubleSign,
65+
pendingHeaders: make(map[uint64]*types.SignedHeader),
66+
pendingData: make(map[uint64]*types.Data),
67+
strictMode: false,
7268
}
7369
}
7470

@@ -181,14 +177,8 @@ func (r *daRetriever) processBlobs(ctx context.Context, blobs [][]byte, daHeight
181177

182178
if header := r.tryDecodeHeader(bz, daHeight); header != nil {
183179
// Catches both in-batch alternates and alternates of already-persisted heights.
184-
if r.store != nil && r.onDoubleSign != nil {
185-
if ev, err := detectDoubleSign(ctx, r.store, r.cache, header, types.EvidenceSourceDA); err == nil && ev != nil {
186-
r.onDoubleSign(ctx, ev)
187-
return nil
188-
} else if err != nil {
189-
r.logger.Warn().Err(err).Uint64("height", header.Height()).Msg("double-sign detection error")
190-
}
191-
r.cache.SetPendingSignedHeader(header, types.EvidenceSourceDA)
180+
if r.detectDoubleSign != nil && r.detectDoubleSign(ctx, header, types.EvidenceSourceDA) {
181+
return nil
192182
}
193183

194184
if _, ok := r.pendingHeaders[header.Height()]; ok {

block/internal/syncing/da_retriever_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func newTestDARetriever(t *testing.T, mockClient *mocks.MockClient, cfg config.C
5252
mockClient.On("GetForcedInclusionNamespace").Return([]byte(nil)).Maybe()
5353
mockClient.On("HasForcedInclusionNamespace").Return(false).Maybe()
5454

55-
return NewDARetriever(mockClient, cm, gen, zerolog.Nop(), nil, nil)
55+
return NewDARetriever(mockClient, cm, gen, zerolog.Nop(), nil)
5656
}
5757

5858
// makeSignedDataBytes builds SignedData containing the provided Data and returns its binary encoding

block/internal/syncing/doublesign.go

Lines changed: 21 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -19,42 +19,33 @@ import (
1919
// ErrDoubleSign is returned when two validly-signed SignedHeaders are observed at the same height.
2020
var ErrDoubleSign = errors.New("double-sign detected")
2121

22-
// doubleSignHandler is fired when an equivocation is confirmed. It persists
23-
// evidence, bumps metrics, and halts the syncer.
24-
type doubleSignHandler func(ctx context.Context, evidence *types.DoubleSignEvidence)
25-
26-
// detectDoubleSign compares incoming against the first-seen SignedHeader at
27-
// the same height (cache then store) and returns non-nil evidence when their
28-
// hashes differ. Caller must verify proposer + signature first.
29-
func detectDoubleSign(
22+
// doubleSignDetector reports an observed header for equivocation detection.
23+
// Returns true on a confirmed double-sign so the caller can abort.
24+
type doubleSignDetector func(ctx context.Context, header *types.SignedHeader, source string) bool
25+
26+
// firstObservation returns the first-seen SignedHeader at this height,
27+
// preferring the cache over the store. Returns (nil, "", nil) when none.
28+
func firstObservation(
3029
ctx context.Context,
3130
st store.Store,
3231
cm cache.CacheManager,
33-
incoming *types.SignedHeader,
34-
incomingSource string,
35-
) (*types.DoubleSignEvidence, error) {
36-
if incoming == nil {
37-
return nil, errors.New("incoming header is nil")
38-
}
39-
height := incoming.Height()
40-
41-
// Cache wins over store: the cached entry is the literal first observation
42-
// and carries the original FirstSource.
32+
height uint64,
33+
) (*types.SignedHeader, string, error) {
4334
if cached, source, ok := cm.GetPendingSignedHeader(height); ok {
44-
return buildEvidenceFromPair(cached, incoming, source, incomingSource), nil
35+
return cached, source, nil
4536
}
4637

47-
storedHeader, storeErr := st.GetHeader(ctx, height)
48-
if storeErr != nil {
49-
if store.IsNotFound(storeErr) {
50-
return nil, nil
38+
storedHeader, err := st.GetHeader(ctx, height)
39+
if err != nil {
40+
if store.IsNotFound(err) {
41+
return nil, "", nil
5142
}
52-
return nil, fmt.Errorf("lookup stored header at %d: %w", height, storeErr)
43+
return nil, "", fmt.Errorf("lookup stored header at %d: %w", height, err)
5344
}
5445
if storedHeader == nil {
55-
return nil, nil
46+
return nil, "", nil
5647
}
57-
return buildEvidenceFromPair(storedHeader, incoming, types.EvidenceSourceStored, incomingSource), nil
48+
return storedHeader, types.EvidenceSourceStored, nil
5849
}
5950

6051
// buildEvidenceFromPair returns evidence for two SignedHeaders at the same
@@ -99,16 +90,15 @@ func persistEvidence(ctx context.Context, st store.Store, ev *types.DoubleSignEv
9990
return nil
10091
}
10192

102-
// reportDoubleSign persists evidence, logs, bumps the metric (once per
103-
// distinct alternate hash via seen), fires criticalErr, and returns the
104-
// wrapped ErrDoubleSign for the caller to propagate as the halt cause.
93+
// reportDoubleSign persists evidence and bumps the metric, deduping by
94+
// (height, altHash). Returns a wrapped ErrDoubleSign on first sighting,
95+
// nil when already seen.
10596
func reportDoubleSign(
10697
ctx context.Context,
10798
st store.Store,
10899
metrics *common.Metrics,
109100
logger zerolog.Logger,
110101
seen *doubleSignDedup,
111-
criticalErr func(error),
112102
ev *types.DoubleSignEvidence,
113103
) error {
114104
altHashStr := ev.AlternateHeader.Hash().String()
@@ -144,15 +134,11 @@ func reportDoubleSign(
144134
Str("evidence_key", key).
145135
Msg("DOUBLE-SIGN DETECTED — sequencer equivocation; halting syncer")
146136

147-
halt := fmt.Errorf(
137+
return fmt.Errorf(
148138
"double-sign detected at height %d: sequencer signed conflicting headers %s and %s. "+
149139
"Evidence persisted at metadata key %s. Manual intervention required: %w",
150140
ev.Height, firstHashStr, altHashStr, key, ErrDoubleSign,
151141
)
152-
if criticalErr != nil {
153-
criticalErr(halt)
154-
}
155-
return halt
156142
}
157143

158144
// doubleSignDedup collapses (height, altHash) duplicates so the same

block/internal/syncing/doublesign_branches_test.go

Lines changed: 25 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -56,48 +56,40 @@ func (nilHeaderStore) GetHeader(context.Context, uint64) (*types.SignedHeader, e
5656
return nil, nil
5757
}
5858

59-
func TestDetectDoubleSign_NilIncomingReturnsError(t *testing.T) {
60-
env := newDSTestEnv(t)
61-
ev, err := detectDoubleSign(context.Background(), env.store, env.cache, nil, types.EvidenceSourceP2P)
62-
require.Error(t, err)
63-
require.Nil(t, ev)
64-
}
65-
6659
// A non-NotFound store failure must be surfaced, not swallowed.
67-
func TestDetectDoubleSign_StoreErrorWrapped(t *testing.T) {
60+
func TestFirstObservation_StoreErrorWrapped(t *testing.T) {
6861
env := newDSTestEnv(t)
6962
wrapped := &errStore{Store: env.store, getHeaderErr: errors.New("backend down")}
7063

71-
alt := env.signHeaderAtHeight(5, 0x01)
72-
ev, err := detectDoubleSign(context.Background(), wrapped, env.cache, alt, types.EvidenceSourceP2P)
64+
prior, priorSource, err := firstObservation(context.Background(), wrapped, env.cache, 5)
7365
require.Error(t, err)
74-
require.Nil(t, ev)
66+
require.Nil(t, prior)
67+
require.Empty(t, priorSource)
7568
require.Contains(t, err.Error(), "lookup stored header")
7669
require.ErrorContains(t, err, "backend down")
7770
}
7871

79-
func TestDetectDoubleSign_StoredHeaderNilDefensive(t *testing.T) {
72+
func TestFirstObservation_StoredHeaderNilDefensive(t *testing.T) {
8073
env := newDSTestEnv(t)
8174
wrapped := nilHeaderStore{Store: env.store}
8275

83-
alt := env.signHeaderAtHeight(5, 0x01)
84-
ev, err := detectDoubleSign(context.Background(), wrapped, env.cache, alt, types.EvidenceSourceP2P)
76+
prior, priorSource, err := firstObservation(context.Background(), wrapped, env.cache, 5)
8577
require.NoError(t, err)
86-
require.Nil(t, ev)
78+
require.Nil(t, prior)
79+
require.Empty(t, priorSource)
8780
}
8881

89-
// Store-path detections must use the "stored" sentinel as FirstSource so
90-
// downstream consumers can disambiguate it from in-flight observations.
91-
func TestDetectDoubleSign_FirstSourceStoredSentinel(t *testing.T) {
82+
// Store-path observations must use the "stored" sentinel as the source so
83+
// downstream consumers can disambiguate them from in-flight observations.
84+
func TestFirstObservation_StoredSourceSentinel(t *testing.T) {
9285
env := newDSTestEnv(t)
9386
first := env.signHeaderAtHeight(5, 0x01)
9487
env.saveHeader(first)
9588

96-
alt := env.signHeaderAtHeight(5, 0x02)
97-
ev, err := detectDoubleSign(context.Background(), env.store, env.cache, alt, types.EvidenceSourceP2P)
89+
prior, priorSource, err := firstObservation(context.Background(), env.store, env.cache, first.Height())
9890
require.NoError(t, err)
99-
require.NotNil(t, ev)
100-
require.Equal(t, types.EvidenceSourceStored, ev.FirstSource)
91+
require.NotNil(t, prior)
92+
require.Equal(t, types.EvidenceSourceStored, priorSource)
10193
}
10294

10395
// SetMetadata failures must include the canonical key so an operator can
@@ -118,7 +110,7 @@ func TestPersistEvidence_StoreError(t *testing.T) {
118110
}
119111

120112
// Persistence failure must not break the halt contract: metric still
121-
// increments, criticalErr still fires, returned error still wraps ErrDoubleSign.
113+
// increments and the returned error still wraps ErrDoubleSign.
122114
func TestReportDoubleSign_PersistFailureLoggedNotBlocking(t *testing.T) {
123115
env := newDSTestEnv(t)
124116
wrapped := &errStore{Store: env.store, setMetadataErr: errors.New("disk full")}
@@ -132,16 +124,12 @@ func TestReportDoubleSign_PersistFailureLoggedNotBlocking(t *testing.T) {
132124
metrics := common.NopMetrics()
133125
metrics.DoubleSignsDetected = &counterCtr{n: &dsCount}
134126

135-
var fired atomic.Pointer[error]
136-
crit := func(err error) { fired.Store(&err) }
137-
138127
halt := reportDoubleSign(context.Background(), wrapped, metrics, zerolog.Nop(),
139-
newDoubleSignDedup(), crit, ev)
128+
newDoubleSignDedup(), ev)
140129
require.Error(t, halt)
141130
require.ErrorIs(t, halt, ErrDoubleSign)
142131

143132
require.Equal(t, int64(1), dsCount.Load())
144-
require.NotNil(t, fired.Load())
145133
}
146134

147135
// Dedup is keyed on (height, altHash), so two distinct alts at the same
@@ -163,12 +151,11 @@ func TestReportDoubleSign_TwoDistinctAltsAtSameHeight(t *testing.T) {
163151
metrics.DoubleSignsDetected = &counterCtr{n: &dsCount}
164152

165153
seen := newDoubleSignDedup()
166-
noopCrit := func(error) {}
167154

168155
require.Error(t, reportDoubleSign(context.Background(), env.store, metrics,
169-
zerolog.Nop(), seen, noopCrit, ev1))
156+
zerolog.Nop(), seen, ev1))
170157
require.Error(t, reportDoubleSign(context.Background(), env.store, metrics,
171-
zerolog.Nop(), seen, noopCrit, ev2))
158+
zerolog.Nop(), seen, ev2))
172159

173160
require.Equal(t, int64(2), dsCount.Load())
174161

@@ -189,14 +176,14 @@ func TestReportDoubleSign_NilSeenAndNilGuards(t *testing.T) {
189176

190177
t.Run("nil seen still halts", func(t *testing.T) {
191178
halt := reportDoubleSign(context.Background(), env.store, common.NopMetrics(),
192-
zerolog.Nop(), nil, func(error) {}, ev)
179+
zerolog.Nop(), nil, ev)
193180
require.Error(t, halt)
194181
require.ErrorIs(t, halt, ErrDoubleSign)
195182
})
196183

197184
t.Run("nil metrics still halts", func(t *testing.T) {
198185
halt := reportDoubleSign(context.Background(), env.store, nil,
199-
zerolog.Nop(), newDoubleSignDedup(), func(error) {}, ev)
186+
zerolog.Nop(), newDoubleSignDedup(), ev)
200187
require.Error(t, halt)
201188
require.ErrorIs(t, halt, ErrDoubleSign)
202189
})
@@ -205,14 +192,7 @@ func TestReportDoubleSign_NilSeenAndNilGuards(t *testing.T) {
205192
m := common.NopMetrics()
206193
m.DoubleSignsDetected = nil
207194
halt := reportDoubleSign(context.Background(), env.store, m,
208-
zerolog.Nop(), newDoubleSignDedup(), func(error) {}, ev)
209-
require.Error(t, halt)
210-
require.ErrorIs(t, halt, ErrDoubleSign)
211-
})
212-
213-
t.Run("nil criticalErr still halts", func(t *testing.T) {
214-
halt := reportDoubleSign(context.Background(), env.store, common.NopMetrics(),
215-
zerolog.Nop(), newDoubleSignDedup(), nil, ev)
195+
zerolog.Nop(), newDoubleSignDedup(), ev)
216196
require.Error(t, halt)
217197
require.ErrorIs(t, halt, ErrDoubleSign)
218198
})
@@ -270,7 +250,7 @@ func TestDARetriever_AbortsBatchOnDetection(t *testing.T) {
270250

271251
mockClient := newMockDAClient(t)
272252

273-
r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.store, env.onDouble)
253+
r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.detectDoubleSign)
274254

275255
events := r.ProcessBlobs(context.Background(),
276256
[][]byte{firstBin, altBin, nextBin}, 100)
@@ -290,7 +270,7 @@ func TestDARetriever_DetectorErrorWarnAndContinue(t *testing.T) {
290270

291271
mockClient := newMockDAClient(t)
292272

293-
r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), wrapped, env.onDouble)
273+
r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.makeDetectDoubleSign(wrapped))
294274

295275
_ = r.ProcessBlobs(context.Background(), [][]byte{bin}, 100)
296276

@@ -324,7 +304,7 @@ func TestDARetriever_StrictModeEnvelopeDoubleSign(t *testing.T) {
324304

325305
mockClient := newMockDAClient(t)
326306

327-
r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.store, env.onDouble)
307+
r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.detectDoubleSign)
328308

329309
events := r.ProcessBlobs(context.Background(), [][]byte{firstBin, altBin}, 100)
330310
require.Empty(t, events)
@@ -348,12 +328,11 @@ func TestDARetriever_SetsPendingSignedHeaderOnFirstObservation(t *testing.T) {
348328

349329
mockClient := newMockDAClient(t)
350330

351-
r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.store, env.onDouble)
331+
r := NewDARetriever(mockClient, env.cache, env.gen, zerolog.Nop(), env.detectDoubleSign)
352332
_ = r.ProcessBlobs(context.Background(), [][]byte{bin}, 100)
353333

354334
got, src, ok := env.cache.GetPendingSignedHeader(5)
355335
require.True(t, ok)
356336
require.Equal(t, first.Hash().String(), got.Hash().String())
357337
require.Equal(t, types.EvidenceSourceDA, src)
358338
}
359-

0 commit comments

Comments
 (0)