Skip to content

Commit c197f76

Browse files
committed
test(evmreader): add edge case and adversarial input tests
1 parent 13a60a3 commit c197f76

1 file changed

Lines changed: 305 additions & 0 deletions

File tree

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
// (c) Cartesi and individual authors (see AUTHORS)
2+
// SPDX-License-Identifier: Apache-2.0 (see LICENSE)
3+
4+
package evmreader
5+
6+
import (
7+
"context"
8+
"errors"
9+
"math/big"
10+
"time"
11+
12+
. "github.com/cartesi/rollups-node/internal/model"
13+
"github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus"
14+
"github.com/cartesi/rollups-node/pkg/contracts/iinputbox"
15+
"github.com/ethereum/go-ethereum/accounts/abi/bind"
16+
"github.com/ethereum/go-ethereum/common"
17+
"github.com/ethereum/go-ethereum/core/types"
18+
"github.com/stretchr/testify/mock"
19+
)
20+
21+
// --- #19: Duplicate/out-of-order input events within a block ---
22+
// When RetrieveInputs returns duplicate events (same index) at a single block,
23+
// insertSorted must deduplicate them. Out-of-order events must be sorted by index.
24+
func (s *EvmReaderSuite) TestFetchApplicationInputsDuplicateAndOutOfOrderEvents() {
25+
addr := common.HexToAddress("0x3333333333333333333333333333333333333333")
26+
27+
inputSrc := &MockInputBox{}
28+
app := appContracts{
29+
application: &Application{
30+
Name: "test-app",
31+
IApplicationAddress: addr,
32+
IInputBoxAddress: inputBoxAddr,
33+
IInputBoxBlock: 10,
34+
},
35+
inputSource: inputSrc,
36+
}
37+
38+
repo := newMockRepository()
39+
repo.On("GetNumberOfInputs", mock.Anything, mock.Anything).
40+
Return(uint64(0), nil)
41+
s.evmReader.repository = repo
42+
43+
// On-chain: 0 inputs before block 100, 3 from block 100
44+
inputSrc.On("GetNumberOfInputs", blockRange(0, 100), mock.Anything).
45+
Return(new(big.Int).SetUint64(0), nil)
46+
inputSrc.On("GetNumberOfInputs", blockFrom(100), mock.Anything).
47+
Return(new(big.Int).SetUint64(3), nil)
48+
49+
// RetrieveInputs at block 100: 4 events including duplicate index 1,
50+
// delivered out-of-order: [2, 0, 1, 1]
51+
inputSrc.On("RetrieveInputs",
52+
mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 100 }),
53+
mock.Anything, mock.Anything,
54+
).Return([]iinputbox.IInputBoxInputAdded{
55+
makeInputEvent(addr, 2, 100),
56+
makeInputEvent(addr, 0, 100),
57+
makeInputEvent(addr, 1, 100),
58+
makeInputEvent(addr, 1, 100), // duplicate
59+
}, nil)
60+
61+
inputs, err := s.evmReader.fetchApplicationInputs(s.ctx, app, 10, 200)
62+
s.Require().NoError(err)
63+
64+
// 3 unique inputs, sorted by index
65+
s.Require().Len(inputs, 3)
66+
s.Require().Equal(uint64(0), inputs[0].Index)
67+
s.Require().Equal(uint64(1), inputs[1].Index)
68+
s.Require().Equal(uint64(2), inputs[2].Index)
69+
}
70+
71+
// --- #18: Adversarial EpochSealed data (LowerBound > UpperBound) ---
72+
// When a sealed event has InputIndexLowerBound > InputIndexUpperBound,
73+
// the code skips input fetching and stores the epoch without crashing.
74+
func (s *SealedEpochsSuite) TestSealedEpochLowerBoundGreaterThanUpperBound() {
75+
tournamentAddr := common.HexToAddress("0xDDDD")
76+
77+
app := appContracts{
78+
application: &Application{
79+
ID: 1,
80+
Name: "test-app",
81+
IApplicationAddress: app1Addr,
82+
IConsensusAddress: consensusAddr,
83+
IInputBoxAddress: inputBoxAddr,
84+
IInputBoxBlock: 10,
85+
},
86+
inputSource: s.inputBox,
87+
daveConsensus: s.dave,
88+
}
89+
90+
// Adversarial sealed event: LowerBound=5, UpperBound=3
91+
event := &idaveconsensus.IDaveConsensusEpochSealed{
92+
EpochNumber: big.NewInt(1),
93+
InputIndexLowerBound: big.NewInt(5),
94+
InputIndexUpperBound: big.NewInt(3), // < LowerBound — adversarial
95+
Tournament: tournamentAddr,
96+
Raw: types.Log{BlockNumber: 200},
97+
}
98+
99+
// Previous epoch (index 0) with UpperBound=5 matching the adversarial LowerBound
100+
s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)).
101+
Return(&Epoch{
102+
Index: 0, FirstBlock: 10, LastBlock: 100,
103+
InputIndexLowerBound: 0, InputIndexUpperBound: 5,
104+
}, nil)
105+
s.repository.On("UpdateEpochClaimTransactionHash",
106+
mock.Anything, mock.Anything, mock.Anything,
107+
).Return(nil)
108+
109+
// Epoch 1 doesn't exist yet
110+
s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(1)).
111+
Return(nil, nil)
112+
113+
var storedEpoch *Epoch
114+
var storedInputs []*Input
115+
s.repository.On("CreateEpochsAndInputs",
116+
mock.Anything, mock.Anything, mock.Anything, mock.Anything,
117+
).Run(func(args mock.Arguments) {
118+
epochInputMap := args.Get(2).(map[*Epoch][]*Input)
119+
for epoch, inputs := range epochInputMap {
120+
storedEpoch = epoch
121+
storedInputs = inputs
122+
}
123+
}).Return(nil)
124+
125+
err := s.evmReader.processSealedEpochEvent(s.ctx, app, event)
126+
s.Require().NoError(err)
127+
128+
// Epoch stored with inverted bounds, no inputs fetched
129+
s.Require().NotNil(storedEpoch)
130+
s.Require().Equal(uint64(1), storedEpoch.Index)
131+
s.Require().Equal(uint64(5), storedEpoch.InputIndexLowerBound)
132+
s.Require().Equal(uint64(3), storedEpoch.InputIndexUpperBound)
133+
s.Require().Equal(EpochStatus_Closed, storedEpoch.Status)
134+
s.Require().Empty(storedInputs)
135+
136+
// No input fetching occurred
137+
s.inputBox.AssertNotCalled(s.T(), "GetNumberOfInputs")
138+
s.inputBox.AssertNotCalled(s.T(), "RetrieveInputs")
139+
}
140+
141+
// --- #10: RetrieveInputs failure at a specific block ---
142+
// When RetrieveInputs fails during fetchSealedEpochInputs, the error must
143+
// propagate through FindTransitions back to the caller.
144+
func (s *SealedEpochsSuite) TestFetchSealedEpochInputsRetrieveFailure() {
145+
app := appContracts{
146+
application: &Application{
147+
ID: 1,
148+
Name: "test-app",
149+
IApplicationAddress: app1Addr,
150+
IInputBoxAddress: inputBoxAddr,
151+
IInputBoxBlock: 10,
152+
},
153+
inputSource: s.inputBox,
154+
}
155+
156+
epoch := &Epoch{
157+
Index: 0,
158+
FirstBlock: 10,
159+
LastBlock: 200,
160+
InputIndexLowerBound: 0,
161+
InputIndexUpperBound: 2,
162+
}
163+
164+
// On-chain: 0 inputs before block 100, 2 from block 100
165+
s.inputBox.On("GetNumberOfInputs", blockRange(0, 100), mock.Anything).
166+
Return(new(big.Int).SetUint64(0), nil)
167+
s.inputBox.On("GetNumberOfInputs", blockFrom(100), mock.Anything).
168+
Return(new(big.Int).SetUint64(2), nil)
169+
170+
// RetrieveInputs fails at the transition block
171+
s.inputBox.On("RetrieveInputs",
172+
mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 100 }),
173+
mock.Anything, mock.Anything,
174+
).Return(([]iinputbox.IInputBoxInputAdded)(nil), errors.New("RPC timeout"))
175+
176+
_, err := s.evmReader.fetchSealedEpochInputs(s.ctx, app, epoch)
177+
s.Require().Error(err)
178+
s.Require().ErrorContains(err, "RPC timeout")
179+
s.Require().ErrorContains(err, "failed to walk input transitions")
180+
}
181+
182+
// --- Adapter cache invalidation on config change ---
183+
// When an application's consensus address changes between block headers,
184+
// the adapter cache must be invalidated and adapters recreated.
185+
func (s *EvmReaderSuite) TestAdapterCacheInvalidationOnConfigChange() {
186+
ws := &FakeWSEthClient{}
187+
s.evmReader.wsClient = ws
188+
s.evmReader.inputReaderEnabled = false
189+
s.evmReader.defaultBlock = DefaultBlock_Latest
190+
191+
addr := common.HexToAddress("0x4444444444444444444444444444444444444444")
192+
consensusAddr1 := common.HexToAddress("0xAAA1")
193+
consensusAddr2 := common.HexToAddress("0xAAA2")
194+
195+
repo := newMockRepository()
196+
// Header 1: app with consensus=addr1
197+
repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false).
198+
Return([]*Application{{
199+
ID: 1, Name: "app",
200+
IApplicationAddress: addr,
201+
IConsensusAddress: consensusAddr1,
202+
IInputBoxAddress: inputBoxAddr,
203+
LastOutputCheckBlock: 999, // > header block → skip output check
204+
}}, uint64(1), nil).Once()
205+
// Header 2: consensus address changed → cache invalidation
206+
repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false).
207+
Return([]*Application{{
208+
ID: 1, Name: "app",
209+
IApplicationAddress: addr,
210+
IConsensusAddress: consensusAddr2,
211+
IInputBoxAddress: inputBoxAddr,
212+
LastOutputCheckBlock: 999,
213+
}}, uint64(1), nil).Once()
214+
// Header 3: same config as header 2 → cache hit
215+
repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false).
216+
Return([]*Application{{
217+
ID: 1, Name: "app",
218+
IApplicationAddress: addr,
219+
IConsensusAddress: consensusAddr2,
220+
IInputBoxAddress: inputBoxAddr,
221+
LastOutputCheckBlock: 999,
222+
}}, uint64(1), nil).Once()
223+
// Catch-all for sentinel header
224+
repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false).
225+
Return([]*Application{}, uint64(0), nil)
226+
s.evmReader.repository = repo
227+
228+
factory := newMockAdapterFactory()
229+
factory.On("CreateAdapters", mock.Anything).
230+
Return(newMockApplicationContract(), newMockInputBox(), nil, nil)
231+
s.evmReader.adapterFactory = factory
232+
233+
ctx, cancel := context.WithCancel(s.ctx)
234+
ready := make(chan struct{}, 1)
235+
errCh := make(chan error, 1)
236+
go func() {
237+
_, err := s.evmReader.watchForNewBlocks(ctx, ready)
238+
errCh <- err
239+
}()
240+
<-ready
241+
242+
// Fire 3 headers (block numbers below 999 so output check skips)
243+
ws.fireNewHead(&types.Header{Number: big.NewInt(100)})
244+
ws.fireNewHead(&types.Header{Number: big.NewInt(101)})
245+
ws.fireNewHead(&types.Header{Number: big.NewInt(102)})
246+
ws.flushHeaders()
247+
248+
cancel()
249+
<-errCh
250+
251+
// CreateAdapters called twice:
252+
// Header 1: cache miss → create
253+
// Header 2: consensus changed → invalidate + recreate
254+
// Header 3: cache hit → skip
255+
factory.AssertNumberOfCalls(s.T(), "CreateAdapters", 2)
256+
}
257+
258+
// --- #20: Liveness timer fires correctly after headers stop ---
259+
// After processing headers, if no new header arrives within the liveness
260+
// timeout, watchForNewBlocks returns a SubscriptionError. This also exercises
261+
// the double-select fix: headers that arrive simultaneously with the timer
262+
// are picked up by the inner non-blocking receive.
263+
func (s *EvmReaderSuite) TestLivenessTimerFiresAfterHeadersStop() {
264+
ws := &FakeWSEthClient{}
265+
s.evmReader.wsClient = ws
266+
s.evmReader.wsLivenessTimeout = 50 * time.Millisecond
267+
s.evmReader.inputReaderEnabled = false
268+
s.evmReader.defaultBlock = DefaultBlock_Latest
269+
270+
repo := newMockRepository()
271+
repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false).
272+
Return([]*Application{}, uint64(0), nil)
273+
s.evmReader.repository = repo
274+
275+
ctx, cancel := context.WithCancel(s.ctx)
276+
defer cancel()
277+
ready := make(chan struct{}, 1)
278+
279+
type watchResult struct {
280+
headersProcessed uint64
281+
err error
282+
}
283+
resultCh := make(chan watchResult, 1)
284+
go func() {
285+
hp, err := s.evmReader.watchForNewBlocks(ctx, ready)
286+
resultCh <- watchResult{hp, err}
287+
}()
288+
<-ready
289+
290+
// Fire 3 headers, then stop sending
291+
ws.fireNewHead(&types.Header{Number: big.NewInt(100)})
292+
ws.fireNewHead(&types.Header{Number: big.NewInt(101)})
293+
ws.fireNewHead(&types.Header{Number: big.NewInt(102)})
294+
295+
// Liveness timer should fire ~50ms after last header
296+
select {
297+
case r := <-resultCh:
298+
s.Require().Equal(uint64(3), r.headersProcessed)
299+
var subErr *SubscriptionError
300+
s.Require().ErrorAs(r.err, &subErr)
301+
s.Require().ErrorContains(r.err, "no new block header received")
302+
case <-time.After(5 * time.Second):
303+
s.FailNow("watchForNewBlocks didn't return after liveness timeout")
304+
}
305+
}

0 commit comments

Comments
 (0)