Skip to content

Commit 987262d

Browse files
committed
fix(ethmonitor): reject malformed logs before caching
1 parent 1c35532 commit 987262d

2 files changed

Lines changed: 154 additions & 2 deletions

File tree

‎ethmonitor/ethmonitor.go‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -851,6 +851,8 @@ func (m *Monitor) logTopics() [][]common.Hash {
851851
}
852852

853853
func (m *Monitor) filterLogs(ctx context.Context, blockHash common.Hash, topics [][]common.Hash, blockBloom types.Bloom) ([]types.Log, []byte, error) {
854+
var fetchedLogs []types.Log
855+
854856
getter := func(ctx context.Context, _ string) ([]byte, error) {
855857
if m.options.DebugLogging {
856858
m.log.Debug(fmt.Sprintf("ethmonitor: filterLogs is calling origin for block hash %s", blockHash))
@@ -873,6 +875,12 @@ func (m *Monitor) filterLogs(ctx context.Context, blockHash common.Hash, topics
873875
// ensure we do not incorrectly cache an empty block-logs response as valid.
874876
return nil, fmt.Errorf("ethmonitor: filterLogs detected empty block-logs response but block bloom is set, ignoring node response")
875877
}
878+
// Validate before caching so a malformed response cannot block log
879+
// backfilling until cache expiry.
880+
fetchedLogs, err = m.unmarshalLogs(logsPayload)
881+
if err != nil {
882+
return nil, err
883+
}
876884
return logsPayload, nil
877885
}
878886

@@ -881,16 +889,25 @@ func (m *Monitor) filterLogs(ctx context.Context, blockHash common.Hash, topics
881889
if err != nil {
882890
return nil, resp, err
883891
}
884-
logs, err := m.unmarshalLogs(resp)
885-
return logs, resp, err
892+
return fetchedLogs, resp, nil
886893
}
887894

888895
key := CacheKeyBlockLogs(m.chainID, blockHash, topics)
889896
resp, err := m.cache.GetOrSetWithLockEx(ctx, key, getter, m.options.CacheExpiry)
890897
if err != nil {
891898
return nil, resp, err
892899
}
900+
if fetchedLogs != nil {
901+
return fetchedLogs, resp, nil
902+
}
893903
logs, err := m.unmarshalLogs(resp)
904+
if err != nil {
905+
// Recover entries cached by peers or older monitors that did not
906+
// validate logs before writing them.
907+
if deleteErr := m.cache.Delete(ctx, key); deleteErr != nil {
908+
m.log.Warn(fmt.Sprintf("ethmonitor: error deleting invalid logs cache for block hash %s due to: '%v'", blockHash.Hex(), deleteErr))
909+
}
910+
}
894911
return logs, resp, err
895912
}
896913

‎ethmonitor/prefetch_internal_test.go‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,14 @@ import (
55
"encoding/json"
66
"fmt"
77
"math/big"
8+
"sync/atomic"
89
"testing"
910
"time"
1011

1112
"github.com/0xsequence/ethkit/ethmonitor/internal/mocks"
13+
"github.com/0xsequence/ethkit/go-ethereum"
14+
"github.com/0xsequence/ethkit/go-ethereum/common"
15+
"github.com/0xsequence/ethkit/go-ethereum/core/types"
1216
"github.com/stretchr/testify/require"
1317
"go.uber.org/mock/gomock"
1418
)
@@ -35,6 +39,137 @@ func TestPrefetchRejectsInvalidBlockBeforeCaching(t *testing.T) {
3539
}
3640
}
3741

42+
func TestMonitorRecoversInvalidPrefetchedLogs(t *testing.T) {
43+
for _, payload := range []string{`{}`, `[{}]`} {
44+
for _, cached := range []bool{false, true} {
45+
t.Run(fmt.Sprintf("payload=%s/cached=%v", payload, cached), func(t *testing.T) {
46+
const first, target, last = uint64(1000), uint64(1002), uint64(1004)
47+
targetHash := common.BigToHash(new(big.Int).SetUint64(target))
48+
expectedLogs := []types.Log{{
49+
Address: common.HexToAddress("0x1234"),
50+
Topics: []common.Hash{common.HexToHash("0xabcd")},
51+
Data: []byte{1, 2},
52+
TxHash: common.HexToHash("0x5678"),
53+
BlockHash: targetHash,
54+
BlockNumber: target,
55+
}}
56+
validPayload, err := json.Marshal(expectedLogs)
57+
require.NoError(t, err)
58+
59+
provider := mocks.NewMockRawInterface(gomock.NewController(t))
60+
provider.EXPECT().ChainID(gomock.Any()).Return(big.NewInt(1), nil).AnyTimes()
61+
provider.EXPECT().IsStreamingEnabled().Return(false).AnyTimes()
62+
provider.EXPECT().BlockNumber(gomock.Any()).Return(last, nil).AnyTimes()
63+
provider.EXPECT().RawBlockByNumber(gomock.Any(), gomock.Any()).DoAndReturn(
64+
func(ctx context.Context, number *big.Int) (json.RawMessage, error) {
65+
if err := ctx.Err(); err != nil {
66+
return nil, err
67+
}
68+
if number.Uint64() > last {
69+
return nil, ethereum.NotFound
70+
}
71+
var bloom types.Bloom
72+
if number.Uint64() == target {
73+
bloom = types.BytesToBloom([]byte{1})
74+
}
75+
header := &types.Header{
76+
Number: number,
77+
ParentHash: common.BigToHash(new(big.Int).Sub(number, big.NewInt(1))),
78+
Difficulty: big.NewInt(0),
79+
GasLimit: 30_000_000,
80+
Bloom: bloom,
81+
}
82+
header.SetHash(common.BigToHash(number))
83+
return json.Marshal(header)
84+
},
85+
).AnyTimes()
86+
var originCalls atomic.Int64
87+
provider.EXPECT().RawFilterLogs(gomock.Any(), gomock.Any()).DoAndReturn(
88+
func(ctx context.Context, query ethereum.FilterQuery) (json.RawMessage, error) {
89+
if err := ctx.Err(); err != nil {
90+
return nil, err
91+
}
92+
if *query.BlockHash != targetHash {
93+
return json.RawMessage(`[]`), nil
94+
}
95+
if originCalls.Add(1) == 1 && !cached {
96+
return json.RawMessage(payload), nil
97+
}
98+
return json.RawMessage(validPayload), nil
99+
},
100+
).AnyTimes()
101+
102+
opts := DefaultOptions
103+
opts.WithLogs = true
104+
opts.PrefetchConcurrency = 1
105+
opts.StartBlockNumber = new(big.Int).SetUint64(first)
106+
opts.PollingInterval = 10 * time.Millisecond
107+
opts.CacheExpiry = time.Hour
108+
// Leave CacheBackend unset to exercise automatic memory caching.
109+
monitor, err := NewMonitor(provider, opts)
110+
require.NoError(t, err)
111+
monitor.chainID = big.NewInt(1)
112+
key := CacheKeyBlockLogs(monitor.chainID, targetHash, monitor.logTopics())
113+
if cached {
114+
require.NoError(t, monitor.cache.SetEx(context.Background(), key, []byte(payload), time.Hour))
115+
} else {
116+
// Complete the speculative fetch before starting the serial loop,
117+
// so the worker deterministically receives the malformed response.
118+
monitor.prefetch.fetch(context.Background(), prefetchJob{num: target})
119+
require.Equal(t, int64(1), originCalls.Load())
120+
_, found, err := monitor.cache.Get(context.Background(), key)
121+
require.NoError(t, err)
122+
require.False(t, found, "failed prefetch must not cache malformed logs")
123+
}
124+
125+
sub := monitor.Subscribe("TestMonitorRecoversInvalidPrefetchedLogs")
126+
defer sub.Unsubscribe()
127+
ctx, cancel := context.WithCancel(context.Background())
128+
done := make(chan error, 1)
129+
go func() { done <- monitor.Run(ctx) }()
130+
defer func() {
131+
cancel()
132+
select {
133+
case err := <-done:
134+
require.NoError(t, err)
135+
case <-time.After(5 * time.Second):
136+
t.Error("monitor did not stop")
137+
}
138+
}()
139+
140+
timer := time.NewTimer(3 * time.Second)
141+
defer timer.Stop()
142+
next := first
143+
for next <= last {
144+
select {
145+
case blocks := <-sub.Blocks():
146+
for _, block := range blocks {
147+
require.Equal(t, Added, block.Event)
148+
require.True(t, block.OK)
149+
require.Equal(t, next, block.NumberU64(), "blocks must publish in order")
150+
if next == target {
151+
require.Equal(t, expectedLogs, block.Logs)
152+
}
153+
next++
154+
}
155+
case <-timer.C:
156+
t.Fatalf("publication stalled at block %d; origin calls: %d", next, originCalls.Load())
157+
}
158+
}
159+
if !cached {
160+
require.GreaterOrEqual(t, originCalls.Load(), int64(2), "malformed prefetch must allow an origin retry")
161+
} else {
162+
require.Positive(t, originCalls.Load(), "invalid cache entry must allow an origin retry")
163+
}
164+
stored, found, err := monitor.cache.Get(context.Background(), key)
165+
require.NoError(t, err)
166+
require.True(t, found)
167+
require.JSONEq(t, string(validPayload), string(stored))
168+
})
169+
}
170+
}
171+
}
172+
38173
func TestPrefetchPanicStopsWorkers(t *testing.T) {
39174
provider := mocks.NewMockRawInterface(gomock.NewController(t))
40175
provider.EXPECT().BlockNumber(gomock.Any()).DoAndReturn(func(context.Context) (uint64, error) {

0 commit comments

Comments
 (0)