Skip to content

Commit 77bbef8

Browse files
committed
fix(ethmonitor): confirm cached parents and track canonical incarnations
1 parent 247c2e1 commit 77bbef8

5 files changed

Lines changed: 350 additions & 41 deletions

File tree

‎ethmonitor/bootstrap.go‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,9 @@ func (c *Chain) bootstrapBlocks(blocks Blocks) error {
3838
return nil
3939
}
4040

41-
if len(blocks) == 1 {
41+
if len(blocks) == 1 && blocks[0].Event != Added {
4242
c.blocks = blocks.Copy()
43+
c.blocks[0].canonicalState = nil
4344
return nil
4445
}
4546

@@ -51,7 +52,7 @@ func (c *Chain) bootstrapBlocks(blocks Blocks) error {
5152

5253
for _, b := range blocks {
5354
if b.Event == Added {
54-
err := c.push(b)
55+
_, err := c.push(b)
5556
if err != nil {
5657
return fmt.Errorf("ethmonitor: bootstrap failed to build canonical chain: %w", err)
5758
}
@@ -101,5 +102,6 @@ func (b *Block) UnmarshalJSON(data []byte) error {
101102
b.Event = s.Event
102103
b.Logs = s.Logs
103104
b.OK = s.OK
105+
b.canonicalState = nil
104106
return nil
105107
}

‎ethmonitor/canonical_state_test.go‎

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package ethmonitor
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"math/big"
7+
"testing"
8+
9+
"github.com/0xsequence/ethkit/go-ethereum/common"
10+
"github.com/0xsequence/ethkit/go-ethereum/core/types"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func canonicalTestBlock(num int64) *Block {
15+
return &Block{
16+
Block: types.NewBlockWithHeader(&types.Header{
17+
Number: big.NewInt(num),
18+
BlockHash: common.BigToHash(big.NewInt(num)),
19+
ParentHash: common.BigToHash(big.NewInt(num - 1)),
20+
Time: uint64(num),
21+
}),
22+
Event: Added,
23+
OK: true,
24+
}
25+
}
26+
27+
func pushCanonicalTestBlock(t *testing.T, chain *Chain, input *Block) *Block {
28+
t.Helper()
29+
chain.push(input)
30+
block := chain.Head()
31+
require.NotNil(t, block)
32+
require.Equal(t, input.Hash(), block.Hash())
33+
return block
34+
}
35+
36+
// The interface keeps the before-fix proof executable without the new method.
37+
func canonicalTestState(t *testing.T, block *Block) (uint64, bool) {
38+
t.Helper()
39+
state, ok := any(block).(interface{ CanonicalState() (uint64, bool) })
40+
if !ok {
41+
return 0, false
42+
}
43+
return state.CanonicalState()
44+
}
45+
46+
func TestBlockCanonicalStateRetention(t *testing.T) {
47+
for _, depth := range []int64{1, 3} {
48+
t.Run(fmt.Sprintf("evictionDepth=%d", depth), func(t *testing.T) {
49+
chain := newChain(10, false)
50+
block := pushCanonicalTestBlock(t, chain, canonicalTestBlock(100))
51+
copy := chain.Blocks().Copy()[0]
52+
incarnation, canonical := canonicalTestState(t, block)
53+
require.Positive(t, incarnation)
54+
require.True(t, canonical)
55+
for num := int64(101); num <= 109+depth; num++ {
56+
pushCanonicalTestBlock(t, chain, canonicalTestBlock(num))
57+
}
58+
require.Nil(t, chain.GetBlock(block.Hash()))
59+
for _, snapshot := range []*Block{block, copy} {
60+
got, canonical := canonicalTestState(t, snapshot)
61+
require.Equal(t, incarnation, got)
62+
require.True(t, canonical, "retention eviction was mistaken for removal")
63+
}
64+
})
65+
}
66+
}
67+
68+
func TestBlockCanonicalStateReadoption(t *testing.T) {
69+
chain := newChain(10, false)
70+
input := canonicalTestBlock(100)
71+
block := pushCanonicalTestBlock(t, chain, input)
72+
shallow := *block
73+
copy := chain.Blocks().Copy()[0]
74+
incarnation, canonical := canonicalTestState(t, block)
75+
require.Positive(t, incarnation)
76+
require.True(t, canonical)
77+
removed := *chain.pop()
78+
removed.Event = Removed
79+
for _, snapshot := range []*Block{block, &shallow, copy, &removed} {
80+
got, canonical := canonicalTestState(t, snapshot)
81+
require.Equal(t, incarnation, got)
82+
require.False(t, canonical, "snapshot did not observe the actual removal")
83+
}
84+
85+
// Reusing either the original input or a removed snapshot must create a fresh
86+
// owned incarnation without mutating old Added/Removed event copies.
87+
for _, reused := range []*Block{input, copy} {
88+
fresh := pushCanonicalTestBlock(t, chain, reused)
89+
got, canonical := canonicalTestState(t, fresh)
90+
require.Greater(t, got, incarnation)
91+
require.True(t, canonical)
92+
for _, snapshot := range []*Block{block, &shallow, copy, &removed} {
93+
got, canonical := canonicalTestState(t, snapshot)
94+
require.Equal(t, incarnation, got)
95+
require.False(t, canonical, "fresh readoption revived an old event")
96+
}
97+
chain.pop()
98+
}
99+
}
100+
101+
func TestBlockCanonicalStateBootstrap(t *testing.T) {
102+
for _, count := range []int{1, 3} {
103+
for _, serialized := range []bool{false, true} {
104+
t.Run(fmt.Sprintf("blocks=%d/JSON=%v", count, serialized), func(t *testing.T) {
105+
inputs := make(Blocks, count)
106+
for i := range inputs {
107+
inputs[i] = canonicalTestBlock(int64(100 + i))
108+
}
109+
chain := newChain(10, true)
110+
if serialized {
111+
data, err := json.Marshal(inputs)
112+
require.NoError(t, err)
113+
require.NoError(t, chain.BootstrapFromBlocksJSON(data))
114+
} else {
115+
require.NoError(t, chain.BootstrapFromBlocks(inputs))
116+
}
117+
for _, block := range chain.Blocks().Copy() {
118+
incarnation, canonical := canonicalTestState(t, block)
119+
require.Positive(t, incarnation)
120+
require.True(t, canonical)
121+
data, err := json.Marshal(block)
122+
require.NoError(t, err)
123+
require.NotContains(t, string(data), "incarnation")
124+
require.NoError(t, json.Unmarshal(data, block))
125+
incarnation, canonical = canonicalTestState(t, block)
126+
require.Zero(t, incarnation, "serialized state preserved runtime ownership")
127+
require.False(t, canonical)
128+
}
129+
})
130+
}
131+
}
132+
}
133+
134+
func TestBlockCanonicalStateConcurrentRemoval(t *testing.T) {
135+
chain := newChain(10, false)
136+
input := canonicalTestBlock(100)
137+
block := pushCanonicalTestBlock(t, chain, input)
138+
incarnation, _ := canonicalTestState(t, block)
139+
require.Positive(t, incarnation)
140+
state := any(block).(interface{ CanonicalState() (uint64, bool) })
141+
start, done := make(chan struct{}), make(chan struct{})
142+
result := make(chan error, 1)
143+
go func() {
144+
close(start)
145+
removed := false
146+
for {
147+
got, canonical := state.CanonicalState()
148+
if got != incarnation || (removed && canonical) {
149+
result <- fmt.Errorf("old incarnation changed or revived: id=%d canonical=%v", got, canonical)
150+
return
151+
}
152+
removed = removed || !canonical
153+
select {
154+
case <-done:
155+
result <- nil
156+
return
157+
default:
158+
}
159+
}
160+
}()
161+
<-start
162+
for i := 0; i < 100; i++ {
163+
chain.pop()
164+
pushCanonicalTestBlock(t, chain, input)
165+
}
166+
close(done)
167+
require.NoError(t, <-result)
168+
got, canonical := state.CanonicalState()
169+
require.Equal(t, incarnation, got)
170+
require.False(t, canonical)
171+
}
172+
173+
func TestBlockCanonicalStateUntracked(t *testing.T) {
174+
for _, block := range []*Block{nil, canonicalTestBlock(100)} {
175+
incarnation, canonical := canonicalTestState(t, block)
176+
require.Zero(t, incarnation)
177+
require.False(t, canonical)
178+
}
179+
}

‎ethmonitor/chain.go‎

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"math/big"
88
"slices"
99
"sync"
10+
"sync/atomic"
1011

1112
"github.com/0xsequence/ethkit/go-ethereum"
1213
"github.com/0xsequence/ethkit/go-ethereum/common"
@@ -28,6 +29,7 @@ type Chain struct {
2829

2930
mu sync.RWMutex
3031
averageBlockTime float64 // in seconds
32+
lastIncarnation uint64
3133
}
3234

3335
func newChain(retentionLimit int, bootstrapMode bool) *Chain {
@@ -59,7 +61,7 @@ func newChain(retentionLimit int, bootstrapMode bool) *Chain {
5961
// }
6062

6163
// Push to the top of the stack
62-
func (c *Chain) push(nextBlock *Block) error {
64+
func (c *Chain) push(nextBlock *Block) (*Block, error) {
6365
c.mu.Lock()
6466
defer c.mu.Unlock()
6567

@@ -70,12 +72,12 @@ func (c *Chain) push(nextBlock *Block) error {
7072

7173
// Assert pointing at prev block
7274
if nextBlock.ParentHash() != headBlock.Hash() {
73-
return ErrUnexpectedParentHash
75+
return nil, ErrUnexpectedParentHash
7476
}
7577

7678
// Assert block numbers are in sequence
7779
if nextBlock.NumberU64() != headBlock.NumberU64()+1 {
78-
return ErrUnexpectedBlockNumber
80+
return nil, ErrUnexpectedBlockNumber
7981
}
8082

8183
// Update average block time
@@ -86,14 +88,20 @@ func (c *Chain) push(nextBlock *Block) error {
8688
}
8789
}
8890

91+
// Each adoption owns its state so reusing an input cannot revive old events.
92+
c.lastIncarnation++
93+
block := *nextBlock
94+
block.canonicalState = &blockCanonicalState{incarnation: c.lastIncarnation}
95+
block.canonicalState.canonical.Store(true)
96+
8997
// Add to head of stack
90-
c.blocks = append(c.blocks, nextBlock)
98+
c.blocks = append(c.blocks, &block)
9199
if len(c.blocks) > c.retentionLimit {
92100
c.blocks[0] = nil
93101
c.blocks = c.blocks[1:]
94102
}
95103

96-
return nil
104+
return &block, nil
97105
}
98106

99107
// Pop from the top of the stack
@@ -107,6 +115,9 @@ func (c *Chain) pop() *Block {
107115

108116
n := len(c.blocks) - 1
109117
block := c.blocks[n]
118+
if block.canonicalState != nil {
119+
block.canonicalState.canonical.Store(false)
120+
}
110121
c.blocks[n] = nil
111122
c.blocks = c.blocks[:n]
112123
return block
@@ -215,6 +226,9 @@ const (
215226
Removed
216227
)
217228

229+
// Block contains a monitored block and its event data.
230+
// Construct values with keyed composite literals: private canonical state makes
231+
// positional literals unsupported.
218232
type Block struct {
219233
*types.Block
220234

@@ -228,6 +242,26 @@ type Block struct {
228242

229243
// OK flag which represents the block is ready for broadcasting
230244
OK bool
245+
246+
canonicalState *blockCanonicalState
247+
}
248+
249+
type blockCanonicalState struct {
250+
incarnation uint64
251+
canonical atomic.Bool
252+
}
253+
254+
// CanonicalState reports the monitor-assigned incarnation and whether it has
255+
// remained canonical without a known removal. Zero means the block is untracked.
256+
// Retention eviction preserves this state; in-memory copies share removal updates.
257+
// Incarnations are local to a monitor chain. Serialized blocks are untracked
258+
// until accepted by a monitor.
259+
func (b *Block) CanonicalState() (incarnation uint64, canonical bool) {
260+
if b == nil || b.canonicalState == nil {
261+
return 0, false
262+
}
263+
state := b.canonicalState
264+
return state.incarnation, state.canonical.Load()
231265
}
232266

233267
type Blocks []*Block
@@ -560,10 +594,11 @@ func (blocks Blocks) Copy() Blocks {
560594
}
561595

562596
nb[i] = &Block{
563-
Block: b.Block,
564-
Event: b.Event,
565-
Logs: logs,
566-
OK: b.OK,
597+
Block: b.Block,
598+
Event: b.Event,
599+
Logs: logs,
600+
OK: b.OK,
601+
canonicalState: b.canonicalState,
567602
}
568603
}
569604

‎ethmonitor/ethmonitor.go‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -640,10 +640,10 @@ func (m *Monitor) monitor(ctx context.Context, wg *sync.WaitGroup) error {
640640
// fetch the next block, either via the stream or via a poll
641641
nextBlock, nextBlockPayload, miss, err := m.fetchNextBlock(ctx, false)
642642

643-
// with prefetching, a cached block may be a stale entry from an abandoned
644-
// fork, written by a prefetch worker of this or a peer monitor. confirm a
645-
// parent mismatch with the node before treating it as a reorg.
646-
if err == nil && m.prefetch != nil && headBlock != nil && nextBlock.ParentHash() != headBlock.Hash() {
643+
// A cached block may be a stale entry from an abandoned fork, written by
644+
// this or a peer monitor even when local prefetching is disabled. Confirm
645+
// a parent mismatch with the node before treating it as a reorg.
646+
if err == nil && m.cache != nil && headBlock != nil && nextBlock.ParentHash() != headBlock.Hash() {
647647
nextBlock, nextBlockPayload, miss, err = m.refetchNextBlock(ctx, nextBlock)
648648
}
649649

@@ -730,9 +730,11 @@ func (m *Monitor) buildCanonicalChain(ctx context.Context, nextBlock *types.Bloc
730730

731731
if headBlock == nil || nextBlock.ParentHash() == headBlock.Hash() {
732732
// block-chaining it up
733-
block := &Block{Event: Added, Block: nextBlock}
734-
events = append(events, block)
735-
return events, m.chain.push(block)
733+
block, err := m.chain.push(&Block{Event: Added, Block: nextBlock})
734+
if err != nil {
735+
return events, err
736+
}
737+
return append(events, block), nil
736738
}
737739

738740
// next block doest match prevHash, therefore we must pop our previous block and recursively
@@ -778,8 +780,7 @@ func (m *Monitor) buildCanonicalChain(ctx context.Context, nextBlock *types.Bloc
778780
return events, err
779781
}
780782

781-
block := &Block{Event: Added, Block: nextBlock}
782-
err = m.chain.push(block)
783+
block, err := m.chain.push(&Block{Event: Added, Block: nextBlock})
783784
if err != nil {
784785
return events, err
785786
}

0 commit comments

Comments
 (0)