|
| 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