From 52db3b0ff99af3efb420c19388960c11ebd9ce4a Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:54:36 -0300 Subject: [PATCH 01/14] fix(evmreader): prevent checkpoint advance for apps that failed input fetch --- internal/evmreader/input.go | 17 +++++-- internal/evmreader/input_test.go | 81 ++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/internal/evmreader/input.go b/internal/evmreader/input.go index 8da87e58c..a6ee8ccd2 100644 --- a/internal/evmreader/input.go +++ b/internal/evmreader/input.go @@ -315,14 +315,16 @@ func (r *Service) readAndStoreInputs( } - // Update LastInputCheckBlock for applications that didn't have any inputs + // Update LastInputCheckBlock for applications that were successfully scanned + // but didn't have any inputs. // (for apps with inputs, LastInputCheckBlock is already updated in CreateEpochsAndInputs) + // Only apps present in appInputsMap were successfully scanned. Apps that failed + // to fetch are absent from the map and their checkpoint must NOT advance, + // otherwise inputs in the failed block range would be permanently skipped. appsToUpdate := []int64{} - // Find applications that didn't have any inputs in appInputsMap for _, app := range apps { appAddress := app.application.IApplicationAddress - // If the app doesn't have any inputs in the map or has an empty slice - if inputs, exists := appInputsMap[appAddress]; !exists || len(inputs) == 0 { + if inputs, exists := appInputsMap[appAddress]; exists && len(inputs) == 0 { appsToUpdate = append(appsToUpdate, app.application.ID) } } @@ -348,6 +350,13 @@ func (r *Service) readAndStoreInputs( return nil } +// readInputsFromBlockchain fetches inputs for each application independently. +// On per-app failure, the failing app is omitted from the returned map (not +// present as a key) and processing continues for the remaining apps. The error +// return is reserved for fatal failures that prevent any work. +// Callers must use map-key presence to distinguish success (key exists, possibly +// with an empty slice) from failure (key absent) — apps absent from the map must +// NOT have their checkpoint advanced. func (r *Service) readInputsFromBlockchain( ctx context.Context, apps []appContracts, diff --git a/internal/evmreader/input_test.go b/internal/evmreader/input_test.go index 1d66c2cec..4a0dfc10d 100644 --- a/internal/evmreader/input_test.go +++ b/internal/evmreader/input_test.go @@ -4,6 +4,7 @@ package evmreader import ( + "errors" "math/big" "time" @@ -12,6 +13,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) func (s *EvmReaderSuite) TestItReadsInputsFromNewBlocksFilteredByDA() { @@ -386,3 +388,82 @@ func (s *EvmReaderSuite) TestItStartsWhenLastProcessedBlockIsTheMostRecentBlock( s.contractFactory.AssertExpectations(s.T()) s.client.AssertExpectations(s.T()) } + +// TestCheckpointNotAdvancedOnFetchFailure is a regression test for a bug where +// readInputsFromBlockchain swallowed per-app fetch errors, and the caller then +// advanced LastInputCheckBlock for failed apps — permanently skipping their inputs. +func (s *EvmReaderSuite) TestCheckpointNotAdvancedOnFetchFailure() { + require := require.New(s.T()) + + app1Addr := common.HexToAddress("0x1111111111111111111111111111111111111111") + app2Addr := common.HexToAddress("0x2222222222222222222222222222222222222222") + inputBoxAddr := common.HexToAddress("0xBa3Cf8fB82E43D370117A0b7296f91ED674E94e3") + + app1 := &Application{ + ID: 1, + Name: "app-ok", + IApplicationAddress: app1Addr, + IInputBoxAddress: inputBoxAddr, + EpochLength: 10, + LastInputCheckBlock: 100, + } + app2 := &Application{ + ID: 2, + Name: "app-fail", + IApplicationAddress: app2Addr, + IInputBoxAddress: inputBoxAddr, + EpochLength: 10, + LastInputCheckBlock: 100, + } + + // app1's inputSource succeeds with no inputs (GetNumberOfInputs returns 0 at both blocks) + inputSource1 := &MockInputBox{} + inputSource1.On("GetNumberOfInputs", mock.Anything, mock.Anything). + Return(new(big.Int).SetUint64(0), nil) + + // app2's inputSource fails + inputSource2 := &MockInputBox{} + inputSource2.On("GetNumberOfInputs", mock.Anything, mock.Anything). + Return((*big.Int)(nil), errors.New("RPC connection refused")) + + apps := []appContracts{ + {application: app1, inputSource: inputSource1}, + {application: app2, inputSource: inputSource2}, + } + + // Repository: app1 has 0 inputs in DB + repo := newMockRepository() + repo.On("GetNumberOfInputs", mock.Anything, app1Addr.String()). + Return(uint64(0), nil) + repo.On("GetNumberOfInputs", mock.Anything, app2Addr.String()). + Return(uint64(0), nil) + + // GetEpoch is called for app1 (which was successfully fetched with 0 inputs) + // calculateEpochIndex(10, 100) = 10 + repo.On("GetEpoch", mock.Anything, app1Addr.String(), uint64(10)). + Return(nil, nil) + + // Expect UpdateEventLastCheckBlock to be called with ONLY app1's ID. + // app2 failed to fetch — its checkpoint must NOT be advanced. + repo.On("UpdateEventLastCheckBlock", + mock.Anything, + mock.MatchedBy(func(ids []int64) bool { + // Must contain only app1 (ID=1), not app2 (ID=2) + for _, id := range ids { + if id == app2.ID { + return false + } + } + return len(ids) == 1 && ids[0] == app1.ID + }), + MonitoredEvent_InputAdded, + uint64(110), + ).Return(nil).Once() + + s.evmReader.repository = repo + + err := s.evmReader.readAndStoreInputs(s.ctx, 100, 110, apps) + require.NoError(err) + + repo.AssertExpectations(s.T()) +} From 596396c44ed78132c98bfbbd07c1b8c4daef979d Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:31:49 -0300 Subject: [PATCH 02/14] fix(evmreader): add ws liveness timeout --- internal/config/generate/Config.toml | 7 +++ internal/config/generated.go | 65 ++++++++++++++++++++++------ internal/evmreader/evmreader.go | 17 ++++++-- internal/evmreader/evmreader_test.go | 10 +++++ internal/evmreader/output.go | 14 +++--- internal/evmreader/output_test.go | 1 + internal/evmreader/service.go | 4 +- 7 files changed, 93 insertions(+), 25 deletions(-) diff --git a/internal/config/generate/Config.toml b/internal/config/generate/Config.toml index ee7dd3c09..6c2c6b630 100644 --- a/internal/config/generate/Config.toml +++ b/internal/config/generate/Config.toml @@ -227,6 +227,13 @@ description = """ Maximum wait time in seconds for the exponential backoff retry policy. The delay between retries for HTTP blockchain requests will never exceed this value, regardless of the backoff calculation.""" used-by = ["evmreader", "claimer", "node", "prt"] +[rollups.CARTESI_BLOCKCHAIN_WS_LIVENESS_TIMEOUT] +default = "120" +go-type = "Duration" +description = """ +Maximum time in seconds to wait for a new block header on the WebSocket subscription before treating the connection as stalled and reconnecting. Handles silent connection drops where no error is delivered.""" +used-by = ["evmreader", "node"] + [rollups.CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE] default = "0" go-type = "uint64" diff --git a/internal/config/generated.go b/internal/config/generated.go index 7070aff97..38528adf5 100644 --- a/internal/config/generated.go +++ b/internal/config/generated.go @@ -65,6 +65,7 @@ const ( BLOCKCHAIN_HTTP_RETRY_MAX_WAIT = "CARTESI_BLOCKCHAIN_HTTP_RETRY_MAX_WAIT" BLOCKCHAIN_HTTP_RETRY_MIN_WAIT = "CARTESI_BLOCKCHAIN_HTTP_RETRY_MIN_WAIT" BLOCKCHAIN_MAX_BLOCK_RANGE = "CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE" + BLOCKCHAIN_WS_LIVENESS_TIMEOUT = "CARTESI_BLOCKCHAIN_WS_LIVENESS_TIMEOUT" CLAIMER_POLLING_INTERVAL = "CARTESI_CLAIMER_POLLING_INTERVAL" MAX_STARTUP_TIME = "CARTESI_MAX_STARTUP_TIME" PRT_POLLING_INTERVAL = "CARTESI_PRT_POLLING_INTERVAL" @@ -174,6 +175,8 @@ func SetDefaults() { viper.SetDefault(BLOCKCHAIN_MAX_BLOCK_RANGE, "0") + viper.SetDefault(BLOCKCHAIN_WS_LIVENESS_TIMEOUT, "120") + viper.SetDefault(CLAIMER_POLLING_INTERVAL, "3") viper.SetDefault(MAX_STARTUP_TIME, "15") @@ -571,6 +574,9 @@ type EvmreaderConfig struct { // Maximum number of blocks in a single query to the provider. Queries with larger ranges will be broken into multiple smaller queries. Zero for unlimited. BlockchainMaxBlockRange uint64 `mapstructure:"CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE"` + // Maximum time in seconds to wait for a new block header on the WebSocket subscription before treating the connection as stalled and reconnecting. Handles silent connection drops where no error is delivered. + BlockchainWsLivenessTimeout Duration `mapstructure:"CARTESI_BLOCKCHAIN_WS_LIVENESS_TIMEOUT"` + // How many seconds the node expects services take initializing before aborting. MaxStartupTime Duration `mapstructure:"CARTESI_MAX_STARTUP_TIME"` } @@ -682,6 +688,13 @@ func LoadEvmreaderConfig() (*EvmreaderConfig, error) { return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE is required for the evmreader service: %w", err) } + cfg.BlockchainWsLivenessTimeout, err = GetBlockchainWsLivenessTimeout() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_WS_LIVENESS_TIMEOUT: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_WS_LIVENESS_TIMEOUT is required for the evmreader service: %w", err) + } + cfg.MaxStartupTime, err = GetMaxStartupTime() if err != nil && err != ErrNotDefined { return nil, fmt.Errorf("failed to get CARTESI_MAX_STARTUP_TIME: %w", err) @@ -866,6 +879,9 @@ type NodeConfig struct { // Maximum number of blocks in a single query to the provider. Queries with larger ranges will be broken into multiple smaller queries. Zero for unlimited. BlockchainMaxBlockRange uint64 `mapstructure:"CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE"` + // Maximum time in seconds to wait for a new block header on the WebSocket subscription before treating the connection as stalled and reconnecting. Handles silent connection drops where no error is delivered. + BlockchainWsLivenessTimeout Duration `mapstructure:"CARTESI_BLOCKCHAIN_WS_LIVENESS_TIMEOUT"` + // How many seconds the node will wait before querying the database for new claims. ClaimerPollingInterval Duration `mapstructure:"CARTESI_CLAIMER_POLLING_INTERVAL"` @@ -1059,6 +1075,13 @@ func LoadNodeConfig() (*NodeConfig, error) { return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE is required for the node service: %w", err) } + cfg.BlockchainWsLivenessTimeout, err = GetBlockchainWsLivenessTimeout() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_WS_LIVENESS_TIMEOUT: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_WS_LIVENESS_TIMEOUT is required for the node service: %w", err) + } + cfg.ClaimerPollingInterval, err = GetClaimerPollingInterval() if err != nil && err != ErrNotDefined { return nil, fmt.Errorf("failed to get CARTESI_CLAIMER_POLLING_INTERVAL: %w", err) @@ -1421,20 +1444,21 @@ func (c *NodeConfig) ToClaimerConfig() *ClaimerConfig { // ToEvmreaderConfig converts a NodeConfig to a EvmreaderConfig. func (c *NodeConfig) ToEvmreaderConfig() *EvmreaderConfig { return &EvmreaderConfig{ - BlockchainDefaultBlock: c.BlockchainDefaultBlock, - BlockchainHttpEndpoint: c.BlockchainHttpEndpoint, - BlockchainId: c.BlockchainId, - BlockchainWsEndpoint: c.BlockchainWsEndpoint, - DatabaseConnection: c.DatabaseConnection, - FeatureInputReaderEnabled: c.FeatureInputReaderEnabled, - TelemetryAddress: c.TelemetryAddress, - LogColor: c.LogColor, - LogLevel: c.LogLevel, - BlockchainHttpMaxRetries: c.BlockchainHttpMaxRetries, - BlockchainHttpRetryMaxWait: c.BlockchainHttpRetryMaxWait, - BlockchainHttpRetryMinWait: c.BlockchainHttpRetryMinWait, - BlockchainMaxBlockRange: c.BlockchainMaxBlockRange, - MaxStartupTime: c.MaxStartupTime, + BlockchainDefaultBlock: c.BlockchainDefaultBlock, + BlockchainHttpEndpoint: c.BlockchainHttpEndpoint, + BlockchainId: c.BlockchainId, + BlockchainWsEndpoint: c.BlockchainWsEndpoint, + DatabaseConnection: c.DatabaseConnection, + FeatureInputReaderEnabled: c.FeatureInputReaderEnabled, + TelemetryAddress: c.TelemetryAddress, + LogColor: c.LogColor, + LogLevel: c.LogLevel, + BlockchainHttpMaxRetries: c.BlockchainHttpMaxRetries, + BlockchainHttpRetryMaxWait: c.BlockchainHttpRetryMaxWait, + BlockchainHttpRetryMinWait: c.BlockchainHttpRetryMinWait, + BlockchainMaxBlockRange: c.BlockchainMaxBlockRange, + BlockchainWsLivenessTimeout: c.BlockchainWsLivenessTimeout, + MaxStartupTime: c.MaxStartupTime, } } @@ -2091,6 +2115,19 @@ func GetBlockchainMaxBlockRange() (uint64, error) { return notDefineduint64(), fmt.Errorf("%s: %w", BLOCKCHAIN_MAX_BLOCK_RANGE, ErrNotDefined) } +// GetBlockchainWsLivenessTimeout returns the value for the environment variable CARTESI_BLOCKCHAIN_WS_LIVENESS_TIMEOUT. +func GetBlockchainWsLivenessTimeout() (Duration, error) { + s := viper.GetString(BLOCKCHAIN_WS_LIVENESS_TIMEOUT) + if s != "" { + v, err := toDuration(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", BLOCKCHAIN_WS_LIVENESS_TIMEOUT, err) + } + return v, nil + } + return notDefinedDuration(), fmt.Errorf("%s: %w", BLOCKCHAIN_WS_LIVENESS_TIMEOUT, ErrNotDefined) +} + // GetClaimerPollingInterval returns the value for the environment variable CARTESI_CLAIMER_POLLING_INTERVAL. func GetClaimerPollingInterval() (Duration, error) { s := viper.GetString(CLAIMER_POLLING_INTERVAL) diff --git a/internal/evmreader/evmreader.go b/internal/evmreader/evmreader.go index 59729bd7e..262da38e8 100644 --- a/internal/evmreader/evmreader.go +++ b/internal/evmreader/evmreader.go @@ -78,7 +78,7 @@ func (r *Service) Run(ctx context.Context, ready chan struct{}) error { r.Logger.Error(err.Error()) if attempt > r.blockchainMaxRetries { - r.Logger.Error("Max attempts reached for subscription restart. Exititng", + r.Logger.Error("Max attempts reached for subscription restart. Exiting", "max_retries", r.blockchainMaxRetries, ) return err @@ -115,19 +115,30 @@ func (r *Service) watchForNewBlocks(ctx context.Context, ready chan<- struct{}) headers := make(chan *types.Header) sub, err := r.wsClient.SubscribeNewHead(ctx, headers) if err != nil { - return fmt.Errorf("could not start subscription: %v", err) + return fmt.Errorf("could not start subscription: %w", err) } r.Logger.Info("Subscribed to new block events") ready <- struct{}{} defer sub.Unsubscribe() + liveness := time.NewTimer(r.wsLivenessTimeout) + defer liveness.Stop() + for { select { case <-ctx.Done(): return ctx.Err() case err := <-sub.Err(): return &SubscriptionError{Cause: err} + case <-liveness.C: + return &SubscriptionError{ + Cause: fmt.Errorf( + "no new block header received for %s, assuming stalled connection", + r.wsLivenessTimeout, + ), + } case header := <-headers: + liveness.Reset(r.wsLivenessTimeout) // Every time a new block arrives r.Logger.Debug("New block header received", "blockNumber", header.Number, "blockHash", header.Hash()) @@ -239,7 +250,7 @@ func (r *Service) fetchMostRecentHeader( ctx, new(big.Int).SetInt64(defaultBlockNumber)) if err != nil { - return nil, fmt.Errorf("failed to retrieve header. %v", err) + return nil, fmt.Errorf("failed to retrieve header: %w", err) } if header == nil { diff --git a/internal/evmreader/evmreader_test.go b/internal/evmreader/evmreader_test.go index bad40aa20..c9df0d5b7 100644 --- a/internal/evmreader/evmreader_test.go +++ b/internal/evmreader/evmreader_test.go @@ -154,6 +154,7 @@ func (s *EvmReaderSuite) SetupTest() { inputReaderEnabled: true, blockchainMaxRetries: 0, blockchainSubscriptionRetryInterval: time.Second, + wsLivenessTimeout: 120 * time.Second, } logLevel, err := config.GetLogLevel() @@ -192,6 +193,15 @@ func (s *EvmReaderSuite) TestItEventuallyBecomesReady() { } } +func (s *EvmReaderSuite) TestItReturnsErrorWhenWebSocketStalls() { + s.evmReader.wsLivenessTimeout = 50 * time.Millisecond + ready := make(chan struct{}, 1) + err := s.evmReader.watchForNewBlocks(s.ctx, ready) + var subErr *SubscriptionError + s.Require().ErrorAs(err, &subErr) + s.Require().ErrorContains(err, "no new block header received") +} + func (s *EvmReaderSuite) TestItFailsToSubscribeForNewInputsOnStart() { s.wsClient.Unset("ChainID") s.wsClient.Unset("SubscribeNewHead") diff --git a/internal/evmreader/output.go b/internal/evmreader/output.go index d1a8ba60d..86cb00269 100644 --- a/internal/evmreader/output.go +++ b/internal/evmreader/output.go @@ -227,15 +227,15 @@ func (r *Service) readOutputExecutionsFromBlockChain( Context: ctx, BlockNumber: new(big.Int).SetUint64(block), } - numInputs, err := app.applicationContract.GetNumberOfExecutedOutputs(callOpts) + numOutputs, err := app.applicationContract.GetNumberOfExecutedOutputs(callOpts) if err != nil { - return nil, fmt.Errorf("failed to get number of Executed outputs at block %d: %w", block, err) + return nil, fmt.Errorf("failed to get number of executed outputs at block %d: %w", block, err) } - return numInputs, nil + return numOutputs, nil } var executedOutputs []*iapplication.IApplicationOutputExecuted - // Define onHit function that accumulates inputs at transition blocks + // Define onHit function that accumulates executed outputs at transition blocks onHit := func(block uint64) error { filterOpts := &bind.FilterOpts{ Context: ctx, @@ -244,7 +244,7 @@ func (r *Service) readOutputExecutionsFromBlockChain( } execEvents, err := app.applicationContract.RetrieveOutputExecutionEvents(filterOpts) if err != nil { - return fmt.Errorf("failed to retrieve inputs at block %d: %w", block, err) + return fmt.Errorf("failed to retrieve output execution events at block %d: %w", block, err) } executedOutputs = append(executedOutputs, execEvents...) return nil @@ -257,10 +257,10 @@ func (r *Service) readOutputExecutionsFromBlockChain( } prevValue.SetUint64(execCount) - // Use FindTransitions to find blocks where inputs were added + // Use FindTransitions to find blocks where outputs were executed _, err = ethutil.FindTransitions(ctx, startBlock, endBlock, prevValue, oracle, onHit) if err != nil { - return nil, fmt.Errorf("failed to walk input transitions: %w", err) + return nil, fmt.Errorf("failed to walk output execution transitions: %w", err) } r.Logger.Debug("Fetched output executed events for application", diff --git a/internal/evmreader/output_test.go b/internal/evmreader/output_test.go index 99de38847..d021f6ccb 100644 --- a/internal/evmreader/output_test.go +++ b/internal/evmreader/output_test.go @@ -536,6 +536,7 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { inputReaderEnabled: true, blockchainMaxRetries: 0, blockchainSubscriptionRetryInterval: time.Second, + wsLivenessTimeout: 120 * time.Second, } logLevel, err := config.GetLogLevel() diff --git a/internal/evmreader/service.go b/internal/evmreader/service.go index 19e3c894f..6d6b231c1 100644 --- a/internal/evmreader/service.go +++ b/internal/evmreader/service.go @@ -41,6 +41,7 @@ type Service struct { inputReaderEnabled bool blockchainMaxRetries uint64 blockchainSubscriptionRetryInterval time.Duration + wsLivenessTimeout time.Duration } const EvmReaderConfigKey = "evm-reader" @@ -104,6 +105,7 @@ func Create(ctx context.Context, c *CreateInfo) (*Service, error) { } s.blockchainMaxRetries = c.Config.BlockchainHttpMaxRetries s.blockchainSubscriptionRetryInterval = c.Config.BlockchainHttpRetryMinWait + s.wsLivenessTimeout = c.Config.BlockchainWsLivenessTimeout s.client = c.EthClient s.wsClient = c.EthWsClient @@ -181,6 +183,6 @@ func (s *Service) setupPersistentConfig( return &config.Value, nil } - s.Logger.Error("Could not retrieve persistent config from Database. %w", "error", err) + s.Logger.Error("Could not retrieve persistent config from database", "error", err) return nil, err } From 6df9ce84aa989f342bbc5a1db94c1b8cd09f5547 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:00:59 -0300 Subject: [PATCH 03/14] fix(evmreader): fix retry logic, health reporting, and context cancellation handling --- internal/config/generate/Config.toml | 14 ++++ internal/config/generated.go | 104 +++++++++++++++++++++++---- internal/evmreader/evmreader.go | 58 +++++++++++---- internal/evmreader/evmreader_test.go | 89 ++++++++++++++++++++++- internal/evmreader/service.go | 28 ++++++-- 5 files changed, 255 insertions(+), 38 deletions(-) diff --git a/internal/config/generate/Config.toml b/internal/config/generate/Config.toml index 6c2c6b630..26154f8f6 100644 --- a/internal/config/generate/Config.toml +++ b/internal/config/generate/Config.toml @@ -234,6 +234,20 @@ description = """ Maximum time in seconds to wait for a new block header on the WebSocket subscription before treating the connection as stalled and reconnecting. Handles silent connection drops where no error is delivered.""" used-by = ["evmreader", "node"] +[rollups.CARTESI_BLOCKCHAIN_WS_MAX_RETRIES] +default = "4" +go-type = "uint64" +description = """ +Maximum number of consecutive WebSocket subscription failures before the service gives up and exits. A failure is counted only when a subscription attempt produces zero headers before disconnecting. Successful header processing resets the counter.""" +used-by = ["evmreader", "node"] + +[rollups.CARTESI_BLOCKCHAIN_WS_RECONNECT_INTERVAL] +default = "1" +go-type = "Duration" +description = """ +Wait time in seconds between WebSocket subscription reconnection attempts after a connection failure.""" +used-by = ["evmreader", "node"] + [rollups.CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE] default = "0" go-type = "uint64" diff --git a/internal/config/generated.go b/internal/config/generated.go index 38528adf5..8770e483a 100644 --- a/internal/config/generated.go +++ b/internal/config/generated.go @@ -66,6 +66,8 @@ const ( BLOCKCHAIN_HTTP_RETRY_MIN_WAIT = "CARTESI_BLOCKCHAIN_HTTP_RETRY_MIN_WAIT" BLOCKCHAIN_MAX_BLOCK_RANGE = "CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE" BLOCKCHAIN_WS_LIVENESS_TIMEOUT = "CARTESI_BLOCKCHAIN_WS_LIVENESS_TIMEOUT" + BLOCKCHAIN_WS_MAX_RETRIES = "CARTESI_BLOCKCHAIN_WS_MAX_RETRIES" + BLOCKCHAIN_WS_RECONNECT_INTERVAL = "CARTESI_BLOCKCHAIN_WS_RECONNECT_INTERVAL" CLAIMER_POLLING_INTERVAL = "CARTESI_CLAIMER_POLLING_INTERVAL" MAX_STARTUP_TIME = "CARTESI_MAX_STARTUP_TIME" PRT_POLLING_INTERVAL = "CARTESI_PRT_POLLING_INTERVAL" @@ -177,6 +179,10 @@ func SetDefaults() { viper.SetDefault(BLOCKCHAIN_WS_LIVENESS_TIMEOUT, "120") + viper.SetDefault(BLOCKCHAIN_WS_MAX_RETRIES, "4") + + viper.SetDefault(BLOCKCHAIN_WS_RECONNECT_INTERVAL, "1") + viper.SetDefault(CLAIMER_POLLING_INTERVAL, "3") viper.SetDefault(MAX_STARTUP_TIME, "15") @@ -577,6 +583,12 @@ type EvmreaderConfig struct { // Maximum time in seconds to wait for a new block header on the WebSocket subscription before treating the connection as stalled and reconnecting. Handles silent connection drops where no error is delivered. BlockchainWsLivenessTimeout Duration `mapstructure:"CARTESI_BLOCKCHAIN_WS_LIVENESS_TIMEOUT"` + // Maximum number of consecutive WebSocket subscription failures before the service gives up and exits. A failure is counted only when a subscription attempt produces zero headers before disconnecting. Successful header processing resets the counter. + BlockchainWsMaxRetries uint64 `mapstructure:"CARTESI_BLOCKCHAIN_WS_MAX_RETRIES"` + + // Wait time in seconds between WebSocket subscription reconnection attempts after a connection failure. + BlockchainWsReconnectInterval Duration `mapstructure:"CARTESI_BLOCKCHAIN_WS_RECONNECT_INTERVAL"` + // How many seconds the node expects services take initializing before aborting. MaxStartupTime Duration `mapstructure:"CARTESI_MAX_STARTUP_TIME"` } @@ -695,6 +707,20 @@ func LoadEvmreaderConfig() (*EvmreaderConfig, error) { return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_WS_LIVENESS_TIMEOUT is required for the evmreader service: %w", err) } + cfg.BlockchainWsMaxRetries, err = GetBlockchainWsMaxRetries() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_WS_MAX_RETRIES: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_WS_MAX_RETRIES is required for the evmreader service: %w", err) + } + + cfg.BlockchainWsReconnectInterval, err = GetBlockchainWsReconnectInterval() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_WS_RECONNECT_INTERVAL: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_WS_RECONNECT_INTERVAL is required for the evmreader service: %w", err) + } + cfg.MaxStartupTime, err = GetMaxStartupTime() if err != nil && err != ErrNotDefined { return nil, fmt.Errorf("failed to get CARTESI_MAX_STARTUP_TIME: %w", err) @@ -882,6 +908,12 @@ type NodeConfig struct { // Maximum time in seconds to wait for a new block header on the WebSocket subscription before treating the connection as stalled and reconnecting. Handles silent connection drops where no error is delivered. BlockchainWsLivenessTimeout Duration `mapstructure:"CARTESI_BLOCKCHAIN_WS_LIVENESS_TIMEOUT"` + // Maximum number of consecutive WebSocket subscription failures before the service gives up and exits. A failure is counted only when a subscription attempt produces zero headers before disconnecting. Successful header processing resets the counter. + BlockchainWsMaxRetries uint64 `mapstructure:"CARTESI_BLOCKCHAIN_WS_MAX_RETRIES"` + + // Wait time in seconds between WebSocket subscription reconnection attempts after a connection failure. + BlockchainWsReconnectInterval Duration `mapstructure:"CARTESI_BLOCKCHAIN_WS_RECONNECT_INTERVAL"` + // How many seconds the node will wait before querying the database for new claims. ClaimerPollingInterval Duration `mapstructure:"CARTESI_CLAIMER_POLLING_INTERVAL"` @@ -1082,6 +1114,20 @@ func LoadNodeConfig() (*NodeConfig, error) { return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_WS_LIVENESS_TIMEOUT is required for the node service: %w", err) } + cfg.BlockchainWsMaxRetries, err = GetBlockchainWsMaxRetries() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_WS_MAX_RETRIES: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_WS_MAX_RETRIES is required for the node service: %w", err) + } + + cfg.BlockchainWsReconnectInterval, err = GetBlockchainWsReconnectInterval() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_WS_RECONNECT_INTERVAL: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_WS_RECONNECT_INTERVAL is required for the node service: %w", err) + } + cfg.ClaimerPollingInterval, err = GetClaimerPollingInterval() if err != nil && err != ErrNotDefined { return nil, fmt.Errorf("failed to get CARTESI_CLAIMER_POLLING_INTERVAL: %w", err) @@ -1444,21 +1490,23 @@ func (c *NodeConfig) ToClaimerConfig() *ClaimerConfig { // ToEvmreaderConfig converts a NodeConfig to a EvmreaderConfig. func (c *NodeConfig) ToEvmreaderConfig() *EvmreaderConfig { return &EvmreaderConfig{ - BlockchainDefaultBlock: c.BlockchainDefaultBlock, - BlockchainHttpEndpoint: c.BlockchainHttpEndpoint, - BlockchainId: c.BlockchainId, - BlockchainWsEndpoint: c.BlockchainWsEndpoint, - DatabaseConnection: c.DatabaseConnection, - FeatureInputReaderEnabled: c.FeatureInputReaderEnabled, - TelemetryAddress: c.TelemetryAddress, - LogColor: c.LogColor, - LogLevel: c.LogLevel, - BlockchainHttpMaxRetries: c.BlockchainHttpMaxRetries, - BlockchainHttpRetryMaxWait: c.BlockchainHttpRetryMaxWait, - BlockchainHttpRetryMinWait: c.BlockchainHttpRetryMinWait, - BlockchainMaxBlockRange: c.BlockchainMaxBlockRange, - BlockchainWsLivenessTimeout: c.BlockchainWsLivenessTimeout, - MaxStartupTime: c.MaxStartupTime, + BlockchainDefaultBlock: c.BlockchainDefaultBlock, + BlockchainHttpEndpoint: c.BlockchainHttpEndpoint, + BlockchainId: c.BlockchainId, + BlockchainWsEndpoint: c.BlockchainWsEndpoint, + DatabaseConnection: c.DatabaseConnection, + FeatureInputReaderEnabled: c.FeatureInputReaderEnabled, + TelemetryAddress: c.TelemetryAddress, + LogColor: c.LogColor, + LogLevel: c.LogLevel, + BlockchainHttpMaxRetries: c.BlockchainHttpMaxRetries, + BlockchainHttpRetryMaxWait: c.BlockchainHttpRetryMaxWait, + BlockchainHttpRetryMinWait: c.BlockchainHttpRetryMinWait, + BlockchainMaxBlockRange: c.BlockchainMaxBlockRange, + BlockchainWsLivenessTimeout: c.BlockchainWsLivenessTimeout, + BlockchainWsMaxRetries: c.BlockchainWsMaxRetries, + BlockchainWsReconnectInterval: c.BlockchainWsReconnectInterval, + MaxStartupTime: c.MaxStartupTime, } } @@ -2128,6 +2176,32 @@ func GetBlockchainWsLivenessTimeout() (Duration, error) { return notDefinedDuration(), fmt.Errorf("%s: %w", BLOCKCHAIN_WS_LIVENESS_TIMEOUT, ErrNotDefined) } +// GetBlockchainWsMaxRetries returns the value for the environment variable CARTESI_BLOCKCHAIN_WS_MAX_RETRIES. +func GetBlockchainWsMaxRetries() (uint64, error) { + s := viper.GetString(BLOCKCHAIN_WS_MAX_RETRIES) + if s != "" { + v, err := toUint64(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", BLOCKCHAIN_WS_MAX_RETRIES, err) + } + return v, nil + } + return notDefineduint64(), fmt.Errorf("%s: %w", BLOCKCHAIN_WS_MAX_RETRIES, ErrNotDefined) +} + +// GetBlockchainWsReconnectInterval returns the value for the environment variable CARTESI_BLOCKCHAIN_WS_RECONNECT_INTERVAL. +func GetBlockchainWsReconnectInterval() (Duration, error) { + s := viper.GetString(BLOCKCHAIN_WS_RECONNECT_INTERVAL) + if s != "" { + v, err := toDuration(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", BLOCKCHAIN_WS_RECONNECT_INTERVAL, err) + } + return v, nil + } + return notDefinedDuration(), fmt.Errorf("%s: %w", BLOCKCHAIN_WS_RECONNECT_INTERVAL, ErrNotDefined) +} + // GetClaimerPollingInterval returns the value for the environment variable CARTESI_CLAIMER_POLLING_INTERVAL. func GetClaimerPollingInterval() (Duration, error) { s := viper.GetString(CLAIMER_POLLING_INTERVAL) diff --git a/internal/evmreader/evmreader.go b/internal/evmreader/evmreader.go index 262da38e8..a2893824d 100644 --- a/internal/evmreader/evmreader.go +++ b/internal/evmreader/evmreader.go @@ -64,6 +64,10 @@ func (e *SubscriptionError) Error() string { return fmt.Sprintf("Subscription error : %v", e.Cause) } +func (e *SubscriptionError) Unwrap() error { + return e.Cause +} + // Internal struct to hold application and it's contracts together type appContracts struct { application *Application @@ -73,26 +77,40 @@ type appContracts struct { } func (r *Service) Run(ctx context.Context, ready chan struct{}) error { - for attempt := uint64(1); ; attempt++ { - err := r.watchForNewBlocks(ctx, ready) - r.Logger.Error(err.Error()) + var consecutiveFailures uint64 + for { + headersProcessed, err := r.watchForNewBlocks(ctx, ready) + if ctx.Err() != nil { + return ctx.Err() + } + r.Logger.Error("watchForNewBlocks exited", + "error", err, "headers_processed", headersProcessed) + + // Only reset the retry counter if the connection actually processed + // at least one block header. This prevents infinite retries when the + // subscription connects but immediately fails before doing useful work. + if headersProcessed > 0 { + consecutiveFailures = 0 + } else { + consecutiveFailures++ + } - if attempt > r.blockchainMaxRetries { - r.Logger.Error("Max attempts reached for subscription restart. Exiting", + if consecutiveFailures > r.blockchainMaxRetries { + r.Logger.Error("Max consecutive failures reached. Exiting", + "consecutive_failures", consecutiveFailures, "max_retries", r.blockchainMaxRetries, ) return err } r.Logger.Info("Restarting subscription", - "attempt", attempt, - "remaining", r.blockchainMaxRetries-attempt, - "time_between_attempts", r.blockchainSubscriptionRetryInterval, + "consecutive_failures", consecutiveFailures, + "max_retries", r.blockchainMaxRetries, ) - // sleep or cancel select { case <-ctx.Done(): + return ctx.Err() case <-time.After(r.blockchainSubscriptionRetryInterval): } } @@ -111,33 +129,43 @@ func (r *Service) setApplicationInoperable(ctx context.Context, app *Application // watchForNewBlocks watches for new blocks and reads new inputs based on the // default block configuration, which have not been processed yet. -func (r *Service) watchForNewBlocks(ctx context.Context, ready chan<- struct{}) error { +// watchForNewBlocks subscribes to new block headers and processes them. +// Returns the number of headers processed and any error that caused it to stop. +func (r *Service) watchForNewBlocks(ctx context.Context, ready chan<- struct{}) (uint64, error) { headers := make(chan *types.Header) sub, err := r.wsClient.SubscribeNewHead(ctx, headers) if err != nil { - return fmt.Errorf("could not start subscription: %w", err) + return 0, fmt.Errorf("could not start subscription: %w", err) } r.Logger.Info("Subscribed to new block events") - ready <- struct{}{} + select { + case ready <- struct{}{}: + default: + } defer sub.Unsubscribe() liveness := time.NewTimer(r.wsLivenessTimeout) defer liveness.Stop() + var headersProcessed uint64 for { select { case <-ctx.Done(): - return ctx.Err() + return headersProcessed, ctx.Err() case err := <-sub.Err(): - return &SubscriptionError{Cause: err} + if err == nil { + err = errors.New("subscription closed unexpectedly") + } + return headersProcessed, &SubscriptionError{Cause: err} case <-liveness.C: - return &SubscriptionError{ + return headersProcessed, &SubscriptionError{ Cause: fmt.Errorf( "no new block header received for %s, assuming stalled connection", r.wsLivenessTimeout, ), } case header := <-headers: + headersProcessed++ liveness.Reset(r.wsLivenessTimeout) // Every time a new block arrives diff --git a/internal/evmreader/evmreader_test.go b/internal/evmreader/evmreader_test.go index c9df0d5b7..fa54a9ee2 100644 --- a/internal/evmreader/evmreader_test.go +++ b/internal/evmreader/evmreader_test.go @@ -196,12 +196,92 @@ func (s *EvmReaderSuite) TestItEventuallyBecomesReady() { func (s *EvmReaderSuite) TestItReturnsErrorWhenWebSocketStalls() { s.evmReader.wsLivenessTimeout = 50 * time.Millisecond ready := make(chan struct{}, 1) - err := s.evmReader.watchForNewBlocks(s.ctx, ready) + headersProcessed, err := s.evmReader.watchForNewBlocks(s.ctx, ready) + s.Require().Equal(uint64(0), headersProcessed) var subErr *SubscriptionError s.Require().ErrorAs(err, &subErr) s.Require().ErrorContains(err, "no new block header received") } +func (s *EvmReaderSuite) TestRunExhaustsRetriesOnConsecutiveConnectionFailures() { + s.evmReader.blockchainMaxRetries = 2 + s.evmReader.blockchainSubscriptionRetryInterval = time.Millisecond + + s.wsClient.Unset("SubscribeNewHead") + sub := &MockSubscription{} + s.wsClient.On("SubscribeNewHead", mock.Anything, mock.Anything). + Return(sub, fmt.Errorf("connection refused")) + + err := s.evmReader.Run(s.ctx, make(chan struct{}, 1)) + s.Require().ErrorContains(err, "connection refused") + // 1 initial + 2 retries = 3 calls + s.wsClient.AssertNumberOfCalls(s.T(), "SubscribeNewHead", 3) +} + +func (s *EvmReaderSuite) TestRunResetsRetriesAfterProcessingHeaders() { + s.evmReader.blockchainMaxRetries = 1 + s.evmReader.blockchainSubscriptionRetryInterval = time.Millisecond + s.evmReader.wsLivenessTimeout = 100 * time.Millisecond + + // First call: subscribe succeeds, deliver a header, then subscription error fires. + // → headersProcessed > 0, so consecutiveFailures resets to 0 + // Second call: subscribe fails (connection error) → consecutiveFailures=1 + // Third call: subscribe fails → consecutiveFailures=2 > maxRetries(1) → exit + subWithError := &MockSubscription{} + errCh := make(chan error, 1) + subWithError.On("Unsubscribe").Return() + subWithError.On("Err").Return((<-chan error)(errCh)) + + s.wsClient.Unset("SubscribeNewHead") + s.wsClient.On("SubscribeNewHead", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + ch := args.Get(1).(chan<- *types.Header) + // Deliver a header then trigger subscription error + go func() { + ch <- &header0 + errCh <- fmt.Errorf("connection lost") + }() + }). + Return(subWithError, nil).Once() + + emptySub := &MockSubscription{} + s.wsClient.On("SubscribeNewHead", mock.Anything, mock.Anything). + Return(emptySub, fmt.Errorf("connection refused")) + + err := s.evmReader.Run(s.ctx, make(chan struct{}, 1)) + s.Require().ErrorContains(err, "connection refused") + // 1 successful + 1 retry + 1 exhausted = 3 calls + s.wsClient.AssertNumberOfCalls(s.T(), "SubscribeNewHead", 3) +} + +func (s *EvmReaderSuite) TestRunDoesNotResetRetriesWithoutProcessingHeaders() { + s.evmReader.blockchainMaxRetries = 1 + s.evmReader.blockchainSubscriptionRetryInterval = time.Millisecond + s.evmReader.wsLivenessTimeout = time.Millisecond + + // Subscribe succeeds but no headers arrive before liveness timeout. + // headersProcessed=0, so consecutiveFailures increments (not reset). + // With maxRetries=1: first timeout → failures=1, second timeout → failures=2 > 1 → exit + err := s.evmReader.Run(s.ctx, make(chan struct{}, 1)) + s.Require().ErrorContains(err, "no new block header received") + s.wsClient.AssertNumberOfCalls(s.T(), "SubscribeNewHead", 2) +} + +func (s *EvmReaderSuite) TestRunStopsDuringRetryWhenContextCanceled() { + s.evmReader.blockchainMaxRetries = 100 + s.evmReader.blockchainSubscriptionRetryInterval = time.Second + + s.wsClient.Unset("SubscribeNewHead") + sub := &MockSubscription{} + ctx, cancel := context.WithCancel(s.ctx) + s.wsClient.On("SubscribeNewHead", mock.Anything, mock.Anything). + Run(func(_ mock.Arguments) { cancel() }). + Return(sub, fmt.Errorf("connection refused")) + + err := s.evmReader.Run(ctx, make(chan struct{}, 1)) + s.Require().ErrorIs(err, context.Canceled) +} + func (s *EvmReaderSuite) TestItFailsToSubscribeForNewInputsOnStart() { s.wsClient.Unset("ChainID") s.wsClient.Unset("SubscribeNewHead") @@ -365,6 +445,7 @@ func newMockSubscription() *MockSubscription { } func (m *MockSubscription) Unsubscribe() { + m.Called() } func (m *MockSubscription) Err() <-chan error { @@ -659,7 +740,11 @@ func (m *MockRepository) UpdateEpochClaimTransactionHash(ctx context.Context, na func (m *MockRepository) GetLastNonOpenEpoch(ctx context.Context, nameOrAddress string) (*Epoch, error) { args := m.Called(ctx, nameOrAddress) - return args.Get(0).(*Epoch), args.Error(1) + obj := args.Get(0) + if obj == nil { + return nil, args.Error(1) + } + return obj.(*Epoch), args.Error(1) } func (m *MockRepository) GetNumberOfInputs(ctx context.Context, nameOrAddress string) (uint64, error) { diff --git a/internal/evmreader/service.go b/internal/evmreader/service.go index 6d6b231c1..ef3b9de69 100644 --- a/internal/evmreader/service.go +++ b/internal/evmreader/service.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "math/big" + "sync/atomic" "time" "github.com/cartesi/rollups-node/internal/config" @@ -42,6 +43,8 @@ type Service struct { blockchainMaxRetries uint64 blockchainSubscriptionRetryInterval time.Duration wsLivenessTimeout time.Duration + alive atomic.Bool + ready atomic.Bool } const EvmReaderConfigKey = "evm-reader" @@ -103,8 +106,8 @@ func Create(ctx context.Context, c *CreateInfo) (*Service, error) { return nil, fmt.Errorf("NodeConfig chainId mismatch: network %d != config %d", chainId.Uint64(), nodeConfig.ChainID) } - s.blockchainMaxRetries = c.Config.BlockchainHttpMaxRetries - s.blockchainSubscriptionRetryInterval = c.Config.BlockchainHttpRetryMinWait + s.blockchainMaxRetries = c.Config.BlockchainWsMaxRetries + s.blockchainSubscriptionRetryInterval = c.Config.BlockchainWsReconnectInterval s.wsLivenessTimeout = c.Config.BlockchainWsLivenessTimeout s.client = c.EthClient @@ -126,11 +129,11 @@ func Create(ctx context.Context, c *CreateInfo) (*Service, error) { } func (s *Service) Alive() bool { - return true + return s.alive.Load() } func (s *Service) Ready() bool { - return true + return s.ready.Load() } func (s *Service) Reload() []error { @@ -146,10 +149,23 @@ func (s *Service) Tick() []error { } func (s *Service) Serve() error { + s.alive.Store(true) ready := make(chan struct{}, 1) go func() { - s.Run(s.Context, ready) - s.Service.Stop(false) + defer s.alive.Store(false) + defer s.ready.Store(false) + err := s.Run(s.Context, ready) + if err != nil && s.Context.Err() == nil { + s.Logger.Error("Run exited with error", "error", err) + } + s.Cancel() + }() + go func() { + select { + case <-ready: + s.ready.Store(true) + case <-s.Context.Done(): + } }() return s.Service.Serve() } From bb2ada1dbefac2a8f06fffbb35848a17bf1baa3c Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:45:56 -0300 Subject: [PATCH 04/14] fix(evmreader): cache contract adapters per app --- internal/evmreader/evmreader.go | 84 ++++++++++++++++++++++++---- internal/evmreader/evmreader_test.go | 45 ++++++++------- internal/evmreader/output_test.go | 20 +++---- internal/evmreader/util.go | 14 ----- 4 files changed, 104 insertions(+), 59 deletions(-) diff --git a/internal/evmreader/evmreader.go b/internal/evmreader/evmreader.go index a2893824d..a7124b3bd 100644 --- a/internal/evmreader/evmreader.go +++ b/internal/evmreader/evmreader.go @@ -11,6 +11,7 @@ import ( "time" "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" @@ -76,6 +77,18 @@ type appContracts struct { daveConsensus DaveConsensusAdapter } +// cachedAdapters stores contract adapters along with the configuration fields +// used to create them, enabling staleness detection when app config changes. +type cachedAdapters struct { + applicationContract ApplicationContractAdapter + inputSource InputSourceAdapter + daveConsensus DaveConsensusAdapter + consensusAddr common.Address + inputBoxAddr common.Address + isDaveConsensus bool + hasInputBoxDA bool +} + func (r *Service) Run(ctx context.Context, ready chan struct{}) error { var consecutiveFailures uint64 for { @@ -147,6 +160,7 @@ func (r *Service) watchForNewBlocks(ctx context.Context, ready chan<- struct{}) liveness := time.NewTimer(r.wsLivenessTimeout) defer liveness.Stop() + adapterCache := make(map[common.Address]cachedAdapters) var headersProcessed uint64 for { select { @@ -169,7 +183,8 @@ func (r *Service) watchForNewBlocks(ctx context.Context, ready chan<- struct{}) liveness.Reset(r.wsLivenessTimeout) // Every time a new block arrives - r.Logger.Debug("New block header received", "blockNumber", header.Number, "blockHash", header.Hash()) + r.Logger.Debug("New block header received", + "blockNumber", header.Number, "blockHash", header.Hash()) r.Logger.Debug("Retrieving enabled applications") runningApps, _, err := getAllRunningApplications(ctx, r.repository) @@ -193,22 +208,65 @@ func (r *Service) watchForNewBlocks(ctx context.Context, ready chan<- struct{}) } r.hasEnabledApps = true - // Build Contracts + // Evict cache entries for applications that are no longer enabled. + activeAddrs := make(map[common.Address]struct{}, len(runningApps)) + for _, app := range runningApps { + activeAddrs[app.IApplicationAddress] = struct{}{} + } + for addr := range adapterCache { + if _, active := activeAddrs[addr]; !active { + r.Logger.Debug("Evicting cached adapters for removed application", + "address", addr) + delete(adapterCache, addr) + } + } + + // Build Contracts (adapters are cached per application address) var apps []appContracts var daveConsensusApps []appContracts var iconsensusApps []appContracts for _, app := range runningApps { - applicationContract, inputSource, daveConsensus, err := r.adapterFactory.CreateAdapters(app, r.client) - - if err != nil { - r.Logger.Error("Error retrieving application contracts", "app", app, "error", err) - continue + addr := app.IApplicationAddress + cached, ok := adapterCache[addr] + if ok { + // Invalidate cache if the app's contract configuration changed. + if cached.consensusAddr != app.IConsensusAddress || + cached.inputBoxAddr != app.IInputBoxAddress || + cached.isDaveConsensus != app.IsDaveConsensus() || + cached.hasInputBoxDA != + app.HasDataAvailabilitySelector(DataAvailability_InputBox) { + r.Logger.Info( + "Application contract configuration changed, recreating adapters", + "application", app.Name, "address", addr) + delete(adapterCache, addr) + ok = false + } + } + if !ok { + appContract, inputSource, daveConsensus, err := + r.adapterFactory.CreateAdapters(app, r.client) + if err != nil { + r.Logger.Error("Error retrieving application contracts", + "app", app, "error", err) + continue + } + cached = cachedAdapters{ + applicationContract: appContract, + inputSource: inputSource, + daveConsensus: daveConsensus, + consensusAddr: app.IConsensusAddress, + inputBoxAddr: app.IInputBoxAddress, + isDaveConsensus: app.IsDaveConsensus(), + hasInputBoxDA: app.HasDataAvailabilitySelector( + DataAvailability_InputBox), + } + adapterCache[addr] = cached } aContracts := appContracts{ application: app, - applicationContract: applicationContract, - inputSource: inputSource, - daveConsensus: daveConsensus, + applicationContract: cached.applicationContract, + inputSource: cached.inputSource, + daveConsensus: cached.daveConsensus, } apps = append(apps, aContracts) @@ -238,8 +296,10 @@ func (r *Service) watchForNewBlocks(ctx context.Context, ready chan<- struct{}) } blockNumber = mostRecentHeader.Number.Uint64() - r.Logger.Debug(fmt.Sprintf("Using block %d and not %d because of commitment policy: %s", - mostRecentHeader.Number.Uint64(), header.Number.Uint64(), r.defaultBlock)) + r.Logger.Debug(fmt.Sprintf( + "Using block %d and not %d because of commitment policy: %s", + mostRecentHeader.Number.Uint64(), + header.Number.Uint64(), r.defaultBlock)) } r.checkForEpochsAndInputs(ctx, daveConsensusApps, blockNumber) diff --git a/internal/evmreader/evmreader_test.go b/internal/evmreader/evmreader_test.go index fa54a9ee2..a4b056e66 100644 --- a/internal/evmreader/evmreader_test.go +++ b/internal/evmreader/evmreader_test.go @@ -298,6 +298,20 @@ func (s *EvmReaderSuite) TestItFailsToSubscribeForNewInputsOnStart() { s.wsClient.AssertExpectations(s.T()) } +// indexApps indexes applications given a key extractor function. +// Only used in tests. +func indexApps[K comparable]( + keyExtractor func(appContracts) K, + apps []appContracts, +) map[K][]appContracts { + result := make(map[K][]appContracts) + for _, item := range apps { + key := keyExtractor(item) + result[key] = append(result[key], item) + } + return result +} + func (s *EvmReaderSuite) TestIndexApps() { s.Run("Ok", func() { @@ -854,31 +868,20 @@ func (m *MockAdapterFactory) SetupDefaultBehavior( inputBox1 *MockInputBox, ) *MockAdapterFactory { - // Set up a default behavior that always returns valid non-nil interfaces - m.On("CreateAdapters", - mock.Anything, - mock.Anything, - ).Return(appContract1, inputBox1, nil).Once() - m.On("CreateAdapters", - mock.Anything, - mock.Anything, - ).Return(appContract2, nil, nil).Once() + // Match by application address so adapters are returned correctly regardless of call count + // (adapter caching means CreateAdapters is only called once per app address) m.On("CreateAdapters", + mock.MatchedBy(func(app *Application) bool { + return app.IApplicationAddress == applications[0].IApplicationAddress + }), mock.Anything, - mock.Anything, - ).Return(appContract1, inputBox1, nil).Once() - m.On("CreateAdapters", - mock.Anything, - mock.Anything, - ).Return(appContract2, nil, nil).Once() + ).Return(appContract1, inputBox1, nil) m.On("CreateAdapters", + mock.MatchedBy(func(app *Application) bool { + return app.IApplicationAddress == applications[1].IApplicationAddress + }), mock.Anything, - mock.Anything, - ).Return(appContract1, inputBox1, nil).Once() - m.On("CreateAdapters", - mock.Anything, - mock.Anything, - ).Return(appContract2, nil, nil).Once() + ).Return(appContract2, nil, nil) return m } diff --git a/internal/evmreader/output_test.go b/internal/evmreader/output_test.go index d021f6ccb..f04929830 100644 --- a/internal/evmreader/output_test.go +++ b/internal/evmreader/output_test.go @@ -670,21 +670,17 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { ).Return(new(big.Int).SetUint64(0), nil).Times(4) s.contractFactory.On("CreateAdapters", + mock.MatchedBy(func(app *Application) bool { + return app.IApplicationAddress == applications[0].IApplicationAddress + }), mock.Anything, - mock.Anything, - ).Return(s.applicationContract1, s.inputBox, nil).Once() - s.contractFactory.On("CreateAdapters", - mock.Anything, - mock.Anything, - ).Return(s.applicationContract2, nil, nil).Once() + ).Return(s.applicationContract1, s.inputBox, nil) s.contractFactory.On("CreateAdapters", + mock.MatchedBy(func(app *Application) bool { + return app.IApplicationAddress == applications[1].IApplicationAddress + }), mock.Anything, - mock.Anything, - ).Return(s.applicationContract2, nil, nil).Once() - s.contractFactory.On("CreateAdapters", - mock.Anything, - mock.Anything, - ).Return(s.applicationContract2, nil, nil).Once() + ).Return(s.applicationContract2, nil, nil) } func (s *EvmReaderSuite) TestCheckOutputFailsWhenOutputMismatches() { diff --git a/internal/evmreader/util.go b/internal/evmreader/util.go index 3e6192f2b..4ae2f1bcf 100644 --- a/internal/evmreader/util.go +++ b/internal/evmreader/util.go @@ -50,17 +50,3 @@ func insertSorted[T any](compare func(a, b *T) int, slice []*T, item *T) []*T { compare) return slices.Insert(slice, i, item) } - -// Index applications given a key extractor function -func indexApps[K comparable]( - keyExtractor func(appContracts) K, - apps []appContracts, -) map[K][]appContracts { - - result := make(map[K][]appContracts) - for _, item := range apps { - key := keyExtractor(item) - result[key] = append(result[key], item) - } - return result -} From 2b8dd92a57dd7b5d370301ba7d086dae58978788 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:51:01 -0300 Subject: [PATCH 05/14] fix(evmreader): validate sealed epoch input count --- internal/evmreader/input.go | 17 ++- internal/evmreader/sealedepochs.go | 135 +++++++++++++++--- internal/evmreader/sealedepochs_test.go | 181 ++++++++++++++++++++++++ internal/evmreader/util.go | 12 +- 4 files changed, 318 insertions(+), 27 deletions(-) create mode 100644 internal/evmreader/sealedepochs_test.go diff --git a/internal/evmreader/input.go b/internal/evmreader/input.go index a6ee8ccd2..0b8fba271 100644 --- a/internal/evmreader/input.go +++ b/internal/evmreader/input.go @@ -317,7 +317,11 @@ func (r *Service) readAndStoreInputs( // Update LastInputCheckBlock for applications that were successfully scanned // but didn't have any inputs. - // (for apps with inputs, LastInputCheckBlock is already updated in CreateEpochsAndInputs) + // For apps WITH inputs, LastInputCheckBlock is already updated atomically inside + // CreateEpochsAndInputs (same DB transaction as the epoch/input inserts). + // This separate call for no-input apps is NOT in the same transaction. If the process + // crashes between the two, no-input apps will re-scan the block range on restart. + // This is benign: the re-scan finds no inputs and updates the checkpoint idempotently. // Only apps present in appInputsMap were successfully scanned. Apps that failed // to fetch are absent from the map and their checkpoint must NOT advance, // otherwise inputs in the failed block range would be permanently skipped. @@ -431,7 +435,16 @@ func (r *Service) fetchApplicationInputs( BlockNumber: event.Raw.BlockNumber, TransactionReference: event.Raw.TxHash, } - sortedInputs = insertSorted(sortByInputIndex, sortedInputs, input) + var duplicate bool + sortedInputs, duplicate = insertSorted(sortByInputIndex, sortedInputs, input) + if duplicate { + r.Logger.Warn("Duplicate input event detected, skipping", + "application", app.application.Name, + "index", input.Index, + "block", input.BlockNumber, + ) + continue + } } return nil } diff --git a/internal/evmreader/sealedepochs.go b/internal/evmreader/sealedepochs.go index 0e88a37d7..c15ddfef0 100644 --- a/internal/evmreader/sealedepochs.go +++ b/internal/evmreader/sealedepochs.go @@ -19,7 +19,7 @@ import ( func (r *Service) initializeNewApplicationSealedEpochSync( ctx context.Context, - app appContracts, + app *appContracts, mostRecentBlockNumber uint64, ) error { r.Logger.Info("Initializing application sealed epoch sync", @@ -114,7 +114,7 @@ func (r *Service) processApplicationSealedEpochs( ) error { // Find the starting block for epoch search if app.application.LastEpochCheckBlock == 0 { - err := r.initializeNewApplicationSealedEpochSync(ctx, app, mostRecentBlockNumber) + err := r.initializeNewApplicationSealedEpochSync(ctx, &app, mostRecentBlockNumber) if err != nil { r.Logger.Error("Failed to initialize application sealed epoch sync", "application", app.application.Name, @@ -326,28 +326,28 @@ func (r *Service) processSealedEpochEvent( epoch.Status = EpochStatus_Closed // Sealed epochs are closed } - // Fetch inputs for this epoch from the InputBox + // Fetch inputs for this epoch from the InputBox. + // Always search from epoch.FirstBlock (not lastInputCheckBlock+1) because with + // PRT's overlapping block boundaries (sealed epoch's LastBlock = next epoch's + // FirstBlock), inputs for this epoch may exist at the overlap block — added in + // a later transaction than the previous epoch's seal within the same block. + // Use InputIndexLowerBound as prevValue rather than the DB input count, since + // the open epoch may have already stored some of these inputs ahead of us. var inputs []*Input if epoch.InputIndexUpperBound > epoch.InputIndexLowerBound { var err error - lastInputCheckBlock, err := r.repository.GetEventLastCheckBlock(ctx, app.application.ID, MonitoredEvent_InputAdded) + inputs, err = r.fetchSealedEpochInputs(ctx, app, epoch) if err != nil { - return fmt.Errorf("failed to get last input check block: %w", err) - } - - nextSearchBlock := lastInputCheckBlock + 1 - if lastInputCheckBlock == 0 { // First time fetching inputs for this application - nextSearchBlock = epoch.FirstBlock - } - if nextSearchBlock < epoch.FirstBlock || nextSearchBlock > epoch.LastBlock { - return fmt.Errorf("invalid next search block %d for inputs in epoch %d (first block: %d, last block: %d)", - nextSearchBlock, epoch.Index, epoch.FirstBlock, epoch.LastBlock) + return fmt.Errorf("failed to fetch inputs for epoch %d: %w", epoch.Index, err) } - inputs, err = r.fetchInputsForEpoch(ctx, app, epoch.Index, nextSearchBlock, epoch.LastBlock, - epoch.InputIndexLowerBound, epoch.InputIndexUpperBound) - if err != nil { - return fmt.Errorf("failed to fetch inputs for epoch %d: %w", epoch.Index, err) + expectedCount := epoch.InputIndexUpperBound - epoch.InputIndexLowerBound + if uint64(len(inputs)) != expectedCount { + return fmt.Errorf( + "epoch %d input count mismatch: expected %d (indices %d to %d), got %d", + epoch.Index, expectedCount, + epoch.InputIndexLowerBound, epoch.InputIndexUpperBound, + len(inputs)) } } // Store epoch and inputs @@ -374,6 +374,92 @@ func (r *Service) processSealedEpochEvent( return nil } +// fetchSealedEpochInputs fetches inputs for a sealed epoch using the epoch's own +// block range and contract-authoritative input bounds. Unlike the open epoch path, +// it does not rely on the DB's LastInputCheckBlock or GetNumberOfInputs, because: +// - With PRT's overlapping block boundaries, the overlap block may contain inputs +// for this epoch that were added after the previous epoch was sealed. +// - The open epoch may have already stored some inputs, making the DB count higher +// than what FindTransitions expects as prevValue. +func (r *Service) fetchSealedEpochInputs( + ctx context.Context, + app appContracts, + epoch *Epoch, +) ([]*Input, error) { + r.Logger.Debug("Fetching inputs for sealed epoch", + "application", app.application.Name, + "epoch_index", epoch.Index, + "input_lower_bound", epoch.InputIndexLowerBound, + "input_upper_bound", epoch.InputIndexUpperBound, + "first_block", epoch.FirstBlock, + "last_block", epoch.LastBlock, + ) + + oracle := func(ctx context.Context, block uint64) (*big.Int, error) { + callOpts := &bind.CallOpts{ + Context: ctx, + BlockNumber: new(big.Int).SetUint64(block), + } + numInputs, err := app.inputSource.GetNumberOfInputs(callOpts, app.application.IApplicationAddress) + if err != nil { + return nil, fmt.Errorf("failed to get number of inputs at block %d: %w", block, err) + } + return numInputs, nil + } + + var sortedInputs []*Input + onHit := func(block uint64) error { + filterOpts := &bind.FilterOpts{ + Context: ctx, + Start: block, + End: &block, + } + inputEvents, err := app.inputSource.RetrieveInputs( + filterOpts, + []common.Address{app.application.IApplicationAddress}, + nil, + ) + if err != nil { + return fmt.Errorf("failed to retrieve inputs at block %d: %w", block, err) + } + for _, event := range inputEvents { + if event.Index.Uint64() >= epoch.InputIndexLowerBound && + event.Index.Uint64() < epoch.InputIndexUpperBound { + input := &Input{ + Index: event.Index.Uint64(), + Status: InputCompletionStatus_None, + RawData: event.Input, + BlockNumber: event.Raw.BlockNumber, + TransactionReference: event.Raw.TxHash, + } + var duplicate bool + sortedInputs, duplicate = insertSorted(sortByInputIndex, sortedInputs, input) + if duplicate { + r.Logger.Warn("Duplicate input event detected, skipping", + "application", app.application.Name, + "index", input.Index, + "block", input.BlockNumber, + ) + } + } + } + return nil + } + + prevValue := new(big.Int).SetUint64(epoch.InputIndexLowerBound) + _, err := ethutil.FindTransitions(ctx, epoch.FirstBlock, epoch.LastBlock, prevValue, oracle, onHit) + if err != nil { + return nil, fmt.Errorf("failed to walk input transitions: %w", err) + } + + r.Logger.Debug("Fetched inputs for sealed epoch", + "application", app.application.Name, + "epoch_index", epoch.Index, + "input_count", len(sortedInputs), + ) + return sortedInputs, nil +} + func (r *Service) fetchInputsForEpoch( ctx context.Context, app appContracts, @@ -428,7 +514,16 @@ func (r *Service) fetchInputsForEpoch( BlockNumber: event.Raw.BlockNumber, TransactionReference: event.Raw.TxHash, } - sortedInputs = insertSorted(sortByInputIndex, sortedInputs, input) + var duplicate bool + sortedInputs, duplicate = insertSorted(sortByInputIndex, sortedInputs, input) + if duplicate { + r.Logger.Warn("Duplicate input event detected, skipping", + "application", app.application.Name, + "index", input.Index, + "block", input.BlockNumber, + ) + continue + } } } return nil @@ -525,7 +620,7 @@ func (r *Service) processApplicationOpenEpoch( return fmt.Errorf("failed to store epoch and inputs: %w", err) } - r.Logger.Debug("Stored sealed epoch and inputs", + r.Logger.Debug("Stored open epoch and inputs", "application", app.application.Name, "epoch_number", nextEpochNumber, "num_inputs", len(inputs), diff --git a/internal/evmreader/sealedepochs_test.go b/internal/evmreader/sealedepochs_test.go new file mode 100644 index 000000000..8c94e9a7b --- /dev/null +++ b/internal/evmreader/sealedepochs_test.go @@ -0,0 +1,181 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/config" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +type SealedEpochsSuite struct { + suite.Suite + ctx context.Context + cancel context.CancelFunc + repository *MockRepository + inputBox *MockInputBox + dave *MockDaveConsensus + evmReader *Service +} + +func TestSealedEpochsSuite(t *testing.T) { + suite.Run(t, new(SealedEpochsSuite)) +} + +func (s *SealedEpochsSuite) SetupSuite() { + s.ctx, s.cancel = context.WithCancel(context.Background()) + config.SetDefaults() +} + +func (s *SealedEpochsSuite) TearDownSuite() { + s.cancel() +} + +func (s *SealedEpochsSuite) SetupTest() { + s.repository = newMockRepository() + s.inputBox = newMockInputBox() + s.dave = newMockDaveConsensus() + + s.evmReader = &Service{ + repository: s.repository, + defaultBlock: DefaultBlock_Latest, + hasEnabledApps: true, + } + + logLevel, err := config.GetLogLevel() + s.Require().NoError(err) + serviceArgs := &service.CreateInfo{Name: "evm-reader", Impl: s.evmReader, LogLevel: logLevel} + err = service.Create(context.Background(), serviceArgs, &s.evmReader.Service) + s.Require().NoError(err) +} + +// TestProcessSealedEpochFindsInputAtOverlapBlock verifies that when an input +// is added at the same block where the previous epoch was sealed (the overlap +// block in PRT's block boundary design), the sealed epoch processing correctly +// finds it. Without the fix, the search would start from lastInputCheckBlock+1, +// skipping the overlap block entirely. +func (s *SealedEpochsSuite) TestProcessSealedEpochFindsInputAtOverlapBlock() { + const ( + sealBlock0 uint64 = 100 // Epoch 0 sealed here + sealBlock1 uint64 = 200 // Epoch 1 sealed here + ) + + tournamentAddr := common.HexToAddress("0xAAAA") + + app := appContracts{ + application: &Application{ + ID: 1, + Name: "test-app", + IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 10, + DataAvailability: DataAvailability_InputBox[:], + }, + inputSource: s.inputBox, + daveConsensus: s.dave, + } + + // Epoch 0 was already stored with LastBlock=100 and InputIndexUpperBound=3. + // CreateEpochsAndInputs set LastInputCheckBlock=100 for this app. + // + // Now epoch 1 is sealed at block 200: + // FirstBlock = prevEpoch.LastBlock = 100 (PRT overlap) + // InputIndexLowerBound = 3, InputIndexUpperBound = 4 + // + // Input index 3 was added at block 100 (same block as epoch 0 seal, later tx). + // The search must start from epoch.FirstBlock=100 (not lastInputCheckBlock+1=101) + // to find this input. + + sealedEvent := &idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: big.NewInt(1), + InputIndexLowerBound: big.NewInt(3), + InputIndexUpperBound: big.NewInt(4), + Tournament: tournamentAddr, + Raw: types.Log{ + BlockNumber: sealBlock1, + TxHash: common.BigToHash(big.NewInt(999)), + }, + } + + // Epoch 0 exists (the previous epoch) — needed to compute FirstBlock. + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)). + Return(&Epoch{ + Index: 0, + FirstBlock: 10, + LastBlock: sealBlock0, + InputIndexLowerBound: 0, + InputIndexUpperBound: 3, + }, nil) + s.repository.On("UpdateEpochClaimTransactionHash", mock.Anything, mock.Anything, mock.Anything). + Return(nil) + + // Epoch 1 does not exist yet (first time seeing it). + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(1)). + Return(nil, nil) + + // On-chain: 3 inputs at block 99, 4 inputs from block 100 onward + // (input 3 was added at block 100, same block as epoch 0 seal). + s.inputBox.Unset("GetNumberOfInputs") + s.inputBox.On("GetNumberOfInputs", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() < sealBlock0 + }), + mock.Anything, + ).Return(big.NewInt(3), nil) + s.inputBox.On("GetNumberOfInputs", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= sealBlock0 + }), + mock.Anything, + ).Return(big.NewInt(4), nil) + + // Input 3 is at block 100 (the overlap block). + overlapInput := makeInputEvent(app1Addr, 3, sealBlock0) + s.inputBox.Unset("RetrieveInputs") + s.inputBox.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == sealBlock0 + }), + mock.Anything, + mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{overlapInput}, nil) + + // No inputs at other blocks in the range. + s.inputBox.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start != sealBlock0 + }), + mock.Anything, + mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{}, nil) + + // CreateEpochsAndInputs captures what was stored. + var storedInputs []*Input + s.repository.On("CreateEpochsAndInputs", + mock.Anything, mock.Anything, mock.Anything, mock.Anything, + ).Run(func(args mock.Arguments) { + epochInputMap := args.Get(2).(map[*Epoch][]*Input) + for _, inputs := range epochInputMap { + storedInputs = inputs + } + }).Return(nil) + + err := s.evmReader.processSealedEpochEvent(s.ctx, app, sealedEvent) + s.Require().NoError(err, "processSealedEpochEvent should succeed when input is at the overlap block") + s.Require().Len(storedInputs, 1, "should find exactly one input") + s.Require().Equal(uint64(3), storedInputs[0].Index, "input should have index 3") + s.Require().Equal(sealBlock0, storedInputs[0].BlockNumber, "input should be at the overlap block") +} diff --git a/internal/evmreader/util.go b/internal/evmreader/util.go index 4ae2f1bcf..9a1cd1559 100644 --- a/internal/evmreader/util.go +++ b/internal/evmreader/util.go @@ -41,12 +41,14 @@ func sortByInputIndex(a, b *Input) int { } // insertSorted inserts the received input in the slice at the position defined -// by its index property. -func insertSorted[T any](compare func(a, b *T) int, slice []*T, item *T) []*T { - // Insert Sorted - i, _ := slices.BinarySearchFunc( +// by its index property. Returns the updated slice and true if the item was a duplicate. +func insertSorted[T any](compare func(a, b *T) int, slice []*T, item *T) ([]*T, bool) { + i, found := slices.BinarySearchFunc( slice, item, compare) - return slices.Insert(slice, i, item) + if found { + return slice, true + } + return slices.Insert(slice, i, item), false } From 90bb3a7c33f476c913b8b8af8368cc172eceabf8 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:06:36 -0300 Subject: [PATCH 06/14] refactor(evmreader): store concrete ethclient in adapter factory and remove runtime type assertion --- internal/evmreader/evmreader.go | 36 +++++++++------------------- internal/evmreader/evmreader_test.go | 6 +---- internal/evmreader/input_test.go | 1 - internal/evmreader/output_test.go | 2 -- internal/evmreader/service.go | 4 +++- 5 files changed, 15 insertions(+), 34 deletions(-) diff --git a/internal/evmreader/evmreader.go b/internal/evmreader/evmreader.go index a7124b3bd..2ab347aa8 100644 --- a/internal/evmreader/evmreader.go +++ b/internal/evmreader/evmreader.go @@ -244,7 +244,7 @@ func (r *Service) watchForNewBlocks(ctx context.Context, ready chan<- struct{}) } if !ok { appContract, inputSource, daveConsensus, err := - r.adapterFactory.CreateAdapters(app, r.client) + r.adapterFactory.CreateAdapters(app) if err != nil { r.Logger.Error("Error retrieving application contracts", "app", app, "error", err) @@ -348,51 +348,37 @@ func (r *Service) fetchMostRecentHeader( } type AdapterFactory interface { - CreateAdapters(app *Application, client EthClientInterface) (ApplicationContractAdapter, InputSourceAdapter, DaveConsensusAdapter, error) + CreateAdapters(app *Application) (ApplicationContractAdapter, InputSourceAdapter, DaveConsensusAdapter, error) } type DefaultAdapterFactory struct { + Client *ethclient.Client Filter ethutil.Filter } -func (f *DefaultAdapterFactory) CreateAdapters(app *Application, client EthClientInterface) (ApplicationContractAdapter, InputSourceAdapter, DaveConsensusAdapter, error) { +func (f *DefaultAdapterFactory) CreateAdapters(app *Application) (ApplicationContractAdapter, InputSourceAdapter, DaveConsensusAdapter, error) { if app == nil { - return nil, nil, nil, fmt.Errorf("Application reference is nil. Should never happen") + return nil, nil, nil, fmt.Errorf("application reference is nil, should never happen") } - // Type assertion to get the concrete client if possible - ethClient, ok := client.(*ethclient.Client) - if !ok { - return nil, nil, nil, fmt.Errorf("client is not an *ethclient.Client, cannot create adapters") - } - - applicationContract, err := NewApplicationContractAdapter(app.IApplicationAddress, ethClient, f.Filter) + applicationContract, err := NewApplicationContractAdapter(app.IApplicationAddress, f.Client, f.Filter) if err != nil { - return nil, nil, nil, errors.Join( - fmt.Errorf("error building application contract"), - err, - ) + return nil, nil, nil, fmt.Errorf("error building application contract: %w", err) } var inputSource InputSourceAdapter if app.HasDataAvailabilitySelector(DataAvailability_InputBox) { - inputSource, err = NewInputSourceAdapter(app.IInputBoxAddress, ethClient, f.Filter) + inputSource, err = NewInputSourceAdapter(app.IInputBoxAddress, f.Client, f.Filter) if err != nil { - return nil, nil, nil, errors.Join( - fmt.Errorf("error building inputbox contract"), - err, - ) + return nil, nil, nil, fmt.Errorf("error building inputbox contract: %w", err) } } var daveConsensus DaveConsensusAdapter if app.IsDaveConsensus() { - daveConsensus, err = NewDaveConsensusAdapter(app.IConsensusAddress, ethClient, f.Filter) + daveConsensus, err = NewDaveConsensusAdapter(app.IConsensusAddress, f.Client, f.Filter) if err != nil { - return nil, nil, nil, errors.Join( - fmt.Errorf("error building daveconsensus contract"), - err, - ) + return nil, nil, nil, fmt.Errorf("error building daveconsensus contract: %w", err) } } diff --git a/internal/evmreader/evmreader_test.go b/internal/evmreader/evmreader_test.go index a4b056e66..9af233777 100644 --- a/internal/evmreader/evmreader_test.go +++ b/internal/evmreader/evmreader_test.go @@ -842,9 +842,8 @@ func (m *MockAdapterFactory) Unset(methodName string) { func (m *MockAdapterFactory) CreateAdapters( app *Application, - client EthClientInterface, ) (ApplicationContractAdapter, InputSourceAdapter, DaveConsensusAdapter, error) { - args := m.Called(app, client) + args := m.Called(app) // Safely handle nil values to prevent interface conversion panic appContract, _ := args.Get(0).(ApplicationContractAdapter) @@ -874,13 +873,11 @@ func (m *MockAdapterFactory) SetupDefaultBehavior( mock.MatchedBy(func(app *Application) bool { return app.IApplicationAddress == applications[0].IApplicationAddress }), - mock.Anything, ).Return(appContract1, inputBox1, nil) m.On("CreateAdapters", mock.MatchedBy(func(app *Application) bool { return app.IApplicationAddress == applications[1].IApplicationAddress }), - mock.Anything, ).Return(appContract2, nil, nil) return m } @@ -891,7 +888,6 @@ func (m *MockAdapterFactory) SetupDefaultBehaviorSingleApp( // Set up a default behavior that always returns valid non-nil interfaces m.On("CreateAdapters", mock.Anything, - mock.Anything, ).Return(appContract, inputBox, nil) return m } diff --git a/internal/evmreader/input_test.go b/internal/evmreader/input_test.go index 4a0dfc10d..90f44351b 100644 --- a/internal/evmreader/input_test.go +++ b/internal/evmreader/input_test.go @@ -361,7 +361,6 @@ func (s *EvmReaderSuite) TestItStartsWhenLastProcessedBlockIsTheMostRecentBlock( s.contractFactory.Unset("CreateAdapters") s.contractFactory.On("CreateAdapters", mock.Anything, - mock.Anything, ).Return(s.applicationContract1, s.inputBox, nil).Once() // Start service diff --git a/internal/evmreader/output_test.go b/internal/evmreader/output_test.go index f04929830..e7e6ce5c1 100644 --- a/internal/evmreader/output_test.go +++ b/internal/evmreader/output_test.go @@ -673,13 +673,11 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { mock.MatchedBy(func(app *Application) bool { return app.IApplicationAddress == applications[0].IApplicationAddress }), - mock.Anything, ).Return(s.applicationContract1, s.inputBox, nil) s.contractFactory.On("CreateAdapters", mock.MatchedBy(func(app *Application) bool { return app.IApplicationAddress == applications[1].IApplicationAddress }), - mock.Anything, ).Return(s.applicationContract2, nil, nil) } diff --git a/internal/evmreader/service.go b/internal/evmreader/service.go index ef3b9de69..de4605895 100644 --- a/internal/evmreader/service.go +++ b/internal/evmreader/service.go @@ -16,6 +16,7 @@ import ( "github.com/cartesi/rollups-node/internal/repository" "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/cartesi/rollups-node/pkg/service" + "github.com/ethereum/go-ethereum/ethclient" ) type CreateInfo struct { @@ -25,7 +26,7 @@ type CreateInfo struct { Repository repository.Repository - EthClient EthClientInterface + EthClient *ethclient.Client EthWsClient EthClientInterface } @@ -118,6 +119,7 @@ func Create(ctx context.Context, c *CreateInfo) (*Service, error) { s.inputReaderEnabled = nodeConfig.InputReaderEnabled s.hasEnabledApps = true s.adapterFactory = &DefaultAdapterFactory{ + Client: c.EthClient, Filter: ethutil.Filter{ MinChunkSize: ethutil.DefaultMinChunkSize, MaxChunkSize: new(big.Int).SetUint64(c.Config.BlockchainMaxBlockRange), From 95cdd78ace96af462dbbb3e65801298fb84475d3 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:19:41 -0300 Subject: [PATCH 07/14] fix(evmreader): resolve liveness timer race with simultaneous header arrival --- internal/config/generate/Config.toml | 2 +- internal/config/generated.go | 4 +- internal/evmreader/evmreader.go | 243 ++++++++++++++------------- internal/evmreader/util.go | 2 +- 4 files changed, 130 insertions(+), 121 deletions(-) diff --git a/internal/config/generate/Config.toml b/internal/config/generate/Config.toml index 26154f8f6..d96604350 100644 --- a/internal/config/generate/Config.toml +++ b/internal/config/generate/Config.toml @@ -231,7 +231,7 @@ used-by = ["evmreader", "claimer", "node", "prt"] default = "120" go-type = "Duration" description = """ -Maximum time in seconds to wait for a new block header on the WebSocket subscription before treating the connection as stalled and reconnecting. Handles silent connection drops where no error is delivered.""" +Maximum time in seconds to wait for a new block header on the WebSocket subscription before treating the connection as stalled and reconnecting. Handles silent connection drops where no error is delivered. The default (120s) is tuned for mainnet (~12s block time). Reduce for faster chains or devnets.""" used-by = ["evmreader", "node"] [rollups.CARTESI_BLOCKCHAIN_WS_MAX_RETRIES] diff --git a/internal/config/generated.go b/internal/config/generated.go index 8770e483a..bcc63f00f 100644 --- a/internal/config/generated.go +++ b/internal/config/generated.go @@ -580,7 +580,7 @@ type EvmreaderConfig struct { // Maximum number of blocks in a single query to the provider. Queries with larger ranges will be broken into multiple smaller queries. Zero for unlimited. BlockchainMaxBlockRange uint64 `mapstructure:"CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE"` - // Maximum time in seconds to wait for a new block header on the WebSocket subscription before treating the connection as stalled and reconnecting. Handles silent connection drops where no error is delivered. + // Maximum time in seconds to wait for a new block header on the WebSocket subscription before treating the connection as stalled and reconnecting. Handles silent connection drops where no error is delivered. The default (120s) is tuned for mainnet (~12s block time). Reduce for faster chains or devnets. BlockchainWsLivenessTimeout Duration `mapstructure:"CARTESI_BLOCKCHAIN_WS_LIVENESS_TIMEOUT"` // Maximum number of consecutive WebSocket subscription failures before the service gives up and exits. A failure is counted only when a subscription attempt produces zero headers before disconnecting. Successful header processing resets the counter. @@ -905,7 +905,7 @@ type NodeConfig struct { // Maximum number of blocks in a single query to the provider. Queries with larger ranges will be broken into multiple smaller queries. Zero for unlimited. BlockchainMaxBlockRange uint64 `mapstructure:"CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE"` - // Maximum time in seconds to wait for a new block header on the WebSocket subscription before treating the connection as stalled and reconnecting. Handles silent connection drops where no error is delivered. + // Maximum time in seconds to wait for a new block header on the WebSocket subscription before treating the connection as stalled and reconnecting. Handles silent connection drops where no error is delivered. The default (120s) is tuned for mainnet (~12s block time). Reduce for faster chains or devnets. BlockchainWsLivenessTimeout Duration `mapstructure:"CARTESI_BLOCKCHAIN_WS_LIVENESS_TIMEOUT"` // Maximum number of consecutive WebSocket subscription failures before the service gives up and exits. A failure is counted only when a subscription attempt produces zero headers before disconnecting. Successful header processing resets the counter. diff --git a/internal/evmreader/evmreader.go b/internal/evmreader/evmreader.go index 2ab347aa8..6b4815ce2 100644 --- a/internal/evmreader/evmreader.go +++ b/internal/evmreader/evmreader.go @@ -140,8 +140,6 @@ func (r *Service) setApplicationInoperable(ctx context.Context, app *Application return appstatus.SetInoperablef(ctx, r.Logger, r.repository, app, reasonFmt, args...) } -// watchForNewBlocks watches for new blocks and reads new inputs based on the -// default block configuration, which have not been processed yet. // watchForNewBlocks subscribes to new block headers and processes them. // Returns the number of headers processed and any error that caused it to stop. func (r *Service) watchForNewBlocks(ctx context.Context, ready chan<- struct{}) (uint64, error) { @@ -163,6 +161,7 @@ func (r *Service) watchForNewBlocks(ctx context.Context, ready chan<- struct{}) adapterCache := make(map[common.Address]cachedAdapters) var headersProcessed uint64 for { + var header *types.Header select { case <-ctx.Done(): return headersProcessed, ctx.Err() @@ -172,143 +171,153 @@ func (r *Service) watchForNewBlocks(ctx context.Context, ready chan<- struct{}) } return headersProcessed, &SubscriptionError{Cause: err} case <-liveness.C: - return headersProcessed, &SubscriptionError{ - Cause: fmt.Errorf( - "no new block header received for %s, assuming stalled connection", - r.wsLivenessTimeout, - ), + // Before declaring stalled, check if a header arrived simultaneously. + // Go's select picks randomly when multiple cases are ready, so the + // liveness timer may win even though a header is available. + select { + case header = <-headers: + default: + return headersProcessed, &SubscriptionError{ + Cause: fmt.Errorf( + "no new block header received for %s, assuming stalled connection", + r.wsLivenessTimeout, + ), + } } - case header := <-headers: - headersProcessed++ - liveness.Reset(r.wsLivenessTimeout) + case header = <-headers: + } - // Every time a new block arrives - r.Logger.Debug("New block header received", - "blockNumber", header.Number, "blockHash", header.Hash()) + if header == nil { + continue + } + headersProcessed++ + liveness.Reset(r.wsLivenessTimeout) - r.Logger.Debug("Retrieving enabled applications") - runningApps, _, err := getAllRunningApplications(ctx, r.repository) - if err != nil { - r.Logger.Error("Error retrieving running applications", - "error", - err, - ) - continue - } + // Every time a new block arrives + r.Logger.Debug("New block header received", + "blockNumber", header.Number, "blockHash", header.Hash()) - if len(runningApps) == 0 { - if r.hasEnabledApps { - r.Logger.Info("No registered applications enabled") - } - r.hasEnabledApps = false - continue - } - if !r.hasEnabledApps { - r.Logger.Info("Found enabled applications") + r.Logger.Debug("Retrieving enabled applications") + runningApps, _, err := getAllRunningApplications(ctx, r.repository) + if err != nil { + r.Logger.Error("Error retrieving running applications", + "error", + err, + ) + continue + } + + if len(runningApps) == 0 { + if r.hasEnabledApps { + r.Logger.Info("No registered applications enabled") } - r.hasEnabledApps = true + r.hasEnabledApps = false + continue + } + if !r.hasEnabledApps { + r.Logger.Info("Found enabled applications") + } + r.hasEnabledApps = true - // Evict cache entries for applications that are no longer enabled. - activeAddrs := make(map[common.Address]struct{}, len(runningApps)) - for _, app := range runningApps { - activeAddrs[app.IApplicationAddress] = struct{}{} + // Evict cache entries for applications that are no longer enabled. + activeAddrs := make(map[common.Address]struct{}, len(runningApps)) + for _, app := range runningApps { + activeAddrs[app.IApplicationAddress] = struct{}{} + } + for addr := range adapterCache { + if _, active := activeAddrs[addr]; !active { + r.Logger.Debug("Evicting cached adapters for removed application", + "address", addr) + delete(adapterCache, addr) } - for addr := range adapterCache { - if _, active := activeAddrs[addr]; !active { - r.Logger.Debug("Evicting cached adapters for removed application", - "address", addr) + } + + // Build Contracts (adapters are cached per application address) + var apps []appContracts + var daveConsensusApps []appContracts + var iconsensusApps []appContracts + for _, app := range runningApps { + addr := app.IApplicationAddress + cached, cacheHit := adapterCache[addr] + if cacheHit { + // Invalidate cache if the app's contract configuration changed. + if cached.consensusAddr != app.IConsensusAddress || + cached.inputBoxAddr != app.IInputBoxAddress || + cached.isDaveConsensus != app.IsDaveConsensus() || + cached.hasInputBoxDA != + app.HasDataAvailabilitySelector(DataAvailability_InputBox) { + r.Logger.Info( + "Application contract configuration changed, recreating adapters", + "application", app.Name, "address", addr) delete(adapterCache, addr) + cacheHit = false } } - - // Build Contracts (adapters are cached per application address) - var apps []appContracts - var daveConsensusApps []appContracts - var iconsensusApps []appContracts - for _, app := range runningApps { - addr := app.IApplicationAddress - cached, ok := adapterCache[addr] - if ok { - // Invalidate cache if the app's contract configuration changed. - if cached.consensusAddr != app.IConsensusAddress || - cached.inputBoxAddr != app.IInputBoxAddress || - cached.isDaveConsensus != app.IsDaveConsensus() || - cached.hasInputBoxDA != - app.HasDataAvailabilitySelector(DataAvailability_InputBox) { - r.Logger.Info( - "Application contract configuration changed, recreating adapters", - "application", app.Name, "address", addr) - delete(adapterCache, addr) - ok = false - } - } - if !ok { - appContract, inputSource, daveConsensus, err := - r.adapterFactory.CreateAdapters(app) - if err != nil { - r.Logger.Error("Error retrieving application contracts", - "app", app, "error", err) - continue - } - cached = cachedAdapters{ - applicationContract: appContract, - inputSource: inputSource, - daveConsensus: daveConsensus, - consensusAddr: app.IConsensusAddress, - inputBoxAddr: app.IInputBoxAddress, - isDaveConsensus: app.IsDaveConsensus(), - hasInputBoxDA: app.HasDataAvailabilitySelector( - DataAvailability_InputBox), - } - adapterCache[addr] = cached + if !cacheHit { + appContract, inputSource, daveConsensus, err := + r.adapterFactory.CreateAdapters(app) + if err != nil { + r.Logger.Error("Error retrieving application contracts", + "app", app, "error", err) + continue } - aContracts := appContracts{ - application: app, - applicationContract: cached.applicationContract, - inputSource: cached.inputSource, - daveConsensus: cached.daveConsensus, - } - - apps = append(apps, aContracts) - if app.IsDaveConsensus() { - daveConsensusApps = append(daveConsensusApps, aContracts) - } else { - iconsensusApps = append(iconsensusApps, aContracts) + cached = cachedAdapters{ + applicationContract: appContract, + inputSource: inputSource, + daveConsensus: daveConsensus, + consensusAddr: app.IConsensusAddress, + inputBoxAddr: app.IInputBoxAddress, + isDaveConsensus: app.IsDaveConsensus(), + hasInputBoxDA: app.HasDataAvailabilitySelector( + DataAvailability_InputBox), } + adapterCache[addr] = cached + } + aContracts := appContracts{ + application: app, + applicationContract: cached.applicationContract, + inputSource: cached.inputSource, + daveConsensus: cached.daveConsensus, } - if len(apps) == 0 { - r.Logger.Info("No correctly configured applications running") - continue + apps = append(apps, aContracts) + if app.IsDaveConsensus() { + daveConsensusApps = append(daveConsensusApps, aContracts) + } else { + iconsensusApps = append(iconsensusApps, aContracts) } + } - blockNumber := header.Number.Uint64() - if r.defaultBlock != DefaultBlock_Latest { - mostRecentHeader, err := r.fetchMostRecentHeader( - ctx, - r.defaultBlock, - ) - if err != nil { - r.Logger.Error("Error fetching most recent block", - "default block", r.defaultBlock, - "error", err) - continue - } - blockNumber = mostRecentHeader.Number.Uint64() + if len(apps) == 0 { + r.Logger.Info("No correctly configured applications running") + continue + } - r.Logger.Debug(fmt.Sprintf( - "Using block %d and not %d because of commitment policy: %s", - mostRecentHeader.Number.Uint64(), - header.Number.Uint64(), r.defaultBlock)) + blockNumber := header.Number.Uint64() + if r.defaultBlock != DefaultBlock_Latest { + mostRecentHeader, err := r.fetchMostRecentHeader( + ctx, + r.defaultBlock, + ) + if err != nil { + r.Logger.Error("Error fetching most recent block", + "default block", r.defaultBlock, + "error", err) + continue } + blockNumber = mostRecentHeader.Number.Uint64() - r.checkForEpochsAndInputs(ctx, daveConsensusApps, blockNumber) + r.Logger.Debug(fmt.Sprintf( + "Using block %d and not %d because of commitment policy: %s", + mostRecentHeader.Number.Uint64(), + header.Number.Uint64(), r.defaultBlock)) + } - r.checkForNewInputs(ctx, iconsensusApps, blockNumber) + r.checkForEpochsAndInputs(ctx, daveConsensusApps, blockNumber) - r.checkForOutputExecution(ctx, apps, blockNumber) + r.checkForNewInputs(ctx, iconsensusApps, blockNumber) - } + r.checkForOutputExecution(ctx, apps, blockNumber) } } diff --git a/internal/evmreader/util.go b/internal/evmreader/util.go index 9a1cd1559..f8be142a8 100644 --- a/internal/evmreader/util.go +++ b/internal/evmreader/util.go @@ -17,7 +17,7 @@ func calculateEpochIndex(epochLength uint64, blockNumber uint64) uint64 { return blockNumber / epochLength } -// appsToAddresses +// appsToAddresses extracts IApplicationAddress from each appContracts entry. func appsToAddresses(apps []appContracts) []common.Address { var addresses []common.Address for _, app := range apps { From a095396071cc1886568dbc5ed890f985aed13766 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Sat, 7 Mar 2026 19:05:48 -0300 Subject: [PATCH 08/14] refactor(evmreader): simplify test infrastructure and mock setup --- internal/evmreader/evmreader_test.go | 628 +---------------- internal/evmreader/fixtures_test.go | 94 +++ internal/evmreader/input_test.go | 101 ++- internal/evmreader/mocks_test.go | 659 ++++++++++++++++++ internal/evmreader/output_test.go | 201 ++---- internal/evmreader/testdata/header_0.json | 14 - internal/evmreader/testdata/header_1.json | 14 - internal/evmreader/testdata/header_2.json | 14 - internal/evmreader/testdata/header_3.json | 14 - .../testdata/input_added_event_0.json | 20 - .../testdata/input_added_event_1.json | 20 - .../testdata/input_added_event_2.json | 20 - .../testdata/input_added_event_3.json | 20 - 13 files changed, 871 insertions(+), 948 deletions(-) create mode 100644 internal/evmreader/fixtures_test.go create mode 100644 internal/evmreader/mocks_test.go delete mode 100644 internal/evmreader/testdata/header_0.json delete mode 100644 internal/evmreader/testdata/header_1.json delete mode 100644 internal/evmreader/testdata/header_2.json delete mode 100644 internal/evmreader/testdata/header_3.json delete mode 100644 internal/evmreader/testdata/input_added_event_0.json delete mode 100644 internal/evmreader/testdata/input_added_event_1.json delete mode 100644 internal/evmreader/testdata/input_added_event_2.json delete mode 100644 internal/evmreader/testdata/input_added_event_3.json diff --git a/internal/evmreader/evmreader_test.go b/internal/evmreader/evmreader_test.go index 9af233777..8413878b0 100644 --- a/internal/evmreader/evmreader_test.go +++ b/internal/evmreader/evmreader_test.go @@ -5,22 +5,13 @@ package evmreader import ( "context" - _ "embed" - "encoding/json" "fmt" - "math/big" "testing" "time" "github.com/cartesi/rollups-node/internal/config" . "github.com/cartesi/rollups-node/internal/model" - "github.com/cartesi/rollups-node/internal/repository" - "github.com/cartesi/rollups-node/pkg/contracts/iapplication" - "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" "github.com/cartesi/rollups-node/pkg/service" - "github.com/ethereum/go-ethereum" - "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" @@ -30,66 +21,6 @@ const ( suiteTimeout = 120 * time.Second ) -//go:embed testdata/input_added_event_0.json -var inputAddedEvent0JsonBytes []byte - -//go:embed testdata/input_added_event_1.json -var inputAddedEvent1JsonBytes []byte - -//go:embed testdata/input_added_event_2.json -var inputAddedEvent2JsonBytes []byte - -//go:embed testdata/input_added_event_3.json -var inputAddedEvent3JsonBytes []byte - -//go:embed testdata/header_0.json -var header0JsonBytes []byte - -//go:embed testdata/header_1.json -var header1JsonBytes []byte - -//go:embed testdata/header_2.json -var header2JsonBytes []byte - -//go:embed testdata/header_3.json -var header3JsonBytes []byte - -var ( - header0 = types.Header{} - header1 = types.Header{} - header2 = types.Header{} - header3 = types.Header{} - - inputAddedEvent0 = iinputbox.IInputBoxInputAdded{} - inputAddedEvent1 = iinputbox.IInputBoxInputAdded{} - inputAddedEvent2 = iinputbox.IInputBoxInputAdded{} - inputAddedEvent3 = iinputbox.IInputBoxInputAdded{} - - subscription0 = newMockSubscription() -) - -var applications = []*Application{{ - Name: "my-app-1", - IApplicationAddress: common.HexToAddress("0x2E663fe9aE92275242406A185AA4fC8174339D3E"), - IConsensusAddress: common.HexToAddress("0xdeadbeef"), - IInputBoxAddress: common.HexToAddress("0xBa3Cf8fB82E43D370117A0b7296f91ED674E94e3"), - DataAvailability: DataAvailability_InputBox[:], - IInputBoxBlock: 0x01, - EpochLength: 10, - LastInputCheckBlock: 0x00, - LastOutputCheckBlock: 0x00, -}, { - Name: "my-app-2", - IApplicationAddress: common.HexToAddress("0x78c716FDaE477595a820D86D0eFAfe0eE54dF7dB"), - IConsensusAddress: common.HexToAddress("0xdeadbeef"), - IInputBoxAddress: common.HexToAddress("0xBa3Cf8fB82E43D370117A0b7296f91ED674E94e3"), - DataAvailability: []byte{0x11, 0x32, 0x45, 0x56}, - IInputBoxBlock: 0x01, - EpochLength: 10, - LastInputCheckBlock: 0x00, - LastOutputCheckBlock: 0x00, -}} - type EvmReaderSuite struct { suite.Suite ctx context.Context @@ -111,24 +42,6 @@ func TestEvmReaderSuite(t *testing.T) { func (s *EvmReaderSuite) SetupSuite() { s.ctx, s.cancel = context.WithTimeout(context.Background(), suiteTimeout) config.SetDefaults() - - err := json.Unmarshal(header0JsonBytes, &header0) - s.Require().Nil(err) - err = json.Unmarshal(header1JsonBytes, &header1) - s.Require().Nil(err) - err = json.Unmarshal(header2JsonBytes, &header2) - s.Require().Nil(err) - err = json.Unmarshal(header3JsonBytes, &header3) - s.Require().Nil(err) - - err = json.Unmarshal(inputAddedEvent0JsonBytes, &inputAddedEvent0) - s.Require().Nil(err) - err = json.Unmarshal(inputAddedEvent1JsonBytes, &inputAddedEvent1) - s.Require().Nil(err) - err = json.Unmarshal(inputAddedEvent2JsonBytes, &inputAddedEvent2) - s.Require().Nil(err) - err = json.Unmarshal(inputAddedEvent3JsonBytes, &inputAddedEvent3) - s.Require().Nil(err) } func (s *EvmReaderSuite) TearDownSuite() { @@ -141,7 +54,7 @@ func (s *EvmReaderSuite) SetupTest() { s.repository = newMockRepository().SetupDefaultBehavior() s.applicationContract1 = newMockApplicationContract().SetupDefaultBehavior() s.applicationContract2 = newMockApplicationContract().SetupDefaultBehavior() - s.inputBox = newMockInputBox().SetupDefaultBehavior(s.ctx) + s.inputBox = newMockInputBox().SetupDefaultBehavior() s.contractFactory = newMockAdapterFactory().SetupDefaultBehavior(s.applicationContract1, s.applicationContract2, s.inputBox) s.evmReader = &Service{ @@ -158,11 +71,11 @@ func (s *EvmReaderSuite) SetupTest() { } logLevel, err := config.GetLogLevel() - s.Require().Nil(err) + s.Require().NoError(err) serviceArgs := &service.CreateInfo{Name: "evm-reader", Impl: s.evmReader, LogLevel: logLevel} err = service.Create(context.Background(), serviceArgs, &s.evmReader.Service) - s.Require().Nil(err) + s.Require().NoError(err) } // Service tests @@ -224,9 +137,9 @@ func (s *EvmReaderSuite) TestRunResetsRetriesAfterProcessingHeaders() { s.evmReader.wsLivenessTimeout = 100 * time.Millisecond // First call: subscribe succeeds, deliver a header, then subscription error fires. - // → headersProcessed > 0, so consecutiveFailures resets to 0 - // Second call: subscribe fails (connection error) → consecutiveFailures=1 - // Third call: subscribe fails → consecutiveFailures=2 > maxRetries(1) → exit + // -> headersProcessed > 0, so consecutiveFailures resets to 0 + // Second call: subscribe fails (connection error) -> consecutiveFailures=1 + // Third call: subscribe fails -> consecutiveFailures=2 > maxRetries(1) -> exit subWithError := &MockSubscription{} errCh := make(chan error, 1) subWithError.On("Unsubscribe").Return() @@ -261,7 +174,7 @@ func (s *EvmReaderSuite) TestRunDoesNotResetRetriesWithoutProcessingHeaders() { // Subscribe succeeds but no headers arrive before liveness timeout. // headersProcessed=0, so consecutiveFailures increments (not reset). - // With maxRetries=1: first timeout → failures=1, second timeout → failures=2 > 1 → exit + // With maxRetries=1: first timeout -> failures=1, second timeout -> failures=2 > 1 -> exit err := s.evmReader.Run(s.ctx, make(chan struct{}, 1)) s.Require().ErrorContains(err, "no new block header received") s.wsClient.AssertNumberOfCalls(s.T(), "SubscribeNewHead", 2) @@ -346,18 +259,6 @@ func (s *EvmReaderSuite) TestIndexApps() { s.Require().Equal(0, len(indexApps)) }) - s.Run("whenIndexAppsArray", func() { - apps := []appContracts{} - - keyByProcessedBlock := func(a appContracts) uint64 { - return a.application.LastInputCheckBlock - } - - indexApps := indexApps(keyByProcessedBlock, apps) - - s.Require().Equal(0, len(indexApps)) - }) - s.Run("whenUsesWrongKey", func() { apps := []appContracts{ {application: &Application{LastInputCheckBlock: 23}}, @@ -380,518 +281,3 @@ func (s *EvmReaderSuite) TestIndexApps() { }) } - -// Mock EthClient -type MockEthClient struct { - mock.Mock -} - -func newMockEthClient() *MockEthClient { - return &MockEthClient{} -} - -func (m *MockEthClient) SetupDefaultBehavior() *MockEthClient { - return m -} - -func (m *MockEthClient) SetupDefaultWsBehavior() *MockEthClient { - m.On("ChainID", mock.Anything).Return(big.NewInt(1), nil) - m.On("SubscribeNewHead", - mock.Anything, - mock.Anything, - ).Return(subscription0, nil) - return m -} - -func UnsetAll(m *mock.Mock, methodName string) { - // Assuming no multithreading issues for test purposes - var index int - for _, call := range m.ExpectedCalls { - if call.Method == methodName { - continue - } - m.ExpectedCalls[index] = call - index++ - } - m.ExpectedCalls = m.ExpectedCalls[:index] -} - -func (m *MockEthClient) Unset(methodName string) { - UnsetAll(&m.Mock, methodName) -} - -func (m *MockEthClient) HeaderByNumber( - ctx context.Context, - number *big.Int, -) (*types.Header, error) { - args := m.Called(ctx, number) - return args.Get(0).(*types.Header), args.Error(1) -} - -func (m *MockEthClient) SubscribeNewHead( - ctx context.Context, - ch chan<- *types.Header, -) (ethereum.Subscription, error) { - args := m.Called(ctx, ch) - return args.Get(0).(ethereum.Subscription), args.Error(1) -} - -func (m *MockEthClient) ChainID(ctx context.Context) (*big.Int, error) { - args := m.Called(ctx) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*big.Int), args.Error(1) -} - -// Mock ethereum.Subscription -type MockSubscription struct { - mock.Mock -} - -func newMockSubscription() *MockSubscription { - sub := &MockSubscription{} - - sub.On("Unsubscribe").Return() - sub.On("Err").Return(make(<-chan error)) - - return sub -} - -func (m *MockSubscription) Unsubscribe() { - m.Called() -} - -func (m *MockSubscription) Err() <-chan error { - args := m.Called() - return args.Get(0).(<-chan error) -} - -// FakeClient -type FakeWSEhtClient struct { - ch chan<- *types.Header -} - -func (f *FakeWSEhtClient) SubscribeNewHead( - _ context.Context, - ch chan<- *types.Header, -) (ethereum.Subscription, error) { - f.ch = ch - return newMockSubscription(), nil -} - -func (f *FakeWSEhtClient) HeaderByNumber( - _ context.Context, - _ *big.Int, -) (*types.Header, error) { - return &header0, nil -} - -func (f *FakeWSEhtClient) ChainID(_ context.Context) (*big.Int, error) { - return big.NewInt(1), nil -} - -func (f *FakeWSEhtClient) fireNewHead(header *types.Header) { - f.ch <- header -} - -// Mock inputbox.InputBox -type MockInputBox struct { - mock.Mock -} - -func (m *MockInputBox) SetupDefaultBehavior(ctx context.Context) *MockInputBox { - events0 := []iinputbox.IInputBoxInputAdded{inputAddedEvent0} - retrieveInputsOpts0 := bind.FilterOpts{ - Context: ctx, - Start: 0x11, - End: Pointer(uint64(0x11)), - } - m.On("RetrieveInputs", - &retrieveInputsOpts0, - mock.Anything, - mock.Anything, - ).Return(events0, nil).Once() - - events1 := []iinputbox.IInputBoxInputAdded{inputAddedEvent1} - retrieveInputsOpts1 := bind.FilterOpts{ - Context: ctx, - Start: 0x12, - End: Pointer(uint64(0x12)), - } - m.On("RetrieveInputs", - &retrieveInputsOpts1, - mock.Anything, - mock.Anything, - ).Return(events1, nil).Once() - - events2 := []iinputbox.IInputBoxInputAdded{inputAddedEvent2, inputAddedEvent3} - retrieveInputsOpts2 := bind.FilterOpts{ - Context: ctx, - Start: 0x13, - End: Pointer(uint64(0x13)), - } - m.On("RetrieveInputs", - &retrieveInputsOpts2, - mock.Anything, - mock.Anything, - ).Return(events2, nil).Once() - - m.On("GetNumberOfInputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(0), nil).Once() - m.On("GetNumberOfInputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(1), nil).Once() - m.On("GetNumberOfInputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(0), nil).Times(4) - m.On("GetNumberOfInputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(2), nil).Once() - m.On("GetNumberOfInputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(4), nil).Once() - return m -} - -func newMockInputBox() *MockInputBox { - return &MockInputBox{} -} - -func (m *MockInputBox) Unset(methodName string) { - UnsetAll(&m.Mock, methodName) -} - -func (m *MockInputBox) RetrieveInputs( - opts *bind.FilterOpts, - appContract []common.Address, - index []*big.Int, -) ([]iinputbox.IInputBoxInputAdded, error) { - args := m.Called(opts, appContract, index) - return args.Get(0).([]iinputbox.IInputBoxInputAdded), args.Error(1) -} - -func (m *MockInputBox) GetNumberOfInputs(opts *bind.CallOpts, appContract common.Address) (*big.Int, error) { - args := m.Called(opts, appContract) - return args.Get(0).(*big.Int), args.Error(1) -} - -// Mock InputReaderRepository -type MockRepository struct { - mock.Mock -} - -func copyApplications(apps []*Application) []*Application { - copies := make([]*Application, len(apps)) - for i, app := range apps { - if app == nil { - continue - } - copyApp := *app - copies[i] = ©App - } - return copies -} - -func (m *MockRepository) SetupDefaultBehavior() *MockRepository { - - apps := copyApplications(applications) - m.On("ListApplications", - mock.Anything, - mock.Anything, - mock.Anything, - false, - ).Return(apps, uint64(2), nil).Once() - - apps = copyApplications(applications) - apps[0].LastInputCheckBlock = 0x11 - apps[0].LastOutputCheckBlock = 0x11 - apps[1].LastOutputCheckBlock = 0x11 - m.On("ListApplications", - mock.Anything, - mock.Anything, - mock.Anything, - false, - ).Return(apps, uint64(2), nil).Once() - - apps = copyApplications(applications) - apps[0].LastInputCheckBlock = 0x12 - apps[0].LastOutputCheckBlock = 0x12 - apps[1].LastOutputCheckBlock = 0x12 - m.On("ListApplications", - mock.Anything, - mock.Anything, - mock.Anything, - false, - ).Return(apps, uint64(2), nil).Once() - - m.On("UpdateEventLastCheckBlock", - mock.Anything, - mock.Anything, - MonitoredEvent_InputAdded, - mock.Anything, - ).Return(nil).Times(1) - m.On("UpdateEventLastCheckBlock", - mock.Anything, - mock.Anything, - MonitoredEvent_OutputExecuted, - mock.Anything, - ).Return(nil).Times(8) - - m.On("GetNumberOfInputs", - mock.Anything, - mock.Anything, - ).Once().Return(uint64(0), nil) - m.On("GetNumberOfInputs", - mock.Anything, - mock.Anything, - ).Once().Return(uint64(1), nil) - m.On("GetNumberOfInputs", - mock.Anything, - mock.Anything, - ).Once().Return(uint64(2), nil) - - m.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(uint64(0), nil).Times(6) - - m.On("CreateEpochsAndInputs", - mock.Anything, - mock.Anything, - mock.Anything, - mock.Anything).Return(nil) - - m.On("GetEpoch", - mock.Anything, - mock.Anything, - uint64(0)).Return(nil, nil).Once() - m.On("GetEpoch", - mock.Anything, - mock.Anything, - uint64(1)).Return( - &Epoch{ - Index: 1, - FirstBlock: 11, - LastBlock: 20, - Status: EpochStatus_Open, - OutputsMerkleRoot: nil, - ClaimTransactionHash: nil, - }, nil).Twice() - return m -} - -func newMockRepository() *MockRepository { - return &MockRepository{} -} - -func (m *MockRepository) Unset(methodName string) { - UnsetAll(&m.Mock, methodName) -} - -func (m *MockRepository) ListApplications( - ctx context.Context, - f repository.ApplicationFilter, - pagination repository.Pagination, - descending bool, -) ([]*Application, uint64, error) { - args := m.Called(ctx, f, pagination, descending) - return args.Get(0).([]*Application), args.Get(1).(uint64), args.Error(2) -} - -func (m *MockRepository) SaveNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error { - args := m.Called(ctx, key, rawJSON) - return args.Error(0) -} - -func (m *MockRepository) LoadNodeConfigRaw(ctx context.Context, key string) (rawJSON []byte, createdAt, updatedAt time.Time, err error) { - args := m.Called(ctx, key) - return args.Get(0).([]byte), args.Get(1).(time.Time), args.Get(2).(time.Time), args.Error(3) -} - -func (m *MockRepository) CreateEpochsAndInputs( - ctx context.Context, nameOrAddress string, - epochInputMap map[*Epoch][]*Input, blockNumber uint64, -) error { - args := m.Called(ctx, nameOrAddress, epochInputMap, blockNumber) - return args.Error(0) -} - -func (m *MockRepository) GetEpoch(ctx context.Context, nameOrAddress string, index uint64) (*Epoch, error) { - args := m.Called(ctx, nameOrAddress, index) - obj := args.Get(0) - if obj == nil { - return nil, args.Error(1) - } - return obj.(*Epoch), args.Error(1) -} - -func (m *MockRepository) ListEpochs(ctx context.Context, nameOrAddress string, - f repository.EpochFilter, p repository.Pagination, descending bool) ([]*Epoch, uint64, error) { - args := m.Called(ctx, nameOrAddress, f, p, descending) - return args.Get(0).([]*Epoch), args.Get(1).(uint64), args.Error(2) -} - -func (m *MockRepository) GetOutput(ctx context.Context, nameOrAddress string, indexKey uint64) (*Output, error) { - args := m.Called(ctx, nameOrAddress, indexKey) - obj := args.Get(0) - if obj == nil { - return nil, args.Error(1) - } - return obj.(*Output), args.Error(1) -} - -func (m *MockRepository) UpdateEpochClaimTransactionHash(ctx context.Context, nameOrAddress string, e *Epoch) error { - args := m.Called(ctx, nameOrAddress, e) - return args.Error(0) -} - -func (m *MockRepository) GetLastNonOpenEpoch(ctx context.Context, nameOrAddress string) (*Epoch, error) { - args := m.Called(ctx, nameOrAddress) - obj := args.Get(0) - if obj == nil { - return nil, args.Error(1) - } - return obj.(*Epoch), args.Error(1) -} - -func (m *MockRepository) GetNumberOfInputs(ctx context.Context, nameOrAddress string) (uint64, error) { - args := m.Called(ctx, nameOrAddress) - return args.Get(0).(uint64), args.Error(1) -} - -func (m *MockRepository) GetNumberOfExecutedOutputs(ctx context.Context, nameOrAddress string) (uint64, error) { - args := m.Called(ctx, nameOrAddress) - return args.Get(0).(uint64), args.Error(1) -} - -func (m *MockRepository) UpdateOutputsExecution(ctx context.Context, nameOrAddress string, - executedOutputs []*Output, blockNumber uint64) error { - args := m.Called(ctx, nameOrAddress, executedOutputs, blockNumber) - return args.Error(0) -} - -func (m *MockRepository) UpdateApplicationState(ctx context.Context, appID int64, state ApplicationState, reason *string) error { - args := m.Called(ctx, appID, state, reason) - return args.Error(0) -} - -func (m *MockRepository) UpdateEventLastCheckBlock(ctx context.Context, appIDs []int64, - event MonitoredEvent, blockNumber uint64) error { - args := m.Called(ctx, appIDs, event, blockNumber) - return args.Error(0) -} - -func (m *MockRepository) GetEventLastCheckBlock(ctx context.Context, appID int64, event MonitoredEvent) (uint64, error) { - args := m.Called(ctx, appID, event) - return args.Get(0).(uint64), args.Error(1) -} - -type MockApplicationContract struct { - mock.Mock -} - -func (m *MockApplicationContract) SetupDefaultBehavior() *MockApplicationContract { - m.On("GetDeploymentBlockNumber", - mock.Anything, - ).Return(new(big.Int).SetUint64(0x10), nil).Once() - m.On("GetNumberOfExecutedOutputs", - mock.Anything, - ).Return(new(big.Int).SetUint64(0), nil).Times(4) - return m -} - -func (m *MockApplicationContract) Unset(methodName string) { - UnsetAll(&m.Mock, methodName) -} - -func (m *MockApplicationContract) RetrieveOutputExecutionEvents( - opts *bind.FilterOpts, -) ([]*iapplication.IApplicationOutputExecuted, error) { - args := m.Called(opts) - return args.Get(0).([]*iapplication.IApplicationOutputExecuted), args.Error(1) -} - -func (m *MockApplicationContract) GetDeploymentBlockNumber(opts *bind.CallOpts) (*big.Int, error) { - args := m.Called(opts) - return args.Get(0).(*big.Int), args.Error(1) -} - -func (m *MockApplicationContract) GetNumberOfExecutedOutputs(opts *bind.CallOpts) (*big.Int, error) { - args := m.Called(opts) - return args.Get(0).(*big.Int), args.Error(1) -} - -func newMockApplicationContract() *MockApplicationContract { - return &MockApplicationContract{} -} - -type MockAdapterFactory struct { - mock.Mock -} - -func (m *MockAdapterFactory) Unset(methodName string) { - UnsetAll(&m.Mock, methodName) -} - -func (m *MockAdapterFactory) CreateAdapters( - app *Application, -) (ApplicationContractAdapter, InputSourceAdapter, DaveConsensusAdapter, error) { - args := m.Called(app) - - // Safely handle nil values to prevent interface conversion panic - appContract, _ := args.Get(0).(ApplicationContractAdapter) - inputSource, _ := args.Get(1).(InputSourceAdapter) - - // If we got nil values but no error was returned, return mock implementations - if appContract == nil && args.Error(2) == nil { - appContract = &MockApplicationContract{} - } - - if inputSource == nil && args.Error(2) == nil { - inputSource = newMockInputBox() - } - - return appContract, inputSource, nil, args.Error(2) -} - -func (m *MockAdapterFactory) SetupDefaultBehavior( - appContract1 *MockApplicationContract, - appContract2 *MockApplicationContract, - inputBox1 *MockInputBox, -) *MockAdapterFactory { - - // Match by application address so adapters are returned correctly regardless of call count - // (adapter caching means CreateAdapters is only called once per app address) - m.On("CreateAdapters", - mock.MatchedBy(func(app *Application) bool { - return app.IApplicationAddress == applications[0].IApplicationAddress - }), - ).Return(appContract1, inputBox1, nil) - m.On("CreateAdapters", - mock.MatchedBy(func(app *Application) bool { - return app.IApplicationAddress == applications[1].IApplicationAddress - }), - ).Return(appContract2, nil, nil) - return m -} - -func (m *MockAdapterFactory) SetupDefaultBehaviorSingleApp( - appContract *MockApplicationContract, - inputBox *MockInputBox) *MockAdapterFactory { - // Set up a default behavior that always returns valid non-nil interfaces - m.On("CreateAdapters", - mock.Anything, - ).Return(appContract, inputBox, nil) - return m -} - -func newMockAdapterFactory() *MockAdapterFactory { - return &MockAdapterFactory{} -} diff --git a/internal/evmreader/fixtures_test.go b/internal/evmreader/fixtures_test.go new file mode 100644 index 000000000..6a43253cd --- /dev/null +++ b/internal/evmreader/fixtures_test.go @@ -0,0 +1,94 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "math/big" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +// Fixed addresses used across evmreader tests. +var ( + app1Addr = common.HexToAddress("0x2E663fe9aE92275242406A185AA4fC8174339D3E") + app2Addr = common.HexToAddress("0x78c716FDaE477595a820D86D0eFAfe0eE54dF7dB") + inputBoxAddr = common.HexToAddress("0xBa3Cf8fB82E43D370117A0b7296f91ED674E94e3") + consensusAddr = common.HexToAddress("0xdeadbeef") +) + +// Test headers — only Number is used by production code. +var ( + header0 = makeHeader(0x11) + header1 = makeHeader(0x12) + header2 = makeHeader(0x13) + header3 = makeHeader(0x33) +) + +// Test input events — all target app1. +var ( + inputAddedEvent0 = makeInputEvent(app1Addr, 0, 0x11) + inputAddedEvent1 = makeInputEvent(app1Addr, 1, 0x12) + inputAddedEvent2 = makeInputEvent(app1Addr, 2, 0x13) + inputAddedEvent3 = makeInputEvent(app1Addr, 3, 0x13) +) + +// sentinelHeader is sent after the real headers to flush the processing pipeline. +// Because the subscription channel is unbuffered, fireNewHead blocks until the +// evmreader receives the header. Sending a sentinel after the last real header +// guarantees all real headers have been fully processed when fireNewHead returns. +var sentinelHeader = makeHeader(0x01) + +var subscription0 = newMockSubscription() + +// applications defines the two-app setup used by most tests. +// app1: InputBox DA (inputs are read), app2: non-InputBox DA (inputs filtered out). +var applications = []*Application{{ + Name: "my-app-1", + IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, + DataAvailability: DataAvailability_InputBox[:], + IInputBoxBlock: 0x01, + EpochLength: 10, + LastInputCheckBlock: 0x00, + LastOutputCheckBlock: 0x00, +}, { + Name: "my-app-2", + IApplicationAddress: app2Addr, + IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, + DataAvailability: []byte{0x11, 0x32, 0x45, 0x56}, + IInputBoxBlock: 0x01, + EpochLength: 10, + LastInputCheckBlock: 0x00, + LastOutputCheckBlock: 0x00, +}} + +// makeHeader creates a types.Header with the given block number. +func makeHeader(blockNum uint64) types.Header { + return types.Header{ + Number: new(big.Int).SetUint64(blockNum), + } +} + +// makeInputEvent creates an IInputBoxInputAdded event for testing. +func makeInputEvent( + appAddr common.Address, + index uint64, + blockNum uint64, +) iinputbox.IInputBoxInputAdded { + return iinputbox.IInputBoxInputAdded{ + AppContract: appAddr, + Index: new(big.Int).SetUint64(index), + Input: []byte{0xde, 0xad, byte(index & 0xFF)}, + Raw: types.Log{ + BlockNumber: blockNum, + TxHash: common.BigToHash(new(big.Int).SetUint64(blockNum*1000 + index)), + BlockHash: common.BigToHash(new(big.Int).SetUint64(blockNum)), + }, + } +} diff --git a/internal/evmreader/input_test.go b/internal/evmreader/input_test.go index 90f44351b..783ab9ae2 100644 --- a/internal/evmreader/input_test.go +++ b/internal/evmreader/input_test.go @@ -6,7 +6,6 @@ package evmreader import ( "errors" "math/big" - "time" . "github.com/cartesi/rollups-node/internal/model" "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" @@ -17,7 +16,7 @@ import ( ) func (s *EvmReaderSuite) TestItReadsInputsFromNewBlocksFilteredByDA() { - wsClient := FakeWSEhtClient{} + wsClient := FakeWSEthClient{} s.evmReader.wsClient = &wsClient // Start service @@ -38,7 +37,7 @@ func (s *EvmReaderSuite) TestItReadsInputsFromNewBlocksFilteredByDA() { wsClient.fireNewHead(&header0) wsClient.fireNewHead(&header1) wsClient.fireNewHead(&header2) - time.Sleep(time.Second) + wsClient.flushHeaders() s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 3) s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 9) @@ -53,7 +52,7 @@ func (s *EvmReaderSuite) TestItReadsInputsFromNewBlocksFilteredByDA() { } func (s *EvmReaderSuite) TestItReadsInputsFromNewFinalizedBlocks() { - wsClient := FakeWSEhtClient{} + wsClient := FakeWSEthClient{} s.evmReader.wsClient = &wsClient s.evmReader.defaultBlock = DefaultBlock_Finalized @@ -88,7 +87,7 @@ func (s *EvmReaderSuite) TestItReadsInputsFromNewFinalizedBlocks() { wsClient.fireNewHead(&header3) wsClient.fireNewHead(&header3) wsClient.fireNewHead(&header3) - time.Sleep(time.Second) + wsClient.flushHeaders() s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 3) s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 9) @@ -103,7 +102,7 @@ func (s *EvmReaderSuite) TestItReadsInputsFromNewFinalizedBlocks() { } func (s *EvmReaderSuite) TestItUpdatesLastInputCheckBlockWhenThereIsNoInputs() { - wsClient := FakeWSEhtClient{} + wsClient := FakeWSEthClient{} s.evmReader.wsClient = &wsClient // Prepare repository @@ -179,7 +178,7 @@ func (s *EvmReaderSuite) TestItUpdatesLastInputCheckBlockWhenThereIsNoInputs() { wsClient.fireNewHead(&header0) wsClient.fireNewHead(&header1) wsClient.fireNewHead(&header2) - time.Sleep(time.Second) + wsClient.flushHeaders() s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) s.repository.AssertExpectations(s.T()) @@ -195,7 +194,7 @@ func (s *EvmReaderSuite) TestItUpdatesLastInputCheckBlockWhenThereIsNoInputs() { func (s *EvmReaderSuite) TestItReadsMultipleInputsFromSingleNewBlock() { - wsClient := FakeWSEhtClient{} + wsClient := FakeWSEthClient{} s.evmReader.wsClient = &wsClient s.applicationContract1.Unset("GetDeploymentBlockNumber") @@ -206,19 +205,11 @@ func (s *EvmReaderSuite) TestItReadsMultipleInputsFromSingleNewBlock() { // Prepare sequence of inputs s.inputBox.Unset("RetrieveInputs") - events_2 := []iinputbox.IInputBoxInputAdded{inputAddedEvent2, inputAddedEvent3} - mostRecentBlockNumber_2 := uint64(0x13) - retrieveInputsOpts_2 := bind.FilterOpts{ - Context: s.ctx, - Start: 0x13, - End: &mostRecentBlockNumber_2, - } - s.inputBox.On( - "RetrieveInputs", - &retrieveInputsOpts_2, + s.inputBox.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x13 }), mock.Anything, mock.Anything, - ).Return(events_2, nil) + ).Return([]iinputbox.IInputBoxInputAdded{inputAddedEvent2, inputAddedEvent3}, nil) s.inputBox.Unset("GetNumberOfInputs") s.inputBox.On("GetNumberOfInputs", @@ -238,15 +229,18 @@ func (s *EvmReaderSuite) TestItReadsMultipleInputsFromSingleNewBlock() { false, ).Return([]*Application{{ Name: "my-app-1", - IApplicationAddress: common.HexToAddress("0x2E663fe9aE92275242406A185AA4fC8174339D3E"), - IConsensusAddress: common.HexToAddress("0xdeadbeef"), - IInputBoxAddress: common.HexToAddress("0xBa3Cf8fB82E43D370117A0b7296f91ED674E94e3"), + IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, DataAvailability: DataAvailability_InputBox[:], IInputBoxBlock: 0x10, EpochLength: 10, LastInputCheckBlock: 0x12, LastOutputCheckBlock: 0x12, }}, uint64(1), nil).Once() + // Catch-all for sentinel / extra headers + s.repository.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*Application{}, uint64(0), nil) s.repository.Unset("CreateEpochsAndInputs") s.repository.On("CreateEpochsAndInputs", @@ -309,7 +303,7 @@ func (s *EvmReaderSuite) TestItReadsMultipleInputsFromSingleNewBlock() { wsClient.fireNewHead(&header2) // Give a time for - time.Sleep(1 * time.Second) + wsClient.flushHeaders() s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 1) s.repository.AssertExpectations(s.T()) @@ -323,7 +317,7 @@ func (s *EvmReaderSuite) TestItReadsMultipleInputsFromSingleNewBlock() { } func (s *EvmReaderSuite) TestItStartsWhenLastProcessedBlockIsTheMostRecentBlock() { - wsClient := FakeWSEhtClient{} + wsClient := FakeWSEthClient{} s.evmReader.wsClient = &wsClient // Prepare Repo @@ -335,15 +329,18 @@ func (s *EvmReaderSuite) TestItStartsWhenLastProcessedBlockIsTheMostRecentBlock( false, ).Return([]*Application{{ Name: "my-app-1", - IApplicationAddress: common.HexToAddress("0x2E663fe9aE92275242406A185AA4fC8174339D3E"), - IConsensusAddress: common.HexToAddress("0xdeadbeef"), - IInputBoxAddress: common.HexToAddress("0xBa3Cf8fB82E43D370117A0b7296f91ED674E94e3"), + IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, DataAvailability: DataAvailability_InputBox[:], IInputBoxBlock: 0x10, EpochLength: 10, LastInputCheckBlock: 0x13, LastOutputCheckBlock: 0x13, }}, uint64(1), nil).Once() + // Catch-all for sentinel / extra headers + s.repository.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*Application{}, uint64(0), nil) s.repository.Unset("CreateEpochsAndInputs") s.repository.Unset("UpdateEventLastCheckBlock") @@ -361,7 +358,7 @@ func (s *EvmReaderSuite) TestItStartsWhenLastProcessedBlockIsTheMostRecentBlock( s.contractFactory.Unset("CreateAdapters") s.contractFactory.On("CreateAdapters", mock.Anything, - ).Return(s.applicationContract1, s.inputBox, nil).Once() + ).Return(s.applicationContract1, s.inputBox, nil, nil).Once() // Start service ready := make(chan struct{}, 1) @@ -379,7 +376,7 @@ func (s *EvmReaderSuite) TestItStartsWhenLastProcessedBlockIsTheMostRecentBlock( } wsClient.fireNewHead(&header2) - time.Sleep(1 * time.Second) + wsClient.flushHeaders() s.repository.AssertExpectations(s.T()) s.inputBox.AssertExpectations(s.T()) @@ -394,66 +391,66 @@ func (s *EvmReaderSuite) TestItStartsWhenLastProcessedBlockIsTheMostRecentBlock( func (s *EvmReaderSuite) TestCheckpointNotAdvancedOnFetchFailure() { require := require.New(s.T()) - app1Addr := common.HexToAddress("0x1111111111111111111111111111111111111111") - app2Addr := common.HexToAddress("0x2222222222222222222222222222222222222222") - inputBoxAddr := common.HexToAddress("0xBa3Cf8fB82E43D370117A0b7296f91ED674E94e3") + okAddr := common.HexToAddress("0x1111111111111111111111111111111111111111") + failAddr := common.HexToAddress("0x2222222222222222222222222222222222222222") + ibAddr := common.HexToAddress("0xBa3Cf8fB82E43D370117A0b7296f91ED674E94e3") - app1 := &Application{ + appOk := &Application{ ID: 1, Name: "app-ok", - IApplicationAddress: app1Addr, - IInputBoxAddress: inputBoxAddr, + IApplicationAddress: okAddr, + IInputBoxAddress: ibAddr, EpochLength: 10, LastInputCheckBlock: 100, } - app2 := &Application{ + appFail := &Application{ ID: 2, Name: "app-fail", - IApplicationAddress: app2Addr, - IInputBoxAddress: inputBoxAddr, + IApplicationAddress: failAddr, + IInputBoxAddress: ibAddr, EpochLength: 10, LastInputCheckBlock: 100, } - // app1's inputSource succeeds with no inputs (GetNumberOfInputs returns 0 at both blocks) + // appOk's inputSource succeeds with no inputs (GetNumberOfInputs returns 0 at both blocks) inputSource1 := &MockInputBox{} inputSource1.On("GetNumberOfInputs", mock.Anything, mock.Anything). Return(new(big.Int).SetUint64(0), nil) - // app2's inputSource fails + // appFail's inputSource fails inputSource2 := &MockInputBox{} inputSource2.On("GetNumberOfInputs", mock.Anything, mock.Anything). Return((*big.Int)(nil), errors.New("RPC connection refused")) apps := []appContracts{ - {application: app1, inputSource: inputSource1}, - {application: app2, inputSource: inputSource2}, + {application: appOk, inputSource: inputSource1}, + {application: appFail, inputSource: inputSource2}, } - // Repository: app1 has 0 inputs in DB + // Repository: appOk has 0 inputs in DB repo := newMockRepository() - repo.On("GetNumberOfInputs", mock.Anything, app1Addr.String()). + repo.On("GetNumberOfInputs", mock.Anything, okAddr.String()). Return(uint64(0), nil) - repo.On("GetNumberOfInputs", mock.Anything, app2Addr.String()). + repo.On("GetNumberOfInputs", mock.Anything, failAddr.String()). Return(uint64(0), nil) - // GetEpoch is called for app1 (which was successfully fetched with 0 inputs) + // GetEpoch is called for appOk (which was successfully fetched with 0 inputs) // calculateEpochIndex(10, 100) = 10 - repo.On("GetEpoch", mock.Anything, app1Addr.String(), uint64(10)). + repo.On("GetEpoch", mock.Anything, okAddr.String(), uint64(10)). Return(nil, nil) - // Expect UpdateEventLastCheckBlock to be called with ONLY app1's ID. - // app2 failed to fetch — its checkpoint must NOT be advanced. + // Expect UpdateEventLastCheckBlock to be called with ONLY appOk's ID. + // appFail failed to fetch — its checkpoint must NOT be advanced. repo.On("UpdateEventLastCheckBlock", mock.Anything, mock.MatchedBy(func(ids []int64) bool { - // Must contain only app1 (ID=1), not app2 (ID=2) + // Must contain only appOk (ID=1), not appFail (ID=2) for _, id := range ids { - if id == app2.ID { + if id == appFail.ID { return false } } - return len(ids) == 1 && ids[0] == app1.ID + return len(ids) == 1 && ids[0] == appOk.ID }), MonitoredEvent_InputAdded, uint64(110), diff --git a/internal/evmreader/mocks_test.go b/internal/evmreader/mocks_test.go new file mode 100644 index 000000000..77107df2e --- /dev/null +++ b/internal/evmreader/mocks_test.go @@ -0,0 +1,659 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "math/big" + "time" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" +) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// blockRange returns a mock.MatchedBy matcher for CallOpts where BlockNumber is in [lo, hi). +func blockRange(lo, hi uint64) interface{} { + return mock.MatchedBy(func(opts *bind.CallOpts) bool { + b := opts.BlockNumber.Uint64() + return b >= lo && b < hi + }) +} + +// blockFrom returns a mock.MatchedBy matcher for CallOpts where BlockNumber >= lo. +func blockFrom(lo uint64) interface{} { + return mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= lo + }) +} + +// UnsetAll removes all expectations for the given method from a mock. +func UnsetAll(m *mock.Mock, methodName string) { + var index int + for _, call := range m.ExpectedCalls { + if call.Method == methodName { + continue + } + m.ExpectedCalls[index] = call + index++ + } + m.ExpectedCalls = m.ExpectedCalls[:index] +} + +func copyApplications(apps []*Application) []*Application { + copies := make([]*Application, len(apps)) + for i, app := range apps { + if app == nil { + continue + } + copyApp := *app + copies[i] = ©App + } + return copies +} + +// --------------------------------------------------------------------------- +// MockEthClient +// --------------------------------------------------------------------------- + +type MockEthClient struct { + mock.Mock +} + +func newMockEthClient() *MockEthClient { + return &MockEthClient{} +} + +func (m *MockEthClient) SetupDefaultBehavior() *MockEthClient { + return m +} + +func (m *MockEthClient) SetupDefaultWsBehavior() *MockEthClient { + m.On("ChainID", mock.Anything).Return(big.NewInt(1), nil) + m.On("SubscribeNewHead", + mock.Anything, + mock.Anything, + ).Return(subscription0, nil) + return m +} + +func (m *MockEthClient) Unset(methodName string) { + UnsetAll(&m.Mock, methodName) +} + +func (m *MockEthClient) HeaderByNumber( + ctx context.Context, + number *big.Int, +) (*types.Header, error) { + args := m.Called(ctx, number) + return args.Get(0).(*types.Header), args.Error(1) +} + +func (m *MockEthClient) SubscribeNewHead( + ctx context.Context, + ch chan<- *types.Header, +) (ethereum.Subscription, error) { + args := m.Called(ctx, ch) + return args.Get(0).(ethereum.Subscription), args.Error(1) +} + +func (m *MockEthClient) ChainID(ctx context.Context) (*big.Int, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*big.Int), args.Error(1) +} + +// --------------------------------------------------------------------------- +// MockSubscription +// --------------------------------------------------------------------------- + +type MockSubscription struct { + mock.Mock +} + +func newMockSubscription() *MockSubscription { + sub := &MockSubscription{} + sub.On("Unsubscribe").Return() + sub.On("Err").Return(make(<-chan error)) + return sub +} + +func (m *MockSubscription) Unsubscribe() { + m.Called() +} + +func (m *MockSubscription) Err() <-chan error { + args := m.Called() + return args.Get(0).(<-chan error) +} + +// --------------------------------------------------------------------------- +// FakeWSEthClient +// --------------------------------------------------------------------------- + +type FakeWSEthClient struct { + ch chan<- *types.Header +} + +func (f *FakeWSEthClient) SubscribeNewHead( + _ context.Context, + ch chan<- *types.Header, +) (ethereum.Subscription, error) { + f.ch = ch + return newMockSubscription(), nil +} + +func (f *FakeWSEthClient) HeaderByNumber( + _ context.Context, + _ *big.Int, +) (*types.Header, error) { + return &header0, nil +} + +func (f *FakeWSEthClient) ChainID(_ context.Context) (*big.Int, error) { + return big.NewInt(1), nil +} + +func (f *FakeWSEthClient) fireNewHead(header *types.Header) { + f.ch <- header +} + +// flushHeaders sends a sentinel header to guarantee that all previously sent +// headers have been fully processed. Works because the channel is unbuffered. +func (f *FakeWSEthClient) flushHeaders() { + f.ch <- &sentinelHeader +} + +// --------------------------------------------------------------------------- +// MockInputBox +// --------------------------------------------------------------------------- + +type MockInputBox struct { + mock.Mock +} + +func newMockInputBox() *MockInputBox { + return &MockInputBox{} +} + +func (m *MockInputBox) SetupDefaultBehavior() *MockInputBox { + // RetrieveInputs: matched by Start block, not call order. + m.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x11 }), + mock.Anything, + mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{inputAddedEvent0}, nil) + + m.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x12 }), + mock.Anything, + mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{inputAddedEvent1}, nil) + + m.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x13 }), + mock.Anything, + mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{inputAddedEvent2, inputAddedEvent3}, nil) + + // GetNumberOfInputs: block-based matching models the on-chain state. + // Each range returns the input count at that point in the blockchain. + m.On("GetNumberOfInputs", blockRange(0, 0x11), mock.Anything). + Return(new(big.Int).SetUint64(0), nil) + m.On("GetNumberOfInputs", blockRange(0x11, 0x12), mock.Anything). + Return(new(big.Int).SetUint64(1), nil) + m.On("GetNumberOfInputs", blockRange(0x12, 0x13), mock.Anything). + Return(new(big.Int).SetUint64(2), nil) + m.On("GetNumberOfInputs", blockFrom(0x13), mock.Anything). + Return(new(big.Int).SetUint64(4), nil) + return m +} + +func (m *MockInputBox) Unset(methodName string) { + UnsetAll(&m.Mock, methodName) +} + +func (m *MockInputBox) RetrieveInputs( + opts *bind.FilterOpts, + appContract []common.Address, + index []*big.Int, +) ([]iinputbox.IInputBoxInputAdded, error) { + args := m.Called(opts, appContract, index) + return args.Get(0).([]iinputbox.IInputBoxInputAdded), args.Error(1) +} + +func (m *MockInputBox) GetNumberOfInputs( + opts *bind.CallOpts, appContract common.Address, +) (*big.Int, error) { + args := m.Called(opts, appContract) + return args.Get(0).(*big.Int), args.Error(1) +} + +// --------------------------------------------------------------------------- +// MockRepository +// --------------------------------------------------------------------------- + +type MockRepository struct { + mock.Mock +} + +func newMockRepository() *MockRepository { + return &MockRepository{} +} + +func (m *MockRepository) SetupDefaultBehavior() *MockRepository { + + apps := copyApplications(applications) + m.On("ListApplications", + mock.Anything, + mock.Anything, + mock.Anything, + false, + ).Return(apps, uint64(2), nil).Once() + + apps = copyApplications(applications) + apps[0].LastInputCheckBlock = 0x11 + apps[0].LastOutputCheckBlock = 0x11 + apps[1].LastOutputCheckBlock = 0x11 + m.On("ListApplications", + mock.Anything, + mock.Anything, + mock.Anything, + false, + ).Return(apps, uint64(2), nil).Once() + + apps = copyApplications(applications) + apps[0].LastInputCheckBlock = 0x12 + apps[0].LastOutputCheckBlock = 0x12 + apps[1].LastOutputCheckBlock = 0x12 + m.On("ListApplications", + mock.Anything, + mock.Anything, + mock.Anything, + false, + ).Return(apps, uint64(2), nil).Once() + + m.On("UpdateEventLastCheckBlock", + mock.Anything, + mock.Anything, + MonitoredEvent_InputAdded, + mock.Anything, + ).Return(nil).Times(1) + m.On("UpdateEventLastCheckBlock", + mock.Anything, + mock.Anything, + MonitoredEvent_OutputExecuted, + mock.Anything, + ).Return(nil).Times(8) + + m.On("GetNumberOfInputs", + mock.Anything, + mock.Anything, + ).Once().Return(uint64(0), nil) + m.On("GetNumberOfInputs", + mock.Anything, + mock.Anything, + ).Once().Return(uint64(1), nil) + m.On("GetNumberOfInputs", + mock.Anything, + mock.Anything, + ).Once().Return(uint64(2), nil) + + m.On("GetNumberOfExecutedOutputs", + mock.Anything, + mock.Anything, + ).Return(uint64(0), nil).Times(6) + + m.On("CreateEpochsAndInputs", + mock.Anything, + mock.Anything, + mock.Anything, + mock.Anything).Return(nil) + + m.On("GetEpoch", + mock.Anything, + mock.Anything, + uint64(0)).Return(nil, nil).Once() + m.On("GetEpoch", + mock.Anything, + mock.Anything, + uint64(1)).Return( + &Epoch{ + Index: 1, + FirstBlock: 11, + LastBlock: 20, + Status: EpochStatus_Open, + OutputsMerkleRoot: nil, + ClaimTransactionHash: nil, + }, nil).Twice() + + // Catch-all: returns empty list for sentinel / extra headers (flushHeaders). + m.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*Application{}, uint64(0), nil) + + return m +} + +func (m *MockRepository) Unset(methodName string) { + UnsetAll(&m.Mock, methodName) +} + +func (m *MockRepository) ListApplications( + ctx context.Context, + f repository.ApplicationFilter, + pagination repository.Pagination, + descending bool, +) ([]*Application, uint64, error) { + args := m.Called(ctx, f, pagination, descending) + return args.Get(0).([]*Application), args.Get(1).(uint64), args.Error(2) +} + +func (m *MockRepository) SaveNodeConfigRaw( + ctx context.Context, key string, rawJSON []byte, +) error { + args := m.Called(ctx, key, rawJSON) + return args.Error(0) +} + +func (m *MockRepository) LoadNodeConfigRaw( + ctx context.Context, key string, +) (rawJSON []byte, createdAt, updatedAt time.Time, err error) { + args := m.Called(ctx, key) + return args.Get(0).([]byte), args.Get(1).(time.Time), args.Get(2).(time.Time), args.Error(3) +} + +func (m *MockRepository) CreateEpochsAndInputs( + ctx context.Context, nameOrAddress string, + epochInputMap map[*Epoch][]*Input, blockNumber uint64, +) error { + args := m.Called(ctx, nameOrAddress, epochInputMap, blockNumber) + return args.Error(0) +} + +func (m *MockRepository) GetEpoch( + ctx context.Context, nameOrAddress string, index uint64, +) (*Epoch, error) { + args := m.Called(ctx, nameOrAddress, index) + obj := args.Get(0) + if obj == nil { + return nil, args.Error(1) + } + return obj.(*Epoch), args.Error(1) +} + +func (m *MockRepository) ListEpochs( + ctx context.Context, nameOrAddress string, + f repository.EpochFilter, p repository.Pagination, descending bool, +) ([]*Epoch, uint64, error) { + args := m.Called(ctx, nameOrAddress, f, p, descending) + return args.Get(0).([]*Epoch), args.Get(1).(uint64), args.Error(2) +} + +func (m *MockRepository) GetOutput( + ctx context.Context, nameOrAddress string, indexKey uint64, +) (*Output, error) { + args := m.Called(ctx, nameOrAddress, indexKey) + obj := args.Get(0) + if obj == nil { + return nil, args.Error(1) + } + return obj.(*Output), args.Error(1) +} + +func (m *MockRepository) UpdateEpochClaimTransactionHash( + ctx context.Context, nameOrAddress string, e *Epoch, +) error { + args := m.Called(ctx, nameOrAddress, e) + return args.Error(0) +} + +func (m *MockRepository) GetLastNonOpenEpoch( + ctx context.Context, nameOrAddress string, +) (*Epoch, error) { + args := m.Called(ctx, nameOrAddress) + obj := args.Get(0) + if obj == nil { + return nil, args.Error(1) + } + return obj.(*Epoch), args.Error(1) +} + +func (m *MockRepository) GetNumberOfInputs( + ctx context.Context, nameOrAddress string, +) (uint64, error) { + args := m.Called(ctx, nameOrAddress) + return args.Get(0).(uint64), args.Error(1) +} + +func (m *MockRepository) GetNumberOfExecutedOutputs( + ctx context.Context, nameOrAddress string, +) (uint64, error) { + args := m.Called(ctx, nameOrAddress) + return args.Get(0).(uint64), args.Error(1) +} + +func (m *MockRepository) UpdateOutputsExecution( + ctx context.Context, nameOrAddress string, + executedOutputs []*Output, blockNumber uint64, +) error { + args := m.Called(ctx, nameOrAddress, executedOutputs, blockNumber) + return args.Error(0) +} + +func (m *MockRepository) UpdateApplicationState( + ctx context.Context, appID int64, state ApplicationState, reason *string, +) error { + args := m.Called(ctx, appID, state, reason) + return args.Error(0) +} + +func (m *MockRepository) UpdateEventLastCheckBlock( + ctx context.Context, appIDs []int64, + event MonitoredEvent, blockNumber uint64, +) error { + args := m.Called(ctx, appIDs, event, blockNumber) + return args.Error(0) +} + +func (m *MockRepository) GetEventLastCheckBlock( + ctx context.Context, appID int64, event MonitoredEvent, +) (uint64, error) { + args := m.Called(ctx, appID, event) + return args.Get(0).(uint64), args.Error(1) +} + +// --------------------------------------------------------------------------- +// MockApplicationContract +// --------------------------------------------------------------------------- + +type MockApplicationContract struct { + mock.Mock +} + +func newMockApplicationContract() *MockApplicationContract { + return &MockApplicationContract{} +} + +func (m *MockApplicationContract) SetupDefaultBehavior() *MockApplicationContract { + m.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(0x10), nil) + m.On("GetNumberOfExecutedOutputs", mock.Anything). + Return(new(big.Int).SetUint64(0), nil) + return m +} + +func (m *MockApplicationContract) Unset(methodName string) { + UnsetAll(&m.Mock, methodName) +} + +func (m *MockApplicationContract) RetrieveOutputExecutionEvents( + opts *bind.FilterOpts, +) ([]*iapplication.IApplicationOutputExecuted, error) { + args := m.Called(opts) + return args.Get(0).([]*iapplication.IApplicationOutputExecuted), args.Error(1) +} + +func (m *MockApplicationContract) GetDeploymentBlockNumber( + opts *bind.CallOpts, +) (*big.Int, error) { + args := m.Called(opts) + return args.Get(0).(*big.Int), args.Error(1) +} + +func (m *MockApplicationContract) GetNumberOfExecutedOutputs( + opts *bind.CallOpts, +) (*big.Int, error) { + args := m.Called(opts) + return args.Get(0).(*big.Int), args.Error(1) +} + +// --------------------------------------------------------------------------- +// MockDaveConsensus +// --------------------------------------------------------------------------- + +type MockDaveConsensus struct { + mock.Mock +} + +func newMockDaveConsensus() *MockDaveConsensus { + return &MockDaveConsensus{} +} + +func (m *MockDaveConsensus) Unset(methodName string) { + UnsetAll(&m.Mock, methodName) +} + +func (m *MockDaveConsensus) GetInputBox( + opts *bind.CallOpts, +) (common.Address, error) { + args := m.Called(opts) + return args.Get(0).(common.Address), args.Error(1) +} + +func (m *MockDaveConsensus) GetCurrentSealedEpoch( + opts *bind.CallOpts, +) (struct { + EpochNumber *big.Int + InputIndexLowerBound *big.Int + InputIndexUpperBound *big.Int + Tournament common.Address +}, error) { + args := m.Called(opts) + return args.Get(0).(struct { + EpochNumber *big.Int + InputIndexLowerBound *big.Int + InputIndexUpperBound *big.Int + Tournament common.Address + }), args.Error(1) +} + +func (m *MockDaveConsensus) GetApplicationContract( + opts *bind.CallOpts, +) (common.Address, error) { + args := m.Called(opts) + return args.Get(0).(common.Address), args.Error(1) +} + +func (m *MockDaveConsensus) GetTournamentFactory( + opts *bind.CallOpts, +) (common.Address, error) { + args := m.Called(opts) + return args.Get(0).(common.Address), args.Error(1) +} + +func (m *MockDaveConsensus) GetDeploymentBlockNumber( + opts *bind.CallOpts, +) (*big.Int, error) { + args := m.Called(opts) + return args.Get(0).(*big.Int), args.Error(1) +} + +func (m *MockDaveConsensus) RetrieveSealedEpochs( + opts *bind.FilterOpts, +) ([]*idaveconsensus.IDaveConsensusEpochSealed, error) { + args := m.Called(opts) + return args.Get(0).([]*idaveconsensus.IDaveConsensusEpochSealed), args.Error(1) +} + +// --------------------------------------------------------------------------- +// MockAdapterFactory +// --------------------------------------------------------------------------- + +type MockAdapterFactory struct { + mock.Mock +} + +func newMockAdapterFactory() *MockAdapterFactory { + return &MockAdapterFactory{} +} + +func (m *MockAdapterFactory) Unset(methodName string) { + UnsetAll(&m.Mock, methodName) +} + +func (m *MockAdapterFactory) CreateAdapters( + app *Application, +) (ApplicationContractAdapter, InputSourceAdapter, DaveConsensusAdapter, error) { + args := m.Called(app) + + // Safely handle nil values to prevent interface conversion panic + appContract, _ := args.Get(0).(ApplicationContractAdapter) + inputSource, _ := args.Get(1).(InputSourceAdapter) + daveConsensus, _ := args.Get(2).(DaveConsensusAdapter) + + // If we got nil values but no error was returned, return mock implementations + if appContract == nil && args.Error(3) == nil { + appContract = &MockApplicationContract{} + } + + if inputSource == nil && args.Error(3) == nil { + inputSource = newMockInputBox() + } + + return appContract, inputSource, daveConsensus, args.Error(3) +} + +func (m *MockAdapterFactory) SetupDefaultBehavior( + appContract1 *MockApplicationContract, + appContract2 *MockApplicationContract, + inputBox1 *MockInputBox, +) *MockAdapterFactory { + + // Match by application address so adapters are returned correctly regardless of call count + // (adapter caching means CreateAdapters is only called once per app address) + m.On("CreateAdapters", + mock.MatchedBy(func(app *Application) bool { + return app.IApplicationAddress == applications[0].IApplicationAddress + }), + ).Return(appContract1, inputBox1, nil, nil) + m.On("CreateAdapters", + mock.MatchedBy(func(app *Application) bool { + return app.IApplicationAddress == applications[1].IApplicationAddress + }), + ).Return(appContract2, nil, nil, nil) + return m +} + +func (m *MockAdapterFactory) SetupDefaultBehaviorSingleApp( + appContract *MockApplicationContract, + inputBox *MockInputBox, +) *MockAdapterFactory { + m.On("CreateAdapters", + mock.Anything, + ).Return(appContract, inputBox, nil, nil) + return m +} diff --git a/internal/evmreader/output_test.go b/internal/evmreader/output_test.go index e7e6ce5c1..b52961925 100644 --- a/internal/evmreader/output_test.go +++ b/internal/evmreader/output_test.go @@ -45,34 +45,21 @@ var outputExecution1 = &iapplication.IApplicationOutputExecuted{ } func (s *EvmReaderSuite) setupOutputExecution() { + // On-chain state: 0 executed outputs before 0x11, 1 at 0x11-0x12, 2 from 0x13 s.applicationContract1.Unset("GetNumberOfExecutedOutputs") - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(0), nil).Once() - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(1), nil).Once() - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(0), nil).Once() - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(1), nil).Once() - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(2), nil).Once() + s.applicationContract1.On("GetNumberOfExecutedOutputs", blockRange(0, 0x11)). + Return(new(big.Int).SetUint64(0), nil) + s.applicationContract1.On("GetNumberOfExecutedOutputs", blockRange(0x11, 0x13)). + Return(new(big.Int).SetUint64(1), nil) + s.applicationContract1.On("GetNumberOfExecutedOutputs", blockFrom(0x13)). + Return(new(big.Int).SetUint64(2), nil) s.applicationContract1.On("RetrieveOutputExecutionEvents", - mock.Anything, - ).Return([]*iapplication.IApplicationOutputExecuted{outputExecution0}, nil).Once() + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x11 }), + ).Return([]*iapplication.IApplicationOutputExecuted{outputExecution0}, nil) s.applicationContract1.On("RetrieveOutputExecutionEvents", - mock.Anything, - ).Return([]*iapplication.IApplicationOutputExecuted{outputExecution1}, nil).Once() + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x13 }), + ).Return([]*iapplication.IApplicationOutputExecuted{outputExecution1}, nil) s.repository.Unset("UpdateEventLastCheckBlock") s.repository.On("UpdateEventLastCheckBlock", @@ -154,7 +141,7 @@ func (s *EvmReaderSuite) setupOutputExecution() { } func (s *EvmReaderSuite) TestOutputExecution() { - wsClient := FakeWSEhtClient{} + wsClient := FakeWSEthClient{} s.evmReader.wsClient = &wsClient s.setupOutputExecution() @@ -177,7 +164,7 @@ func (s *EvmReaderSuite) TestOutputExecution() { wsClient.fireNewHead(&header0) wsClient.fireNewHead(&header1) wsClient.fireNewHead(&header2) - time.Sleep(1 * time.Second) + wsClient.flushHeaders() s.repository.AssertNumberOfCalls(s.T(), "UpdateOutputsExecution", 2) s.repository.AssertExpectations(s.T()) @@ -191,7 +178,7 @@ func (s *EvmReaderSuite) TestOutputExecution() { } func (s *EvmReaderSuite) TestOutputExecutionOnFinalizedBlocks() { - wsClient := FakeWSEhtClient{} + wsClient := FakeWSEthClient{} s.evmReader.wsClient = &wsClient s.evmReader.defaultBlock = DefaultBlock_Finalized @@ -229,7 +216,7 @@ func (s *EvmReaderSuite) TestOutputExecutionOnFinalizedBlocks() { wsClient.fireNewHead(&header3) wsClient.fireNewHead(&header3) wsClient.fireNewHead(&header3) - time.Sleep(1 * time.Second) + wsClient.flushHeaders() s.repository.AssertNumberOfCalls(s.T(), "UpdateOutputsExecution", 2) s.repository.AssertExpectations(s.T()) @@ -243,7 +230,7 @@ func (s *EvmReaderSuite) TestOutputExecutionOnFinalizedBlocks() { } func (s *EvmReaderSuite) TestCheckOutputFailsWhenRetrieveOutputsFails() { - wsClient := FakeWSEhtClient{} + wsClient := FakeWSEthClient{} s.evmReader.wsClient = &wsClient s.setupOutputExecution() @@ -251,46 +238,16 @@ func (s *EvmReaderSuite) TestCheckOutputFailsWhenRetrieveOutputsFails() { s.applicationContract1.Unset("RetrieveOutputExecutionEvents") s.applicationContract1.On("RetrieveOutputExecutionEvents", mock.Anything, - ).Return([]*iapplication.IApplicationOutputExecuted{}, errors.New("No outputs for you")).Times(3) + ).Return([]*iapplication.IApplicationOutputExecuted{}, errors.New("No outputs for you")) - // If retrieving outputs fails, it does not update the database and keep scanning the ranges + // On-chain state: same as setupOutputExecution. Retrieval fails but oracle still works. s.applicationContract1.Unset("GetNumberOfExecutedOutputs") - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(0), nil).Once() - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(1), nil).Once() - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(0), nil).Twice() - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(1), nil).Twice() - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(0), nil).Twice() - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(2), nil).Once() - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(1), nil).Once() - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(0), nil).Once() - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(1), nil).Once() + s.applicationContract1.On("GetNumberOfExecutedOutputs", blockRange(0, 0x11)). + Return(new(big.Int).SetUint64(0), nil) + s.applicationContract1.On("GetNumberOfExecutedOutputs", blockRange(0x11, 0x13)). + Return(new(big.Int).SetUint64(1), nil) + s.applicationContract1.On("GetNumberOfExecutedOutputs", blockFrom(0x13)). + Return(new(big.Int).SetUint64(2), nil) apps := copyApplications(applications) s.repository.Unset("ListApplications") @@ -322,6 +279,9 @@ func (s *EvmReaderSuite) TestCheckOutputFailsWhenRetrieveOutputsFails() { mock.Anything, false, ).Return(apps, uint64(2), nil).Once() + // Catch-all for sentinel / extra headers + s.repository.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*Application{}, uint64(0), nil) s.repository.Unset("GetNumberOfExecutedOutputs") s.repository.On("GetNumberOfExecutedOutputs", @@ -364,7 +324,7 @@ func (s *EvmReaderSuite) TestCheckOutputFailsWhenRetrieveOutputsFails() { wsClient.fireNewHead(&header0) wsClient.fireNewHead(&header1) wsClient.fireNewHead(&header2) - time.Sleep(1 * time.Second) + wsClient.flushHeaders() s.repository.AssertNumberOfCalls(s.T(), "UpdateOutputsExecution", 0) s.repository.AssertExpectations(s.T()) @@ -378,7 +338,7 @@ func (s *EvmReaderSuite) TestCheckOutputFailsWhenRetrieveOutputsFails() { } func (s *EvmReaderSuite) TestCheckOutputFailsWhenGetOutputsFails() { - wsClient := FakeWSEhtClient{} + wsClient := FakeWSEthClient{} s.evmReader.wsClient = &wsClient s.setupOutputExecution() @@ -389,52 +349,22 @@ func (s *EvmReaderSuite) TestCheckOutputFailsWhenGetOutputsFails() { mock.Anything, mock.Anything).Return(nil, errors.New("no output for you")).Times(3) - // If retrieving outputs fails, it does not update the database and keep scanning the ranges + // On-chain state: same as setupOutputExecution. GetOutput fails but oracle still works. s.applicationContract1.Unset("GetNumberOfExecutedOutputs") - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(0), nil).Once() - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(1), nil).Once() - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(0), nil).Twice() - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(1), nil).Twice() - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(0), nil).Twice() - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(2), nil).Once() - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(1), nil).Once() - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(0), nil).Once() - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(1), nil).Once() + s.applicationContract1.On("GetNumberOfExecutedOutputs", blockRange(0, 0x11)). + Return(new(big.Int).SetUint64(0), nil) + s.applicationContract1.On("GetNumberOfExecutedOutputs", blockRange(0x11, 0x13)). + Return(new(big.Int).SetUint64(1), nil) + s.applicationContract1.On("GetNumberOfExecutedOutputs", blockFrom(0x13)). + Return(new(big.Int).SetUint64(2), nil) s.applicationContract1.Unset("RetrieveOutputExecutionEvents") s.applicationContract1.On("RetrieveOutputExecutionEvents", - mock.Anything, - ).Return([]*iapplication.IApplicationOutputExecuted{outputExecution0}, nil).Times(3) + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x11 }), + ).Return([]*iapplication.IApplicationOutputExecuted{outputExecution0}, nil) s.applicationContract1.On("RetrieveOutputExecutionEvents", - mock.Anything, - ).Return([]*iapplication.IApplicationOutputExecuted{outputExecution1}, nil).Once() + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x13 }), + ).Return([]*iapplication.IApplicationOutputExecuted{outputExecution1}, nil) apps := copyApplications(applications) s.repository.Unset("ListApplications") @@ -466,6 +396,9 @@ func (s *EvmReaderSuite) TestCheckOutputFailsWhenGetOutputsFails() { mock.Anything, false, ).Return(apps, uint64(2), nil).Once() + // Catch-all for sentinel / extra headers + s.repository.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*Application{}, uint64(0), nil) s.repository.Unset("GetNumberOfExecutedOutputs") s.repository.On("GetNumberOfExecutedOutputs", @@ -507,7 +440,7 @@ func (s *EvmReaderSuite) TestCheckOutputFailsWhenGetOutputsFails() { wsClient.fireNewHead(&header0) wsClient.fireNewHead(&header1) wsClient.fireNewHead(&header2) - time.Sleep(1 * time.Second) + wsClient.flushHeaders() s.repository.AssertNumberOfCalls(s.T(), "UpdateOutputsExecution", 0) s.repository.AssertExpectations(s.T()) @@ -540,11 +473,11 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { } logLevel, err := config.GetLogLevel() - s.Require().Nil(err) + s.Require().NoError(err) serviceArgs := &service.CreateInfo{Name: "evm-reader", Impl: s.evmReader, LogLevel: logLevel} err = service.Create(context.Background(), serviceArgs, &s.evmReader.Service) - s.Require().Nil(err) + s.Require().NoError(err) apps := copyApplications(applications) s.repository.On("ListApplications", @@ -571,6 +504,9 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { mock.Anything, false, ).Return(apps, uint64(1), nil).Once() + // Catch-all for sentinel / extra headers + s.repository.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*Application{}, uint64(0), nil) s.repository.On("UpdateEventLastCheckBlock", mock.Anything, @@ -627,34 +563,21 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { mock.Anything, ).Return(new(big.Int).SetUint64(0x10), nil).Once() - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(0), nil).Once() - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(1), nil).Once() - s.applicationContract1.On("GetNumberOfExecutedOutputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(0), nil).Once() + // On-chain state: 0 executed outputs before 0x11, 1 from 0x11 + s.applicationContract1.On("GetNumberOfExecutedOutputs", blockRange(0, 0x11)). + Return(new(big.Int).SetUint64(0), nil) + s.applicationContract1.On("GetNumberOfExecutedOutputs", blockFrom(0x11)). + Return(new(big.Int).SetUint64(1), nil) s.applicationContract1.On("RetrieveOutputExecutionEvents", - mock.Anything, - ).Return([]*iapplication.IApplicationOutputExecuted{outputExecution0}, nil).Once() + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x11 }), + ).Return([]*iapplication.IApplicationOutputExecuted{outputExecution0}, nil) - events0 := []iinputbox.IInputBoxInputAdded{inputAddedEvent0} - retrieveInputsOpts0 := bind.FilterOpts{ - Context: s.ctx, - Start: 0x11, - End: Pointer(uint64(0x11)), - } s.inputBox.On("RetrieveInputs", - &retrieveInputsOpts0, + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x11 }), mock.Anything, mock.Anything, - ).Return(events0, nil).Once() + ).Return([]iinputbox.IInputBoxInputAdded{inputAddedEvent0}, nil) s.inputBox.On("GetNumberOfInputs", mock.Anything, @@ -673,18 +596,18 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { mock.MatchedBy(func(app *Application) bool { return app.IApplicationAddress == applications[0].IApplicationAddress }), - ).Return(s.applicationContract1, s.inputBox, nil) + ).Return(s.applicationContract1, s.inputBox, nil, nil) s.contractFactory.On("CreateAdapters", mock.MatchedBy(func(app *Application) bool { return app.IApplicationAddress == applications[1].IApplicationAddress }), - ).Return(s.applicationContract2, nil, nil) + ).Return(s.applicationContract2, nil, nil, nil) } func (s *EvmReaderSuite) TestCheckOutputFailsWhenOutputMismatches() { s.setupOutputMismatchTest() - wsClient := FakeWSEhtClient{} + wsClient := FakeWSEthClient{} s.evmReader.wsClient = &wsClient // Start service @@ -705,7 +628,7 @@ func (s *EvmReaderSuite) TestCheckOutputFailsWhenOutputMismatches() { wsClient.fireNewHead(&header0) wsClient.fireNewHead(&header1) wsClient.fireNewHead(&header2) - time.Sleep(1 * time.Second) + wsClient.flushHeaders() s.repository.AssertNumberOfCalls(s.T(), "UpdateOutputsExecution", 0) s.repository.AssertExpectations(s.T()) diff --git a/internal/evmreader/testdata/header_0.json b/internal/evmreader/testdata/header_0.json deleted file mode 100644 index a8095a116..000000000 --- a/internal/evmreader/testdata/header_0.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "number": "0x11", - "gasUsed": "0x11ddc", - "gasLimit": "0x1c9c380", - "extraData": "0x", - "timestamp": "0x6653e99a", - "difficulty": "0x0", - "parentHash":"0x0000000000000000000000000000000000000000000000000000000000000000", - "sha3Uncles":"0x0000000000000000000000000000000000000000000000000000000000000000", - "stateRoot":"0x0000000000000000000000000000000000000000000000000000000000000000", - "transactionsRoot":"0x0000000000000000000000000000000000000000000000000000000000000000", - "receiptsRoot":"0x0000000000000000000000000000000000000000000000000000000000000000", - "logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" -} diff --git a/internal/evmreader/testdata/header_1.json b/internal/evmreader/testdata/header_1.json deleted file mode 100644 index 6d003b609..000000000 --- a/internal/evmreader/testdata/header_1.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "number": "0x12", - "gasUsed": "0x11ddc", - "gasLimit": "0x1c9c380", - "extraData": "0x", - "timestamp": "0x6653e99b", - "difficulty": "0x0", - "parentHash":"0x0000000000000000000000000000000000000000000000000000000000000000", - "sha3Uncles":"0x0000000000000000000000000000000000000000000000000000000000000000", - "stateRoot":"0x0000000000000000000000000000000000000000000000000000000000000000", - "transactionsRoot":"0x0000000000000000000000000000000000000000000000000000000000000000", - "receiptsRoot":"0x0000000000000000000000000000000000000000000000000000000000000000", - "logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" -} diff --git a/internal/evmreader/testdata/header_2.json b/internal/evmreader/testdata/header_2.json deleted file mode 100644 index ff16f48ac..000000000 --- a/internal/evmreader/testdata/header_2.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "number": "0x13", - "gasUsed": "0x11ddc", - "gasLimit": "0x1c9c380", - "extraData": "0x", - "timestamp": "0x6653e99c", - "difficulty": "0x0", - "parentHash":"0x0000000000000000000000000000000000000000000000000000000000000000", - "sha3Uncles":"0x0000000000000000000000000000000000000000000000000000000000000000", - "stateRoot":"0x0000000000000000000000000000000000000000000000000000000000000000", - "transactionsRoot":"0x0000000000000000000000000000000000000000000000000000000000000000", - "receiptsRoot":"0x0000000000000000000000000000000000000000000000000000000000000000", - "logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" -} diff --git a/internal/evmreader/testdata/header_3.json b/internal/evmreader/testdata/header_3.json deleted file mode 100644 index 6a55b5484..000000000 --- a/internal/evmreader/testdata/header_3.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "number": "0x33", - "gasUsed": "0x11ddc", - "gasLimit": "0x1c9c380", - "extraData": "0x", - "timestamp": "0x6653eabc", - "difficulty": "0x0", - "parentHash":"0x0000000000000000000000000000000000000000000000000000000000000000", - "sha3Uncles":"0x0000000000000000000000000000000000000000000000000000000000000000", - "stateRoot":"0x0000000000000000000000000000000000000000000000000000000000000000", - "transactionsRoot":"0x0000000000000000000000000000000000000000000000000000000000000000", - "receiptsRoot":"0x0000000000000000000000000000000000000000000000000000000000000000", - "logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" -} diff --git a/internal/evmreader/testdata/input_added_event_0.json b/internal/evmreader/testdata/input_added_event_0.json deleted file mode 100644 index 3b24005b6..000000000 --- a/internal/evmreader/testdata/input_added_event_0.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "AppContract": "0x2e663fe9ae92275242406a185aa4fc8174339d3e", - "Index": 0, - "Input": "zH3uHwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHppAAAAAAAAAAAAAAAAtyyDLd6hAyYUODHx5fFkaSDJyZAAAAAAAAAAAAAAAADzn9blGq2I9vTOariCcnnP/7kiZgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGZM7voAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATerb7vAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", - "Raw": { - "address": "0xa1b8eb1f13d8d5db976a653bbdf8972cfd14691c", - "topics": [ - "0xc05d337121a6e8605c6ec0b72aa29c4210ffe6e5b9cefdd6a7058188a8f66f98", - "0x000000000000000000000000b72c832ddea10326143831f1e5f1646920c9c990", - "0x0000000000000000000000000000000000000000000000000000000000000000" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000124cc7dee1f0000000000000000000000000000000000000000000000000000000000007a69000000000000000000000000b72c832ddea10326143831f1e5f1646920c9c990000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266000000000000000000000000000000000000000000000000000000000000001100000000000000000000000000000000000000000000000000000000664ceefa000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000004deadbeef0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "blockNumber": "0x11", - "transactionHash": "0xc405b17d1216fa348ac399df99703ec97cd77b03c9790cca8acddffe7ce4e901", - "transactionIndex": "0x0", - "blockHash": "0x03f50ead05dc4700559e7da32724e6bef31acbfd9b96d823203a8737432b6ad4", - "logIndex": "0x0", - "removed": false - } -} diff --git a/internal/evmreader/testdata/input_added_event_1.json b/internal/evmreader/testdata/input_added_event_1.json deleted file mode 100644 index 212d5d9c8..000000000 --- a/internal/evmreader/testdata/input_added_event_1.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "AppContract": "0x2e663fe9ae92275242406a185aa4fc8174339d3e", - "Index": 1, - "Input": "zH3uHwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHppAAAAAAAAAAAAAAAAtyyDLd6hAyYUODHx5fFkaSDJyZAAAAAAAAAAAAAAAABwmXlwxRgS3DoBDH0BtQ4NF9x5yAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGZM71kAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAS+rb7vAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", - "Raw": { - "address": "0xa1b8eb1f13d8d5db976a653bbdf8972cfd14691c", - "topics": [ - "0xc05d337121a6e8605c6ec0b72aa29c4210ffe6e5b9cefdd6a7058188a8f66f98", - "0x000000000000000000000000b72c832ddea10326143831f1e5f1646920c9c990", - "0x0000000000000000000000000000000000000000000000000000000000000001" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000124cc7dee1f0000000000000000000000000000000000000000000000000000000000007a69000000000000000000000000b72c832ddea10326143831f1e5f1646920c9c99000000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000664cef59000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000004beadbeef0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "blockNumber": "0x12", - "transactionHash": "0x2ae7f7e308ecd1c75c43f073cf15692ac460d7578cc618be242b2b89354a3eb4", - "transactionIndex": "0x0", - "blockHash": "0xcf47fc77c83197f02fb95ea2dd4ac9a681f8cc270151d72765074b876041b584", - "logIndex": "0x0", - "removed": false - } -} diff --git a/internal/evmreader/testdata/input_added_event_2.json b/internal/evmreader/testdata/input_added_event_2.json deleted file mode 100644 index 5fa3ba0bc..000000000 --- a/internal/evmreader/testdata/input_added_event_2.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "AppContract": "0x2e663fe9ae92275242406a185aa4fc8174339d3e", - "Index": 2, - "Input": "zH3uHwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHppAAAAAAAAAAAAAAAALmY/6a6SJ1JCQGoYWqT8gXQznT4AAAAAAAAAAAAAAADzn9blGq2I9vTOariCcnnP/7kiZgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGZx6R8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATOrb7vAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", - "Raw": { - "address": "0xa1b8eb1f13d8d5db976a653bbdf8972cfd14691c", - "topics": [ - "0xc05d337121a6e8605c6ec0b72aa29c4210ffe6e5b9cefdd6a7058188a8f66f98", - "0x0000000000000000000000002e663fe9ae92275242406a185aa4fc8174339d3e", - "0x0000000000000000000000000000000000000000000000000000000000000002" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000124cc7dee1f0000000000000000000000000000000000000000000000000000000000007a690000000000000000000000002e663fe9ae92275242406a185aa4fc8174339d3e000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000000000013000000000000000000000000000000000000000000000000000000006671e91f000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000004ceadbeef0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "blockNumber": "0x13", - "transactionHash": "0xf936021c09a73cbd35ee4bc648e48a3524f2665d93b97c56510348098f32c48c", - "transactionIndex": "0x0", - "blockHash": "0x1bd76f02c7c57256a2025410ab902ad56942faed08df0cf812216e37f7cc9f97", - "logIndex": "0x0", - "removed": false - } -} diff --git a/internal/evmreader/testdata/input_added_event_3.json b/internal/evmreader/testdata/input_added_event_3.json deleted file mode 100644 index fbf2a8a8c..000000000 --- a/internal/evmreader/testdata/input_added_event_3.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "AppContract": "0x2e663fe9ae92275242406a185aa4fc8174339d3e", - "Index": 3, - "Input": "zH3uHwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHppAAAAAAAAAAAAAAAALmY/6a6SJ1JCQGoYWqT8gXQznT4AAAAAAAAAAAAAAADzn9blGq2I9vTOariCcnnP/7kiZgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGZx6R8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAT+rb7vAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", - "Raw": { - "address": "0xa1b8eb1f13d8d5db976a653bbdf8972cfd14691c", - "topics": [ - "0xc05d337121a6e8605c6ec0b72aa29c4210ffe6e5b9cefdd6a7058188a8f66f98", - "0x0000000000000000000000002e663fe9ae92275242406a185aa4fc8174339d3e", - "0x0000000000000000000000000000000000000000000000000000000000000003" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000124cc7dee1f0000000000000000000000000000000000000000000000000000000000007a690000000000000000000000002e663fe9ae92275242406a185aa4fc8174339d3e000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000000000013000000000000000000000000000000000000000000000000000000006671e91f000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000004feadbeef0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "blockNumber": "0x13", - "transactionHash": "0x9c100b1acf602f19798f699cb7c06e16949f39a6b0c99b8ea462f1f916ff0102", - "transactionIndex": "0x1", - "blockHash": "0x1bd76f02c7c57256a2025410ab902ad56942faed08df0cf812216e37f7cc9f97", - "logIndex": "0x1", - "removed": false - } -} From 7225b4b9e5c61d34d8d58ab26f7acb3803de626b Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:18:55 -0300 Subject: [PATCH 09/14] refactor(evmreader): extract epoch indexing into pure testable function --- internal/evmreader/input.go | 175 +++++---- internal/evmreader/input_indexing_test.go | 427 ++++++++++++++++++++++ 2 files changed, 523 insertions(+), 79 deletions(-) create mode 100644 internal/evmreader/input_indexing_test.go diff --git a/internal/evmreader/input.go b/internal/evmreader/input.go index 0b8fba271..c1f17b451 100644 --- a/internal/evmreader/input.go +++ b/internal/evmreader/input.go @@ -146,6 +146,79 @@ func (r *Service) checkForNewInputs( } } +// ErrInputForNonOpenEpoch indicates that an input was received for an epoch +// that is not open — a state that should never occur during normal operation. +var ErrInputForNonOpenEpoch = errors.New("received input for non-open epoch") + +// indexInputsIntoEpochs is a pure function that indexes a sorted list of inputs +// into their respective epochs based on block number and epoch length. +// It creates new epochs as needed and closes epochs when subsequent inputs fall +// into a later epoch. It also closes the final epoch if mostRecentBlockNumber +// has advanced past its last block. +// The returned map contains epoch pointers as keys and their inputs as values. +// An epoch may appear with an empty input slice if it was closed without receiving +// new inputs in this batch. +// Returns ErrInputForNonOpenEpoch if an input targets an already-closed epoch. +func indexInputsIntoEpochs( + epochLength uint64, + currentEpoch *Epoch, + inputs []*Input, + mostRecentBlockNumber uint64, +) (map[*Epoch][]*Input, error) { + epochInputMap := make(map[*Epoch][]*Input) + + for _, input := range inputs { + inputEpochIndex := calculateEpochIndex(epochLength, input.BlockNumber) + + // If input belongs into a new epoch, close the previous known one + if currentEpoch != nil { + if currentEpoch.Index == inputEpochIndex { + // Input can only be added to open epochs + if currentEpoch.Status != EpochStatus_Open { + return nil, fmt.Errorf( + "%w: epoch %d status %s, input %d", + ErrInputForNonOpenEpoch, + currentEpoch.Index, currentEpoch.Status, input.Index) + } + currentEpoch.InputIndexUpperBound = input.Index + 1 + } else { + if currentEpoch.Status == EpochStatus_Open { + currentEpoch.Status = EpochStatus_Closed + currentEpoch.InputIndexUpperBound = input.Index + if _, ok := epochInputMap[currentEpoch]; !ok { + epochInputMap[currentEpoch] = []*Input{} + } + } + currentEpoch = nil + } + } + if currentEpoch == nil { + currentEpoch = &Epoch{ + Index: inputEpochIndex, + FirstBlock: inputEpochIndex * epochLength, + LastBlock: (inputEpochIndex * epochLength) + epochLength - 1, + InputIndexLowerBound: input.Index, + InputIndexUpperBound: input.Index + 1, + Status: EpochStatus_Open, + } + epochInputMap[currentEpoch] = []*Input{} + } + + epochInputMap[currentEpoch] = append(epochInputMap[currentEpoch], input) + } + + // Indexed all inputs. Check if it is time to close the last epoch + if currentEpoch != nil && currentEpoch.Status == EpochStatus_Open && + mostRecentBlockNumber >= currentEpoch.LastBlock { + currentEpoch.Status = EpochStatus_Closed + if _, ok := epochInputMap[currentEpoch]; !ok { + epochInputMap[currentEpoch] = []*Input{} + } + } + + return epochInputMap, nil +} + // readAndStoreInputs reads, inputs from the InputSource given specific filter options, indexes // them into epochs and store the indexed inputs and epochs func (r *Service) readAndStoreInputs( @@ -199,89 +272,33 @@ func (r *Service) readAndStoreInputs( continue } - // Initialize epochs inputs map - var epochInputMap = make(map[*Epoch][]*Input) - // Index Inputs into epochs - for _, input := range inputs { - - inputEpochIndex := calculateEpochIndex(epochLength, input.BlockNumber) + // Index inputs into epochs using pure function + epochInputMap, err := indexInputsIntoEpochs( + epochLength, currentEpoch, inputs, mostRecentBlockNumber) + if err != nil { + if errors.Is(err, ErrInputForNonOpenEpoch) { + return r.setApplicationInoperable(ctx, app.application, + "Should never happen. %v", err) + } + return fmt.Errorf("error indexing inputs: %w", err) + } - // If input belongs into a new epoch, close the previous known one - if currentEpoch != nil { - r.Logger.Debug("Current epoch and new input", + for epoch, epochInputs := range epochInputMap { + if epoch.Status == EpochStatus_Closed { + r.Logger.Info("Closing epoch", "application", app.application.Name, "address", address, - "epoch_index", currentEpoch.Index, - "epoch_status", currentEpoch.Status, - "input_epoch_index", inputEpochIndex, - ) - if currentEpoch.Index == inputEpochIndex { - // Input can only be added to open epochs - if currentEpoch.Status != EpochStatus_Open { - return r.setApplicationInoperable(ctx, app.application, - "Received inputs for an epoch that was not open. Should never happen. Epoch %d Status %s, Input %d", - currentEpoch.Index, currentEpoch.Status, input.Index) - } - currentEpoch.InputIndexUpperBound = input.Index + 1 - } else { - if currentEpoch.Status == EpochStatus_Open { - currentEpoch.Status = EpochStatus_Closed - currentEpoch.InputIndexUpperBound = input.Index - r.Logger.Info("Closing epoch", - "application", app.application.Name, - "address", address, - "epoch_index", currentEpoch.Index, - "start", currentEpoch.FirstBlock, - "end", currentEpoch.LastBlock) - _, ok := epochInputMap[currentEpoch] - if !ok { - epochInputMap[currentEpoch] = []*Input{} - } - } - currentEpoch = nil - } - } - if currentEpoch == nil { - currentEpoch = &Epoch{ - Index: inputEpochIndex, - FirstBlock: inputEpochIndex * epochLength, - LastBlock: (inputEpochIndex * epochLength) + epochLength - 1, - InputIndexLowerBound: input.Index, - InputIndexUpperBound: input.Index + 1, - Status: EpochStatus_Open, - } - epochInputMap[currentEpoch] = []*Input{} - } - - r.Logger.Info("Found new Input", - "application", app.application.Name, - "address", address, - "index", input.Index, - "block", input.BlockNumber, - "epoch_index", inputEpochIndex) - - currentInputs, ok := epochInputMap[currentEpoch] - if !ok { - currentInputs = []*Input{} + "epoch_index", epoch.Index, + "start", epoch.FirstBlock, + "end", epoch.LastBlock) } - epochInputMap[currentEpoch] = append(currentInputs, input) - - } - - // Indexed all inputs. Check if it is time to close the last epoch - if currentEpoch != nil && currentEpoch.Status == EpochStatus_Open && - mostRecentBlockNumber >= currentEpoch.LastBlock { - currentEpoch.Status = EpochStatus_Closed - r.Logger.Info("Closing epoch", - "application", app.application.Name, - "address", address, - "epoch_index", currentEpoch.Index, - "start", currentEpoch.FirstBlock, - "end", currentEpoch.LastBlock) - // Add to inputMap so it is stored - _, ok := epochInputMap[currentEpoch] - if !ok { - epochInputMap[currentEpoch] = []*Input{} + for _, input := range epochInputs { + r.Logger.Info("Found new Input", + "application", app.application.Name, + "address", address, + "index", input.Index, + "block", input.BlockNumber, + "epoch_index", epoch.Index) } } diff --git a/internal/evmreader/input_indexing_test.go b/internal/evmreader/input_indexing_test.go new file mode 100644 index 000000000..15ccc150f --- /dev/null +++ b/internal/evmreader/input_indexing_test.go @@ -0,0 +1,427 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "math" + "testing" + "testing/quick" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/require" +) + +// makeInput creates a test input with the given index and block number. +func makeInput(index, blockNumber uint64) *Input { + return &Input{ + Index: index, + BlockNumber: blockNumber, + Status: InputCompletionStatus_None, + } +} + +// collectEpochs flattens the epoch→inputs map into sorted slices for assertions. +// Returns epochs sorted by Index and a parallel slice of input slices. +func collectEpochs(m map[*Epoch][]*Input) (epochs []*Epoch, inputs [][]*Input) { + for e, ins := range m { + epochs = append(epochs, e) + inputs = append(inputs, ins) + } + // Sort by epoch index for deterministic assertions + for i := 0; i < len(epochs); i++ { + for j := i + 1; j < len(epochs); j++ { + if epochs[j].Index < epochs[i].Index { + epochs[i], epochs[j] = epochs[j], epochs[i] + inputs[i], inputs[j] = inputs[j], inputs[i] + } + } + } + return +} + +func TestIndexInputsIntoEpochs(t *testing.T) { + const epochLength uint64 = 10 + + t.Run("InputsSpanningMultipleEpochs", func(t *testing.T) { + // Inputs in epochs 0, 1, 2 (blocks 5, 15, 25) with mostRecent=29 (epoch 2 not closed) + inputs := []*Input{ + makeInput(0, 5), // epoch 0 + makeInput(1, 15), // epoch 1 + makeInput(2, 25), // epoch 2 + } + + result, err := indexInputsIntoEpochs(epochLength, nil, inputs, 29) + require.NoError(t, err) + require.Len(t, result, 3) + + epochs, epochInputs := collectEpochs(result) + + // Epoch 0: closed (input 1 at block 15 moved past it) + require.Equal(t, uint64(0), epochs[0].Index) + require.Equal(t, EpochStatus_Closed, epochs[0].Status) + require.Equal(t, uint64(0), epochs[0].FirstBlock) + require.Equal(t, uint64(9), epochs[0].LastBlock) + require.Equal(t, uint64(0), epochs[0].InputIndexLowerBound) + require.Equal(t, uint64(1), epochs[0].InputIndexUpperBound) // set when closing: next input index + require.Len(t, epochInputs[0], 1) + require.Equal(t, uint64(0), epochInputs[0][0].Index) + + // Epoch 1: closed (input 2 at block 25 moved past it) + require.Equal(t, uint64(1), epochs[1].Index) + require.Equal(t, EpochStatus_Closed, epochs[1].Status) + require.Equal(t, uint64(10), epochs[1].FirstBlock) + require.Equal(t, uint64(19), epochs[1].LastBlock) + require.Equal(t, uint64(1), epochs[1].InputIndexLowerBound) + require.Equal(t, uint64(2), epochs[1].InputIndexUpperBound) + require.Len(t, epochInputs[1], 1) + + // Epoch 2: still open (mostRecent=29 < LastBlock=29, i.e. 29 >= 29 → closed!) + // Actually 29 >= 29, so epoch 2 IS closed. + require.Equal(t, uint64(2), epochs[2].Index) + require.Equal(t, EpochStatus_Closed, epochs[2].Status) + require.Equal(t, uint64(20), epochs[2].FirstBlock) + require.Equal(t, uint64(29), epochs[2].LastBlock) + require.Len(t, epochInputs[2], 1) + }) + + t.Run("InputsSpanningMultipleEpochsLastOpen", func(t *testing.T) { + // Same inputs but mostRecent=28, so epoch 2 stays open + inputs := []*Input{ + makeInput(0, 5), // epoch 0 + makeInput(1, 15), // epoch 1 + makeInput(2, 25), // epoch 2 + } + + result, err := indexInputsIntoEpochs(epochLength, nil, inputs, 28) + require.NoError(t, err) + + epochs, _ := collectEpochs(result) + require.Len(t, epochs, 3) + + require.Equal(t, EpochStatus_Closed, epochs[0].Status) + require.Equal(t, EpochStatus_Closed, epochs[1].Status) + require.Equal(t, EpochStatus_Open, epochs[2].Status) // 28 < 29 + }) + + t.Run("NoInputsNoCurrentEpoch", func(t *testing.T) { + result, err := indexInputsIntoEpochs(epochLength, nil, nil, 100) + require.NoError(t, err) + require.Empty(t, result) + }) + + t.Run("NoInputsCurrentEpochStaysOpen", func(t *testing.T) { + // Current epoch at index 1 (blocks 10-19), mostRecent=15: should stay open + currentEpoch := &Epoch{ + Index: 1, + FirstBlock: 10, + LastBlock: 19, + InputIndexLowerBound: 0, + InputIndexUpperBound: 1, + Status: EpochStatus_Open, + } + + result, err := indexInputsIntoEpochs(epochLength, currentEpoch, nil, 15) + require.NoError(t, err) + require.Empty(t, result) // no inputs means no entries + require.Equal(t, EpochStatus_Open, currentEpoch.Status) + }) + + t.Run("NoInputsCurrentEpochClosedByBlockAdvance", func(t *testing.T) { + // Current epoch at index 1 (blocks 10-19), mostRecent=19: should close + currentEpoch := &Epoch{ + Index: 1, + FirstBlock: 10, + LastBlock: 19, + InputIndexLowerBound: 0, + InputIndexUpperBound: 1, + Status: EpochStatus_Open, + } + + result, err := indexInputsIntoEpochs(epochLength, currentEpoch, nil, 19) + require.NoError(t, err) + require.Len(t, result, 1) + require.Equal(t, EpochStatus_Closed, currentEpoch.Status) + // The epoch should be in the map with empty inputs + inputs, ok := result[currentEpoch] + require.True(t, ok) + require.Empty(t, inputs) + }) + + t.Run("InputForAlreadyClosedEpochReturnsError", func(t *testing.T) { + currentEpoch := &Epoch{ + Index: 1, + FirstBlock: 10, + LastBlock: 19, + Status: EpochStatus_Closed, // already closed + } + inputs := []*Input{ + makeInput(5, 15), // targets epoch 1 which is closed + } + + result, err := indexInputsIntoEpochs(epochLength, currentEpoch, inputs, 25) + require.Error(t, err) + require.ErrorIs(t, err, ErrInputForNonOpenEpoch) + require.Nil(t, result) + }) + + t.Run("InputForInputsProcessedEpochReturnsError", func(t *testing.T) { + currentEpoch := &Epoch{ + Index: 1, + FirstBlock: 10, + LastBlock: 19, + Status: EpochStatus_InputsProcessed, + } + inputs := []*Input{ + makeInput(5, 15), + } + + result, err := indexInputsIntoEpochs(epochLength, currentEpoch, inputs, 25) + require.Error(t, err) + require.ErrorIs(t, err, ErrInputForNonOpenEpoch) + require.Nil(t, result) + }) + + t.Run("MultipleInputsSameEpoch", func(t *testing.T) { + inputs := []*Input{ + makeInput(0, 10), + makeInput(1, 12), + makeInput(2, 18), + } + + result, err := indexInputsIntoEpochs(epochLength, nil, inputs, 18) + require.NoError(t, err) + require.Len(t, result, 1) + + epochs, epochInputs := collectEpochs(result) + require.Equal(t, uint64(1), epochs[0].Index) + require.Equal(t, EpochStatus_Open, epochs[0].Status) // 18 < 19 + require.Equal(t, uint64(0), epochs[0].InputIndexLowerBound) + require.Equal(t, uint64(3), epochs[0].InputIndexUpperBound) // last input index + 1 + require.Len(t, epochInputs[0], 3) + }) + + t.Run("ContinuesExistingOpenEpoch", func(t *testing.T) { + currentEpoch := &Epoch{ + Index: 1, + FirstBlock: 10, + LastBlock: 19, + InputIndexLowerBound: 0, + InputIndexUpperBound: 2, // already has inputs 0,1 + Status: EpochStatus_Open, + } + inputs := []*Input{ + makeInput(2, 14), // same epoch, new input + makeInput(3, 17), + } + + result, err := indexInputsIntoEpochs(epochLength, currentEpoch, inputs, 17) + require.NoError(t, err) + + // The existing epoch pointer should be in the map + epochInputs, ok := result[currentEpoch] + require.True(t, ok) + require.Len(t, epochInputs, 2) + require.Equal(t, uint64(4), currentEpoch.InputIndexUpperBound) // updated to 3+1 + require.Equal(t, EpochStatus_Open, currentEpoch.Status) + }) + + t.Run("ExistingEpochClosedByNewEpochInput", func(t *testing.T) { + currentEpoch := &Epoch{ + Index: 0, + FirstBlock: 0, + LastBlock: 9, + InputIndexLowerBound: 0, + InputIndexUpperBound: 1, + Status: EpochStatus_Open, + } + inputs := []*Input{ + makeInput(1, 10), // epoch 1 → closes epoch 0 + } + + result, err := indexInputsIntoEpochs(epochLength, currentEpoch, inputs, 15) + require.NoError(t, err) + require.Len(t, result, 2) // old epoch (closed) + new epoch + + // Verify old epoch was closed + require.Equal(t, EpochStatus_Closed, currentEpoch.Status) + require.Equal(t, uint64(1), currentEpoch.InputIndexUpperBound) // set to input.Index + + epochs, epochInputs := collectEpochs(result) + + // Epoch 0: closed, no new inputs in this batch (but was in map) + require.Equal(t, uint64(0), epochs[0].Index) + require.Equal(t, EpochStatus_Closed, epochs[0].Status) + require.Empty(t, epochInputs[0]) + + // Epoch 1: open with the new input + require.Equal(t, uint64(1), epochs[1].Index) + require.Equal(t, EpochStatus_Open, epochs[1].Status) // 15 < 19 + require.Len(t, epochInputs[1], 1) + }) + + t.Run("EpochBoundaryExact", func(t *testing.T) { + // Input at exact epoch boundary (block 10 = first block of epoch 1) + inputs := []*Input{ + makeInput(0, 9), // last block of epoch 0 + makeInput(1, 10), // first block of epoch 1 + } + + result, err := indexInputsIntoEpochs(epochLength, nil, inputs, 10) + require.NoError(t, err) + require.Len(t, result, 2) + + epochs, epochInputs := collectEpochs(result) + require.Equal(t, uint64(0), epochs[0].Index) + require.Equal(t, EpochStatus_Closed, epochs[0].Status) + require.Len(t, epochInputs[0], 1) + + require.Equal(t, uint64(1), epochs[1].Index) + require.Equal(t, EpochStatus_Open, epochs[1].Status) + require.Len(t, epochInputs[1], 1) + }) + + t.Run("SkippedEpochs", func(t *testing.T) { + // Inputs in epoch 0 and epoch 5, skipping 1-4 (no inputs in between) + inputs := []*Input{ + makeInput(0, 5), // epoch 0 + makeInput(1, 55), // epoch 5 + } + + result, err := indexInputsIntoEpochs(epochLength, nil, inputs, 59) + require.NoError(t, err) + require.Len(t, result, 2) // only epochs with inputs + closed prev + + epochs, epochInputs := collectEpochs(result) + + // Epoch 0: closed when input at block 55 arrived + require.Equal(t, uint64(0), epochs[0].Index) + require.Equal(t, EpochStatus_Closed, epochs[0].Status) + require.Equal(t, uint64(1), epochs[0].InputIndexUpperBound) // next input index + require.Len(t, epochInputs[0], 1) + + // Epoch 5: closed by mostRecentBlockNumber (59 >= 59) + require.Equal(t, uint64(5), epochs[1].Index) + require.Equal(t, EpochStatus_Closed, epochs[1].Status) + require.Equal(t, uint64(50), epochs[1].FirstBlock) + require.Equal(t, uint64(59), epochs[1].LastBlock) + require.Len(t, epochInputs[1], 1) + }) +} + +func TestCalculateEpochIndex(t *testing.T) { + t.Run("BasicCases", func(t *testing.T) { + require.Equal(t, uint64(0), calculateEpochIndex(10, 0)) + require.Equal(t, uint64(0), calculateEpochIndex(10, 9)) + require.Equal(t, uint64(1), calculateEpochIndex(10, 10)) + require.Equal(t, uint64(1), calculateEpochIndex(10, 19)) + require.Equal(t, uint64(2), calculateEpochIndex(10, 20)) + }) + + t.Run("EpochLengthOne", func(t *testing.T) { + require.Equal(t, uint64(0), calculateEpochIndex(1, 0)) + require.Equal(t, uint64(42), calculateEpochIndex(1, 42)) + }) + + t.Run("LargeEpochLength", func(t *testing.T) { + require.Equal(t, uint64(0), calculateEpochIndex(1000, 999)) + require.Equal(t, uint64(1), calculateEpochIndex(1000, 1000)) + }) +} + +func TestCalculateEpochIndexProperty(t *testing.T) { + t.Run("BlockBelongsToExactlyOneEpoch", func(t *testing.T) { + f := func(epochLength uint64, blockNumber uint64) bool { + if epochLength == 0 { + return true // skip division by zero + } + idx := calculateEpochIndex(epochLength, blockNumber) + firstBlock := idx * epochLength + lastBlock := firstBlock + epochLength - 1 + + // Guard against overflow + if lastBlock < firstBlock { + return true // overflow, skip + } + + return blockNumber >= firstBlock && blockNumber <= lastBlock + } + require.NoError(t, quick.Check(f, nil)) + }) + + t.Run("ConsecutiveBlocksNeverSkipEpochs", func(t *testing.T) { + f := func(epochLength uint64, blockNumber uint64) bool { + if epochLength == 0 || blockNumber == math.MaxUint64 { + return true + } + idx1 := calculateEpochIndex(epochLength, blockNumber) + idx2 := calculateEpochIndex(epochLength, blockNumber+1) + // Consecutive blocks are either in the same epoch or adjacent epochs + return idx2 == idx1 || idx2 == idx1+1 + } + require.NoError(t, quick.Check(f, nil)) + }) +} + +func TestIndexInputsIntoEpochsProperty(t *testing.T) { + t.Run("MonotonicInputsProduceContiguousNonOverlappingEpochs", func(t *testing.T) { + const epochLength uint64 = 10 + + f := func(numInputs uint8) bool { + n := int(numInputs) % 20 // limit to 0-19 inputs + if n == 0 { + return true + } + + inputs := make([]*Input, n) + for i := range n { + inputs[i] = makeInput(uint64(i), uint64(i*3+1)) // monotonic blocks: 1,4,7,10,... + } + mostRecent := inputs[n-1].BlockNumber + epochLength // ensure past last epoch + + result, err := indexInputsIntoEpochs(epochLength, nil, inputs, mostRecent) + if err != nil { + return false + } + + epochs, epochInputs := collectEpochs(result) + + // 1. Every input must be assigned to exactly one epoch + totalInputs := 0 + for _, ins := range epochInputs { + totalInputs += len(ins) + } + if totalInputs != n { + return false + } + + // 2. Epochs must be non-overlapping (sorted by index, no duplicates) + for i := 1; i < len(epochs); i++ { + if epochs[i].Index <= epochs[i-1].Index { + return false + } + } + + // 3. Each input's block must fall within its epoch's block range + for i, ep := range epochs { + for _, inp := range epochInputs[i] { + if inp.BlockNumber < ep.FirstBlock || inp.BlockNumber > ep.LastBlock { + return false + } + } + } + + // 4. Input indices within each epoch must be monotonically increasing + for _, ins := range epochInputs { + for j := 1; j < len(ins); j++ { + if ins[j].Index <= ins[j-1].Index { + return false + } + } + } + + return true + } + require.NoError(t, quick.Check(f, nil)) + }) +} From 48a2a7df38db40d57db5c1aba039389e7d164343 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:47:24 -0300 Subject: [PATCH 10/14] test(evmreader): add critical error path tests for checkpoint safety --- internal/evmreader/error_paths_test.go | 496 +++++++++++++++++++++++++ 1 file changed, 496 insertions(+) create mode 100644 internal/evmreader/error_paths_test.go diff --git a/internal/evmreader/error_paths_test.go b/internal/evmreader/error_paths_test.go new file mode 100644 index 000000000..9fc317d25 --- /dev/null +++ b/internal/evmreader/error_paths_test.go @@ -0,0 +1,496 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "errors" + "math/big" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// --- Priority 1: CreateEpochsAndInputs error → checkpoint not advanced --- +// When CreateEpochsAndInputs fails for an app with inputs, the checkpoint must +// NOT be advanced. Apps with inputs are excluded from the no-input checkpoint +// update path, so no UpdateEventLastCheckBlock call should occur. +func (s *EvmReaderSuite) TestCreateEpochsAndInputsErrorDoesNotAdvanceCheckpoint() { + require := require.New(s.T()) + + addr := common.HexToAddress("0x1111111111111111111111111111111111111111") + + app := &Application{ + ID: 1, + Name: "test-app", + IApplicationAddress: addr, + IInputBoxAddress: inputBoxAddr, + EpochLength: 10, + LastInputCheckBlock: 100, + } + + // Input source: 0 inputs before block 105, 1 from block 105 + inputSrc := &MockInputBox{} + inputSrc.On("GetNumberOfInputs", blockRange(0, 105), mock.Anything). + Return(new(big.Int).SetUint64(0), nil) + inputSrc.On("GetNumberOfInputs", blockFrom(105), mock.Anything). + Return(new(big.Int).SetUint64(1), nil) + inputSrc.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 105 }), + mock.Anything, mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{makeInputEvent(addr, 0, 105)}, nil) + + apps := []appContracts{ + {application: app, inputSource: inputSrc}, + } + + repo := newMockRepository() + repo.On("GetNumberOfInputs", mock.Anything, mock.Anything). + Return(uint64(0), nil) + repo.On("GetEpoch", mock.Anything, mock.Anything, uint64(10)). + Return(nil, nil) + repo.On("CreateEpochsAndInputs", + mock.Anything, mock.Anything, mock.Anything, mock.Anything, + ).Return(errors.New("database connection lost")) + + s.evmReader.repository = repo + + err := s.evmReader.readAndStoreInputs(s.ctx, 100, 110, apps) + require.NoError(err) // per-app failure doesn't abort + + // CreateEpochsAndInputs was attempted + repo.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 1) + // Checkpoint must NOT be advanced + repo.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) +} + +// --- Priority 2: Sealed epoch input count mismatch → error, no epoch stored --- +// When fetchSealedEpochInputs returns fewer inputs than the sealed event +// declares, processSealedEpochEvent must return an error and NOT store the epoch. +func (s *SealedEpochsSuite) TestSealedEpochInputCountMismatchReturnsError() { + const sealBlock uint64 = 200 + tournamentAddr := common.HexToAddress("0xAAAA") + + app := appContracts{ + application: &Application{ + ID: 1, + Name: "test-app", + IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 10, + }, + inputSource: s.inputBox, + daveConsensus: s.dave, + } + + // Sealed event expects 2 inputs (indices 3 to 5), but only 1 exists on-chain. + event := &idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: big.NewInt(1), + InputIndexLowerBound: big.NewInt(3), + InputIndexUpperBound: big.NewInt(5), + Tournament: tournamentAddr, + Raw: types.Log{BlockNumber: sealBlock}, + } + + // Previous epoch (index 0) + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)). + Return(&Epoch{ + Index: 0, FirstBlock: 10, LastBlock: 100, + InputIndexLowerBound: 0, InputIndexUpperBound: 3, + }, nil) + s.repository.On("UpdateEpochClaimTransactionHash", + mock.Anything, mock.Anything, mock.Anything).Return(nil) + // Epoch 1 doesn't exist yet + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(1)). + Return(nil, nil) + + // On-chain: only 4 inputs total (1 new instead of expected 2) + s.inputBox.On("GetNumberOfInputs", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() < 150 + }), + mock.Anything, + ).Return(big.NewInt(3), nil) + s.inputBox.On("GetNumberOfInputs", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= 150 + }), + mock.Anything, + ).Return(big.NewInt(4), nil) + s.inputBox.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == 150 + }), + mock.Anything, mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{ + makeInputEvent(app1Addr, 3, 150), + }, nil) + + err := s.evmReader.processSealedEpochEvent(s.ctx, app, event) + s.Require().Error(err) + s.Require().ErrorContains(err, "input count mismatch") + s.Require().ErrorContains(err, "expected 2") + s.Require().ErrorContains(err, "got 1") + + // Epoch must NOT be stored + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) +} + +// --- Priority 3: Existing epoch FirstBlock mismatch → error --- +// When a sealed epoch already exists in the DB with a different FirstBlock +// than what the previous epoch's LastBlock implies, processSealedEpochEvent +// must return a data mismatch error. +func (s *SealedEpochsSuite) TestSealedEpochFirstBlockMismatchReturnsError() { + tournamentAddr := common.HexToAddress("0xBBBB") + + app := appContracts{ + application: &Application{ + ID: 1, + Name: "test-app", + IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, + }, + inputSource: s.inputBox, + daveConsensus: s.dave, + } + + // Epoch 1 sealed. Previous epoch (0) has LastBlock=100 → firstBlock should be 100. + event := &idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: big.NewInt(1), + InputIndexLowerBound: big.NewInt(3), + InputIndexUpperBound: big.NewInt(3), // no inputs + Tournament: tournamentAddr, + Raw: types.Log{BlockNumber: 200}, + } + + // Previous epoch + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)). + Return(&Epoch{ + Index: 0, FirstBlock: 10, LastBlock: 100, + InputIndexLowerBound: 0, InputIndexUpperBound: 3, + }, nil) + s.repository.On("UpdateEpochClaimTransactionHash", + mock.Anything, mock.Anything, mock.Anything).Return(nil) + + // Existing epoch 1 has WRONG FirstBlock (50 instead of 100) + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(1)). + Return(&Epoch{ + Index: 1, + FirstBlock: 50, // should be 100 (prevEpoch.LastBlock) + InputIndexLowerBound: 3, + }, nil) + + err := s.evmReader.processSealedEpochEvent(s.ctx, app, event) + s.Require().Error(err) + s.Require().ErrorContains(err, "data mismatch") + + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) +} + +// --- Priority 4: GetLastNonOpenEpoch nil → app set inoperable --- +// When processApplicationOpenEpoch finds no non-open epoch, the application +// must be set inoperable. This is an invariant of the DaveConsensus protocol. +func (s *SealedEpochsSuite) TestOpenEpochWithNoNonOpenEpochSetsInoperable() { + app := appContracts{ + application: &Application{ + ID: 1, + Name: "test-app", + IApplicationAddress: app1Addr, + }, + inputSource: s.inputBox, + } + + s.repository.On("GetLastNonOpenEpoch", mock.Anything, mock.Anything). + Return(nil, nil) + s.repository.On("UpdateApplicationState", + mock.Anything, int64(1), ApplicationState_Inoperable, mock.Anything, + ).Return(nil).Once() + + err := s.evmReader.processApplicationOpenEpoch(s.ctx, app, 200) + s.Require().Error(err) + s.Require().ErrorContains(err, "no non open epochs found") + + s.repository.AssertExpectations(s.T()) + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) +} + +// --- Priority 5: Epoch 0 with IInputBoxBlock=0 → error --- +// When processing sealed epoch 0 for an app without a configured InputBox +// block number, processSealedEpochEvent must return an error immediately. +func (s *SealedEpochsSuite) TestSealedEpoch0WithNoInputBoxBlockReturnsError() { + tournamentAddr := common.HexToAddress("0xCCCC") + + app := appContracts{ + application: &Application{ + ID: 1, + Name: "test-app", + IApplicationAddress: app1Addr, + IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 0, // misconfigured + }, + inputSource: s.inputBox, + daveConsensus: s.dave, + } + + event := &idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: big.NewInt(0), + InputIndexLowerBound: big.NewInt(0), + InputIndexUpperBound: big.NewInt(0), + Tournament: tournamentAddr, + Raw: types.Log{BlockNumber: 100}, + } + + err := s.evmReader.processSealedEpochEvent(s.ctx, app, event) + s.Require().Error(err) + s.Require().ErrorContains(err, "no InputBox block number defined") + + // No DB operations should occur + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) + s.repository.AssertNumberOfCalls(s.T(), "GetEpoch", 0) +} + +// --- Priority 6: UpdateOutputsExecution error → checkpoint not advanced --- +// When UpdateOutputsExecution fails, no UpdateEventLastCheckBlock for +// OutputExecuted should be called. The checkpoint update is embedded in +// UpdateOutputsExecution, so failure means no checkpoint advance. +func (s *EvmReaderSuite) TestUpdateOutputsExecutionErrorDoesNotAdvanceCheckpoint() { + appContract := &MockApplicationContract{} + // On-chain: 0 executed outputs before block 50, 1 from block 50 + appContract.On("GetNumberOfExecutedOutputs", blockRange(0, 50)). + Return(new(big.Int).SetUint64(0), nil) + appContract.On("GetNumberOfExecutedOutputs", blockFrom(50)). + Return(new(big.Int).SetUint64(1), nil) + appContract.On("RetrieveOutputExecutionEvents", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == 50 + }), + ).Return([]*iapplication.IApplicationOutputExecuted{outputExecution0}, nil) + + app := appContracts{ + application: &Application{ + ID: 1, + Name: "test-app", + IApplicationAddress: app1Addr, + }, + applicationContract: appContract, + } + + repo := newMockRepository() + repo.On("GetNumberOfExecutedOutputs", mock.Anything, mock.Anything). + Return(uint64(0), nil) + repo.On("GetOutput", mock.Anything, mock.Anything, uint64(0)). + Return(output0, nil) + repo.On("UpdateOutputsExecution", + mock.Anything, mock.Anything, mock.Anything, mock.Anything, + ).Return(errors.New("database connection lost")) + + s.evmReader.repository = repo + + s.evmReader.readAndUpdateOutputs(s.ctx, app, 40, 60) + + // UpdateOutputsExecution was attempted once (and failed) + repo.AssertNumberOfCalls(s.T(), "UpdateOutputsExecution", 1) + // Checkpoint must NOT be advanced + repo.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) +} + +// --- Priority 7: Block regression → no DB writes, warn logged --- +// When mostRecentBlockNumber < lastProcessedBlock (chain reorg or node +// misconfiguration), checkForNewInputs must not write to the database. +func (s *EvmReaderSuite) TestBlockRegressionDoesNotWriteToDb() { + app := &Application{ + Name: "test-app", + IApplicationAddress: app1Addr, + IInputBoxAddress: inputBoxAddr, + DataAvailability: DataAvailability_InputBox[:], + EpochLength: 10, + LastInputCheckBlock: 100, + } + + apps := []appContracts{ + {application: app}, + } + + repo := newMockRepository() + s.evmReader.repository = repo + + // mostRecentBlockNumber (90) < lastProcessedBlock (100) → block regression + s.evmReader.checkForNewInputs(s.ctx, apps, 90) + + // No DB writes should happen + repo.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) + repo.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) + repo.AssertNumberOfCalls(s.T(), "GetEpoch", 0) + repo.AssertNumberOfCalls(s.T(), "GetNumberOfInputs", 0) +} + +// --- Block regression in sealed epoch path → no DB writes --- +// When mostRecentBlockNumber < LastEpochCheckBlock, processApplicationSealedEpochs +// must skip processing and not write to the database. +func (s *SealedEpochsSuite) TestSealedEpochBlockRegressionDoesNotWriteToDb() { + app := appContracts{ + application: &Application{ + ID: 1, + Name: "test-app", + IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, + LastEpochCheckBlock: 200, + }, + daveConsensus: s.dave, + } + + // mostRecentBlockNumber (150) < LastEpochCheckBlock (200) → regression + err := s.evmReader.processApplicationSealedEpochs(s.ctx, app, 150) + s.Require().NoError(err) + + // No DB writes should occur + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) + s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) + s.repository.AssertNumberOfCalls(s.T(), "GetLastNonOpenEpoch", 0) +} + +// --- Block regression in output execution path → no DB writes --- +// When mostRecentBlockNumber < LastOutputCheckBlock, checkForOutputExecution +// must skip processing and not write to the database. +func (s *EvmReaderSuite) TestOutputBlockRegressionDoesNotWriteToDb() { + app := appContracts{ + application: &Application{ + ID: 1, + Name: "test-app", + IApplicationAddress: app1Addr, + LastOutputCheckBlock: 100, + }, + applicationContract: &MockApplicationContract{}, + } + + repo := newMockRepository() + s.evmReader.repository = repo + + // mostRecentBlockNumber (80) < LastOutputCheckBlock (100) + s.evmReader.checkForOutputExecution(s.ctx, []appContracts{app}, 80) + + // No DB writes should occur + repo.AssertNumberOfCalls(s.T(), "UpdateOutputsExecution", 0) + repo.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) + repo.AssertNumberOfCalls(s.T(), "GetNumberOfExecutedOutputs", 0) +} + +// --- EpochLength=0 sets app inoperable --- +// When an application with EpochLength=0 reaches the epoch indexing logic, +// it must be set inoperable to prevent division-by-zero in calculateEpochIndex. +func (s *EvmReaderSuite) TestEpochLengthZeroSetsAppInoperable() { + addr := common.HexToAddress("0x5555555555555555555555555555555555555555") + + inputSrc := &MockInputBox{} + // On-chain: constant 0 inputs (no transitions, fetchApplicationInputs succeeds) + inputSrc.On("GetNumberOfInputs", mock.Anything, mock.Anything). + Return(new(big.Int).SetUint64(0), nil) + + apps := []appContracts{{ + application: &Application{ + ID: 1, + Name: "test-app", + IApplicationAddress: addr, + IInputBoxAddress: inputBoxAddr, + DataAvailability: DataAvailability_InputBox[:], + EpochLength: 0, // will trigger inoperable + LastInputCheckBlock: 100, + }, + inputSource: inputSrc, + }} + + repo := newMockRepository() + repo.On("GetNumberOfInputs", mock.Anything, mock.Anything). + Return(uint64(0), nil) + repo.On("UpdateApplicationState", + mock.Anything, int64(1), ApplicationState_Inoperable, mock.Anything, + ).Return(nil) + repo.On("UpdateEventLastCheckBlock", + mock.Anything, mock.Anything, MonitoredEvent_InputAdded, mock.Anything, + ).Return(nil) + s.evmReader.repository = repo + + err := s.evmReader.readAndStoreInputs(s.ctx, 100, 110, apps) + s.Require().NoError(err) + + // App must be set inoperable + repo.AssertNumberOfCalls(s.T(), "UpdateApplicationState", 1) + // No epochs or inputs should be stored + repo.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) +} + +// --- Sealed epoch CreateEpochsAndInputs failure prevents checkpoint advance --- +// When CreateEpochsAndInputs fails inside processSealedEpochEvent, the error +// must propagate through processEpochTransition and FindTransitions, causing +// processApplicationSealedEpochs to return before calling UpdateEventLastCheckBlock. +func (s *SealedEpochsSuite) TestSealedEpochDBFailurePreventsCheckpointAdvance() { + const ( + sealBlock uint64 = 100 + mostRecentBlock uint64 = 200 + ) + tournamentAddr := common.HexToAddress("0xEEEE") + + app := appContracts{ + application: &Application{ + ID: 1, + Name: "test-app", + IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 10, + LastEpochCheckBlock: 50, + }, + inputSource: s.inputBox, + daveConsensus: s.dave, + } + + // Oracle: before sealBlock → -1, from sealBlock → 0 + s.dave.On("GetCurrentSealedEpoch", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Int64() < int64(sealBlock) + }), + ).Return(makeSealedEpochResult(-1, 0, 0, common.Address{}), nil) + s.dave.On("GetCurrentSealedEpoch", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= sealBlock + }), + ).Return(makeSealedEpochResult(0, 0, 0, tournamentAddr), nil) + + // RetrieveSealedEpochs at transition block + s.dave.On("RetrieveSealedEpochs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == sealBlock }), + ).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(0, 0, 0, sealBlock, tournamentAddr), + }, nil) + + // No previous sealed epochs + s.repository.On("GetLastNonOpenEpoch", mock.Anything, mock.Anything). + Return(nil, nil) + + // Epoch 0 doesn't exist + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)). + Return(nil, nil) + + // CreateEpochsAndInputs FAILS + s.repository.On("CreateEpochsAndInputs", + mock.Anything, mock.Anything, mock.Anything, mock.Anything, + ).Return(errors.New("database connection lost")) + + err := s.evmReader.processApplicationSealedEpochs(s.ctx, app, mostRecentBlock) + s.Require().Error(err) + s.Require().ErrorContains(err, "database connection lost") + + // CreateEpochsAndInputs was attempted + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 1) + // Checkpoint must NOT be advanced (error prevented reaching UpdateEventLastCheckBlock) + s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) +} From c84aa598b8959a9de2f5088ae67080b5c1c94373 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:07:22 -0300 Subject: [PATCH 11/14] test(evmreader): cover DaveConsensus sealed and open epoch paths --- internal/evmreader/dave_consensus_test.go | 493 ++++++++++++++++++++++ 1 file changed, 493 insertions(+) create mode 100644 internal/evmreader/dave_consensus_test.go diff --git a/internal/evmreader/dave_consensus_test.go b/internal/evmreader/dave_consensus_test.go new file mode 100644 index 000000000..fa0e5cb72 --- /dev/null +++ b/internal/evmreader/dave_consensus_test.go @@ -0,0 +1,493 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "math/big" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" +) + +// makeSealedEpochResult constructs the anonymous struct returned by GetCurrentSealedEpoch. +func makeSealedEpochResult( + epochNum int64, + lowerBound, upperBound uint64, + tournament common.Address, +) struct { + EpochNumber *big.Int + InputIndexLowerBound *big.Int + InputIndexUpperBound *big.Int + Tournament common.Address +} { + return struct { + EpochNumber *big.Int + InputIndexLowerBound *big.Int + InputIndexUpperBound *big.Int + Tournament common.Address + }{ + EpochNumber: big.NewInt(epochNum), + InputIndexLowerBound: new(big.Int).SetUint64(lowerBound), + InputIndexUpperBound: new(big.Int).SetUint64(upperBound), + Tournament: tournament, + } +} + +// makeSealedEpochEvent constructs an IDaveConsensusEpochSealed event for testing. +func makeSealedEpochEvent( + epochNum int64, + lowerBound, upperBound, blockNum uint64, + tournament common.Address, +) *idaveconsensus.IDaveConsensusEpochSealed { + return &idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: big.NewInt(epochNum), + InputIndexLowerBound: new(big.Int).SetUint64(lowerBound), + InputIndexUpperBound: new(big.Int).SetUint64(upperBound), + Tournament: tournament, + Raw: types.Log{ + BlockNumber: blockNum, + TxHash: common.BigToHash(new(big.Int).SetUint64(blockNum)), + }, + } +} + +// --- Test 1: Full sealed pipeline with 2 epoch transitions (no inputs) --- +// Exercises: processApplicationSealedEpochs → FindTransitions → processEpochTransition +// → processSealedEpochEvent, verifying the complete DaveConsensus sealed epoch pipeline. +func (s *SealedEpochsSuite) TestSealedEpochsEndToEndTwoTransitions() { + const ( + inputBoxBlock uint64 = 10 + searchStart uint64 = 50 + sealBlock0 uint64 = 100 + sealBlock1 uint64 = 150 + mostRecentBlock uint64 = 200 + ) + + tournamentAddr0 := common.HexToAddress("0xA000") + tournamentAddr1 := common.HexToAddress("0xA001") + + app := appContracts{ + application: &Application{ + ID: 1, + Name: "test-app", + IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: inputBoxBlock, + LastEpochCheckBlock: searchStart, + }, + inputSource: s.inputBox, + daveConsensus: s.dave, + } + + // Oracle: GetCurrentSealedEpoch returns epoch number based on block + s.dave.On("GetCurrentSealedEpoch", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Int64() < int64(sealBlock0) + }), + ).Return(makeSealedEpochResult(-1, 0, 0, common.Address{}), nil) + + s.dave.On("GetCurrentSealedEpoch", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + b := opts.BlockNumber.Uint64() + return b >= sealBlock0 && b < sealBlock1 + }), + ).Return(makeSealedEpochResult(0, 0, 0, tournamentAddr0), nil) + + s.dave.On("GetCurrentSealedEpoch", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= sealBlock1 + }), + ).Return(makeSealedEpochResult(1, 0, 0, tournamentAddr1), nil) + + // RetrieveSealedEpochs at each transition block + s.dave.On("RetrieveSealedEpochs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == sealBlock0 }), + ).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(0, 0, 0, sealBlock0, tournamentAddr0), + }, nil) + + s.dave.On("RetrieveSealedEpochs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == sealBlock1 }), + ).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(1, 0, 0, sealBlock1, tournamentAddr1), + }, nil) + + // No previous sealed epochs in DB + s.repository.On("GetLastNonOpenEpoch", mock.Anything, mock.Anything). + Return(nil, nil) + + // Epoch 0: first lookup → nil (new epoch) + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)). + Return(nil, nil).Once() + // Epoch 1: lookup of prev epoch (0) → returns epoch 0 data + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)). + Return(&Epoch{ + Index: 0, FirstBlock: inputBoxBlock, LastBlock: sealBlock0, + InputIndexLowerBound: 0, InputIndexUpperBound: 0, + }, nil) + // Epoch 1: self-lookup → nil (new epoch) + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(1)). + Return(nil, nil) + + s.repository.On("UpdateEpochClaimTransactionHash", + mock.Anything, mock.Anything, mock.Anything, + ).Return(nil) + + var storedEpochs []*Epoch + s.repository.On("CreateEpochsAndInputs", + mock.Anything, mock.Anything, mock.Anything, mock.Anything, + ).Run(func(args mock.Arguments) { + epochInputMap := args.Get(2).(map[*Epoch][]*Input) + for epoch := range epochInputMap { + storedEpochs = append(storedEpochs, epoch) + } + }).Return(nil) + + s.repository.On("UpdateEventLastCheckBlock", + mock.Anything, mock.Anything, MonitoredEvent_EpochSealed, mostRecentBlock, + ).Return(nil) + + err := s.evmReader.processApplicationSealedEpochs(s.ctx, app, mostRecentBlock) + s.Require().NoError(err) + + // Two epochs stored + s.Require().Len(storedEpochs, 2) + // Sort by index for deterministic assertions + if storedEpochs[0].Index > storedEpochs[1].Index { + storedEpochs[0], storedEpochs[1] = storedEpochs[1], storedEpochs[0] + } + + // Epoch 0 + s.Require().Equal(uint64(0), storedEpochs[0].Index) + s.Require().Equal(inputBoxBlock, storedEpochs[0].FirstBlock) + s.Require().Equal(sealBlock0, storedEpochs[0].LastBlock) + s.Require().Equal(EpochStatus_Closed, storedEpochs[0].Status) + s.Require().Equal(&tournamentAddr0, storedEpochs[0].TournamentAddress) + + // Epoch 1 (PRT overlap: FirstBlock = prevEpoch.LastBlock) + s.Require().Equal(uint64(1), storedEpochs[1].Index) + s.Require().Equal(sealBlock0, storedEpochs[1].FirstBlock) + s.Require().Equal(sealBlock1, storedEpochs[1].LastBlock) + s.Require().Equal(EpochStatus_Closed, storedEpochs[1].Status) + s.Require().Equal(&tournamentAddr1, storedEpochs[1].TournamentAddress) + + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 2) + s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 1) +} + +// --- Test 2: Open epoch happy path — new epoch with 1 input --- +// Exercises processApplicationOpenEpoch creating a new open epoch and fetching inputs. +func (s *SealedEpochsSuite) TestOpenEpochHappyPathCreatesNewEpoch() { + const ( + sealBlock uint64 = 100 + mostRecentBlock uint64 = 200 + ) + + app := appContracts{ + application: &Application{ + ID: 1, + Name: "test-app", + IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 10, + }, + inputSource: s.inputBox, + daveConsensus: s.dave, + } + + // Last sealed epoch + s.repository.On("GetLastNonOpenEpoch", mock.Anything, mock.Anything). + Return(&Epoch{ + Index: 0, FirstBlock: 10, LastBlock: sealBlock, + InputIndexLowerBound: 0, InputIndexUpperBound: 3, + }, nil) + + // Open epoch (index 1) doesn't exist yet + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(1)). + Return(nil, nil) + + // Last input check block + s.repository.On("GetEventLastCheckBlock", + mock.Anything, int64(1), MonitoredEvent_InputAdded, + ).Return(sealBlock, nil) + + // DB has 3 inputs + s.repository.On("GetNumberOfInputs", mock.Anything, mock.Anything). + Return(uint64(3), nil) + + // On-chain: 3 inputs before block 150, 4 from block 150 + s.inputBox.On("GetNumberOfInputs", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() < 150 + }), + mock.Anything, + ).Return(big.NewInt(3), nil) + s.inputBox.On("GetNumberOfInputs", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= 150 + }), + mock.Anything, + ).Return(big.NewInt(4), nil) + + // Input 3 at block 150 + s.inputBox.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 150 }), + mock.Anything, mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{makeInputEvent(app1Addr, 3, 150)}, nil) + + var storedEpoch *Epoch + var storedInputs []*Input + s.repository.On("CreateEpochsAndInputs", + mock.Anything, mock.Anything, mock.Anything, mock.Anything, + ).Run(func(args mock.Arguments) { + epochInputMap := args.Get(2).(map[*Epoch][]*Input) + for epoch, inputs := range epochInputMap { + storedEpoch = epoch + storedInputs = inputs + } + }).Return(nil) + + err := s.evmReader.processApplicationOpenEpoch(s.ctx, app, mostRecentBlock) + s.Require().NoError(err) + + s.Require().NotNil(storedEpoch) + s.Require().Equal(uint64(1), storedEpoch.Index) + s.Require().Equal(sealBlock, storedEpoch.FirstBlock) // PRT overlap + s.Require().Equal(mostRecentBlock, storedEpoch.LastBlock) + s.Require().Equal(uint64(3), storedEpoch.InputIndexLowerBound) + s.Require().Equal(uint64(4), storedEpoch.InputIndexUpperBound) // 3 + 1 new + s.Require().Equal(EpochStatus_Open, storedEpoch.Status) + + s.Require().Len(storedInputs, 1) + s.Require().Equal(uint64(3), storedInputs[0].Index) + s.Require().Equal(uint64(150), storedInputs[0].BlockNumber) +} + +// --- Test 3: Open epoch with existing epoch accumulates inputs --- +// When processApplicationOpenEpoch finds an existing open epoch in the DB, +// it must reuse it and add new inputs (incrementing InputIndexUpperBound). +func (s *SealedEpochsSuite) TestOpenEpochExistingEpochAccumulatesInputs() { + const mostRecentBlock uint64 = 300 + + app := appContracts{ + application: &Application{ + ID: 1, + Name: "test-app", + IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 10, + }, + inputSource: s.inputBox, + daveConsensus: s.dave, + } + + // Last sealed epoch + s.repository.On("GetLastNonOpenEpoch", mock.Anything, mock.Anything). + Return(&Epoch{ + Index: 0, FirstBlock: 10, LastBlock: 100, + InputIndexLowerBound: 0, InputIndexUpperBound: 3, + }, nil) + + // Open epoch already exists from a previous tick + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(1)). + Return(&Epoch{ + Index: 1, FirstBlock: 100, LastBlock: 200, + InputIndexLowerBound: 3, InputIndexUpperBound: 4, + Status: EpochStatus_Open, + }, nil) + + // Last input check at block 200 + s.repository.On("GetEventLastCheckBlock", + mock.Anything, int64(1), MonitoredEvent_InputAdded, + ).Return(uint64(200), nil) + + // DB has 4 inputs + s.repository.On("GetNumberOfInputs", mock.Anything, mock.Anything). + Return(uint64(4), nil) + + // On-chain: 4 inputs before block 250, 5 from block 250 + s.inputBox.On("GetNumberOfInputs", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() < 250 + }), + mock.Anything, + ).Return(big.NewInt(4), nil) + s.inputBox.On("GetNumberOfInputs", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= 250 + }), + mock.Anything, + ).Return(big.NewInt(5), nil) + + // Input 4 at block 250 + s.inputBox.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 250 }), + mock.Anything, mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{makeInputEvent(app1Addr, 4, 250)}, nil) + + var storedEpoch *Epoch + var storedInputs []*Input + s.repository.On("CreateEpochsAndInputs", + mock.Anything, mock.Anything, mock.Anything, mock.Anything, + ).Run(func(args mock.Arguments) { + epochInputMap := args.Get(2).(map[*Epoch][]*Input) + for epoch, inputs := range epochInputMap { + storedEpoch = epoch + storedInputs = inputs + } + }).Return(nil) + + err := s.evmReader.processApplicationOpenEpoch(s.ctx, app, mostRecentBlock) + s.Require().NoError(err) + + // Existing epoch reused with accumulated inputs + s.Require().Equal(uint64(1), storedEpoch.Index) + s.Require().Equal(uint64(100), storedEpoch.FirstBlock) // Preserved + s.Require().Equal(mostRecentBlock, storedEpoch.LastBlock) // Updated + s.Require().Equal(uint64(3), storedEpoch.InputIndexLowerBound) // Preserved + s.Require().Equal(uint64(5), storedEpoch.InputIndexUpperBound) // 4 + 1 new + s.Require().Equal(EpochStatus_Open, storedEpoch.Status) + + s.Require().Len(storedInputs, 1) + s.Require().Equal(uint64(4), storedInputs[0].Index) +} + +// --- Test 4: Multiple EpochSealed events in one block --- +// When two epochs are sealed in the same block, FindTransitions sees a single +// transition, but RetrieveSealedEpochs returns both events and each is processed. +func (s *SealedEpochsSuite) TestMultipleSealedEpochsInOneBlock() { + const ( + sealBlock uint64 = 100 + mostRecentBlock uint64 = 200 + ) + + tournamentAddr0 := common.HexToAddress("0xA000") + tournamentAddr1 := common.HexToAddress("0xA001") + + app := appContracts{ + application: &Application{ + ID: 1, + Name: "test-app", + IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 10, + LastEpochCheckBlock: 50, + }, + inputSource: s.inputBox, + daveConsensus: s.dave, + } + + // Oracle: before sealBlock → -1, from sealBlock → 1 (both sealed at same block) + s.dave.On("GetCurrentSealedEpoch", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Int64() < int64(sealBlock) + }), + ).Return(makeSealedEpochResult(-1, 0, 0, common.Address{}), nil) + + s.dave.On("GetCurrentSealedEpoch", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= sealBlock + }), + ).Return(makeSealedEpochResult(1, 0, 0, tournamentAddr1), nil) + + // Both sealed events at the same block + s.dave.On("RetrieveSealedEpochs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == sealBlock }), + ).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(0, 0, 0, sealBlock, tournamentAddr0), + makeSealedEpochEvent(1, 0, 0, sealBlock, tournamentAddr1), + }, nil) + + // No previous sealed epochs + s.repository.On("GetLastNonOpenEpoch", mock.Anything, mock.Anything). + Return(nil, nil) + + // Epoch 0: self-lookup → nil (new) + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)). + Return(nil, nil).Once() + // Epoch 1: prev epoch lookup → epoch 0 data + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)). + Return(&Epoch{ + Index: 0, FirstBlock: 10, LastBlock: sealBlock, + InputIndexLowerBound: 0, InputIndexUpperBound: 0, + }, nil) + // Epoch 1: self-lookup → nil (new) + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(1)). + Return(nil, nil) + + s.repository.On("UpdateEpochClaimTransactionHash", + mock.Anything, mock.Anything, mock.Anything, + ).Return(nil) + + var storedEpochs []*Epoch + s.repository.On("CreateEpochsAndInputs", + mock.Anything, mock.Anything, mock.Anything, mock.Anything, + ).Run(func(args mock.Arguments) { + epochInputMap := args.Get(2).(map[*Epoch][]*Input) + for epoch := range epochInputMap { + storedEpochs = append(storedEpochs, epoch) + } + }).Return(nil) + + s.repository.On("UpdateEventLastCheckBlock", + mock.Anything, mock.Anything, MonitoredEvent_EpochSealed, mostRecentBlock, + ).Return(nil) + + err := s.evmReader.processApplicationSealedEpochs(s.ctx, app, mostRecentBlock) + s.Require().NoError(err) + + s.Require().Len(storedEpochs, 2) + if storedEpochs[0].Index > storedEpochs[1].Index { + storedEpochs[0], storedEpochs[1] = storedEpochs[1], storedEpochs[0] + } + + s.Require().Equal(uint64(0), storedEpochs[0].Index) + s.Require().Equal(uint64(10), storedEpochs[0].FirstBlock) + s.Require().Equal(sealBlock, storedEpochs[0].LastBlock) + + s.Require().Equal(uint64(1), storedEpochs[1].Index) + s.Require().Equal(sealBlock, storedEpochs[1].FirstBlock) // PRT overlap + s.Require().Equal(sealBlock, storedEpochs[1].LastBlock) // Same block + + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 2) +} + +// --- Test 5: initializeNewApplicationSealedEpochSync sets checkpoint --- +// On first run, the deployment block is fetched from DaveConsensus and the +// LastEpochCheckBlock is set to deploymentBlock - 1. +func (s *SealedEpochsSuite) TestInitializeSealedEpochSyncSetsCheckpoint() { + app := appContracts{ + application: &Application{ + ID: 1, + Name: "test-app", + IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, + }, + daveConsensus: s.dave, + } + + // DaveConsensus deployed at block 50 + s.dave.On("GetDeploymentBlockNumber", mock.Anything). + Return(big.NewInt(50), nil) + + s.repository.On("UpdateEventLastCheckBlock", + mock.Anything, []int64{int64(1)}, MonitoredEvent_EpochSealed, uint64(49), + ).Return(nil) + + err := s.evmReader.initializeNewApplicationSealedEpochSync(s.ctx, &app, 200) + s.Require().NoError(err) + + // LastEpochCheckBlock set to deploymentBlock - 1 + s.Require().Equal(uint64(49), app.application.LastEpochCheckBlock) + s.repository.AssertExpectations(s.T()) +} From 13a60a3cb487892e50aa2ea4a965a56e8018fd42 Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:16:42 -0300 Subject: [PATCH 12/14] test(evmreader): cover service entry point, config, and feature flags --- internal/evmreader/service_config_test.go | 195 ++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 internal/evmreader/service_config_test.go diff --git a/internal/evmreader/service_config_test.go b/internal/evmreader/service_config_test.go new file mode 100644 index 000000000..d8e251e87 --- /dev/null +++ b/internal/evmreader/service_config_test.go @@ -0,0 +1,195 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "encoding/json" + "errors" + "math/big" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// --- Create() entry point tests --- + +func TestCreateWithCancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := Create(ctx, &CreateInfo{}) + require.ErrorIs(t, err, context.Canceled) +} + +func TestCreateWithNilEthClient(t *testing.T) { + config.SetDefaults() + logLevel, err := config.GetLogLevel() + require.NoError(t, err) + + _, err = Create(context.Background(), &CreateInfo{ + CreateInfo: service.CreateInfo{Name: "evm-reader", LogLevel: logLevel}, + }) + require.ErrorContains(t, err, "EthClient on evmreader service Create is nil") +} + +// --- fetchMostRecentHeader tests --- + +func (s *EvmReaderSuite) TestFetchMostRecentHeaderRPCError() { + s.client.On("HeaderByNumber", mock.Anything, mock.Anything). + Return((*types.Header)(nil), errors.New("RPC connection timeout")) + + _, err := s.evmReader.fetchMostRecentHeader(s.ctx, DefaultBlock_Finalized) + s.Require().Error(err) + s.Require().ErrorContains(err, "failed to retrieve header") + s.Require().ErrorContains(err, "RPC connection timeout") +} + +func (s *EvmReaderSuite) TestFetchMostRecentHeaderNilHeader() { + s.client.On("HeaderByNumber", mock.Anything, mock.Anything). + Return((*types.Header)(nil), nil) + + _, err := s.evmReader.fetchMostRecentHeader(s.ctx, DefaultBlock_Finalized) + s.Require().Error(err) + s.Require().ErrorContains(err, "returned header is nil") +} + +func (s *EvmReaderSuite) TestFetchMostRecentHeaderUnsupportedBlock() { + _, err := s.evmReader.fetchMostRecentHeader(s.ctx, DefaultBlock("INVALID")) + s.Require().Error(err) + s.Require().ErrorContains(err, "not supported") +} + +func (s *EvmReaderSuite) TestFetchMostRecentHeaderSuccess() { + expected := &types.Header{Number: big.NewInt(42)} + s.client.On("HeaderByNumber", mock.Anything, mock.Anything). + Return(expected, nil) + + header, err := s.evmReader.fetchMostRecentHeader(s.ctx, DefaultBlock_Finalized) + s.Require().NoError(err) + s.Require().Equal(expected, header) +} + +// --- inputReaderEnabled feature flag tests --- + +func (s *EvmReaderSuite) TestInputReaderDisabledSkipsInputChecks() { + s.evmReader.inputReaderEnabled = false + + app := &Application{ + Name: "test-app", + IApplicationAddress: app1Addr, + IInputBoxAddress: inputBoxAddr, + DataAvailability: DataAvailability_InputBox[:], + EpochLength: 10, + LastInputCheckBlock: 100, + } + apps := []appContracts{{application: app}} + + repo := newMockRepository() + s.evmReader.repository = repo + + s.evmReader.checkForNewInputs(s.ctx, apps, 200) + + repo.AssertNumberOfCalls(s.T(), "GetNumberOfInputs", 0) + repo.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) + repo.AssertNumberOfCalls(s.T(), "GetEpoch", 0) +} + +func (s *EvmReaderSuite) TestInputReaderDisabledSkipsEpochChecks() { + s.evmReader.inputReaderEnabled = false + + apps := []appContracts{{ + application: &Application{ + Name: "test-app", + IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, + }, + }} + + repo := newMockRepository() + s.evmReader.repository = repo + + s.evmReader.checkForEpochsAndInputs(s.ctx, apps, 200) + + repo.AssertNumberOfCalls(s.T(), "GetLastNonOpenEpoch", 0) + repo.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) +} + +// --- setupPersistentConfig tests --- + +func (s *EvmReaderSuite) TestSetupPersistentConfigFirstRun() { + repo := newMockRepository() + repo.On("LoadNodeConfigRaw", mock.Anything, EvmReaderConfigKey). + Return(([]byte)(nil), time.Time{}, time.Time{}, repository.ErrNotFound) + repo.On("SaveNodeConfigRaw", mock.Anything, EvmReaderConfigKey, mock.Anything). + Return(nil) + + s.evmReader.repository = repo + + cfg := &config.EvmreaderConfig{ + BlockchainDefaultBlock: DefaultBlock_Finalized, + FeatureInputReaderEnabled: true, + BlockchainId: 42, + } + + result, err := s.evmReader.setupPersistentConfig(s.ctx, cfg) + s.Require().NoError(err) + s.Require().NotNil(result) + s.Require().Equal(DefaultBlock_Finalized, result.DefaultBlock) + s.Require().True(result.InputReaderEnabled) + s.Require().Equal(uint64(42), result.ChainID) + + repo.AssertNumberOfCalls(s.T(), "SaveNodeConfigRaw", 1) +} + +func (s *EvmReaderSuite) TestSetupPersistentConfigExistingConfigWins() { + existingJSON, err := json.Marshal(PersistentConfig{ + DefaultBlock: DefaultBlock_Safe, + InputReaderEnabled: false, + ChainID: 99, + }) + s.Require().NoError(err) + + repo := newMockRepository() + repo.On("LoadNodeConfigRaw", mock.Anything, EvmReaderConfigKey). + Return(existingJSON, time.Now(), time.Now(), nil) + + s.evmReader.repository = repo + + // Env config has DIFFERENT values — should be ignored + cfg := &config.EvmreaderConfig{ + BlockchainDefaultBlock: DefaultBlock_Latest, + FeatureInputReaderEnabled: true, + BlockchainId: 1, + } + + result, err := s.evmReader.setupPersistentConfig(s.ctx, cfg) + s.Require().NoError(err) + + // Existing config wins + s.Require().Equal(DefaultBlock_Safe, result.DefaultBlock) + s.Require().False(result.InputReaderEnabled) + s.Require().Equal(uint64(99), result.ChainID) + + // SaveNodeConfigRaw must NOT be called + repo.AssertNumberOfCalls(s.T(), "SaveNodeConfigRaw", 0) +} + +func (s *EvmReaderSuite) TestSetupPersistentConfigDBError() { + repo := newMockRepository() + repo.On("LoadNodeConfigRaw", mock.Anything, EvmReaderConfigKey). + Return(([]byte)(nil), time.Time{}, time.Time{}, errors.New("database unreachable")) + + s.evmReader.repository = repo + + _, err := s.evmReader.setupPersistentConfig(s.ctx, &config.EvmreaderConfig{}) + s.Require().Error(err) + s.Require().ErrorContains(err, "database unreachable") +} From c197f766d7f06cfb7d99a6c7786775b5c1416fdb Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:35:19 -0300 Subject: [PATCH 13/14] test(evmreader): add edge case and adversarial input tests --- internal/evmreader/edge_cases_test.go | 305 ++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 internal/evmreader/edge_cases_test.go diff --git a/internal/evmreader/edge_cases_test.go b/internal/evmreader/edge_cases_test.go new file mode 100644 index 000000000..ce4f09756 --- /dev/null +++ b/internal/evmreader/edge_cases_test.go @@ -0,0 +1,305 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "errors" + "math/big" + "time" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" +) + +// --- #19: Duplicate/out-of-order input events within a block --- +// When RetrieveInputs returns duplicate events (same index) at a single block, +// insertSorted must deduplicate them. Out-of-order events must be sorted by index. +func (s *EvmReaderSuite) TestFetchApplicationInputsDuplicateAndOutOfOrderEvents() { + addr := common.HexToAddress("0x3333333333333333333333333333333333333333") + + inputSrc := &MockInputBox{} + app := appContracts{ + application: &Application{ + Name: "test-app", + IApplicationAddress: addr, + IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 10, + }, + inputSource: inputSrc, + } + + repo := newMockRepository() + repo.On("GetNumberOfInputs", mock.Anything, mock.Anything). + Return(uint64(0), nil) + s.evmReader.repository = repo + + // On-chain: 0 inputs before block 100, 3 from block 100 + inputSrc.On("GetNumberOfInputs", blockRange(0, 100), mock.Anything). + Return(new(big.Int).SetUint64(0), nil) + inputSrc.On("GetNumberOfInputs", blockFrom(100), mock.Anything). + Return(new(big.Int).SetUint64(3), nil) + + // RetrieveInputs at block 100: 4 events including duplicate index 1, + // delivered out-of-order: [2, 0, 1, 1] + inputSrc.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 100 }), + mock.Anything, mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{ + makeInputEvent(addr, 2, 100), + makeInputEvent(addr, 0, 100), + makeInputEvent(addr, 1, 100), + makeInputEvent(addr, 1, 100), // duplicate + }, nil) + + inputs, err := s.evmReader.fetchApplicationInputs(s.ctx, app, 10, 200) + s.Require().NoError(err) + + // 3 unique inputs, sorted by index + s.Require().Len(inputs, 3) + s.Require().Equal(uint64(0), inputs[0].Index) + s.Require().Equal(uint64(1), inputs[1].Index) + s.Require().Equal(uint64(2), inputs[2].Index) +} + +// --- #18: Adversarial EpochSealed data (LowerBound > UpperBound) --- +// When a sealed event has InputIndexLowerBound > InputIndexUpperBound, +// the code skips input fetching and stores the epoch without crashing. +func (s *SealedEpochsSuite) TestSealedEpochLowerBoundGreaterThanUpperBound() { + tournamentAddr := common.HexToAddress("0xDDDD") + + app := appContracts{ + application: &Application{ + ID: 1, + Name: "test-app", + IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 10, + }, + inputSource: s.inputBox, + daveConsensus: s.dave, + } + + // Adversarial sealed event: LowerBound=5, UpperBound=3 + event := &idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: big.NewInt(1), + InputIndexLowerBound: big.NewInt(5), + InputIndexUpperBound: big.NewInt(3), // < LowerBound — adversarial + Tournament: tournamentAddr, + Raw: types.Log{BlockNumber: 200}, + } + + // Previous epoch (index 0) with UpperBound=5 matching the adversarial LowerBound + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)). + Return(&Epoch{ + Index: 0, FirstBlock: 10, LastBlock: 100, + InputIndexLowerBound: 0, InputIndexUpperBound: 5, + }, nil) + s.repository.On("UpdateEpochClaimTransactionHash", + mock.Anything, mock.Anything, mock.Anything, + ).Return(nil) + + // Epoch 1 doesn't exist yet + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(1)). + Return(nil, nil) + + var storedEpoch *Epoch + var storedInputs []*Input + s.repository.On("CreateEpochsAndInputs", + mock.Anything, mock.Anything, mock.Anything, mock.Anything, + ).Run(func(args mock.Arguments) { + epochInputMap := args.Get(2).(map[*Epoch][]*Input) + for epoch, inputs := range epochInputMap { + storedEpoch = epoch + storedInputs = inputs + } + }).Return(nil) + + err := s.evmReader.processSealedEpochEvent(s.ctx, app, event) + s.Require().NoError(err) + + // Epoch stored with inverted bounds, no inputs fetched + s.Require().NotNil(storedEpoch) + s.Require().Equal(uint64(1), storedEpoch.Index) + s.Require().Equal(uint64(5), storedEpoch.InputIndexLowerBound) + s.Require().Equal(uint64(3), storedEpoch.InputIndexUpperBound) + s.Require().Equal(EpochStatus_Closed, storedEpoch.Status) + s.Require().Empty(storedInputs) + + // No input fetching occurred + s.inputBox.AssertNotCalled(s.T(), "GetNumberOfInputs") + s.inputBox.AssertNotCalled(s.T(), "RetrieveInputs") +} + +// --- #10: RetrieveInputs failure at a specific block --- +// When RetrieveInputs fails during fetchSealedEpochInputs, the error must +// propagate through FindTransitions back to the caller. +func (s *SealedEpochsSuite) TestFetchSealedEpochInputsRetrieveFailure() { + app := appContracts{ + application: &Application{ + ID: 1, + Name: "test-app", + IApplicationAddress: app1Addr, + IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 10, + }, + inputSource: s.inputBox, + } + + epoch := &Epoch{ + Index: 0, + FirstBlock: 10, + LastBlock: 200, + InputIndexLowerBound: 0, + InputIndexUpperBound: 2, + } + + // On-chain: 0 inputs before block 100, 2 from block 100 + s.inputBox.On("GetNumberOfInputs", blockRange(0, 100), mock.Anything). + Return(new(big.Int).SetUint64(0), nil) + s.inputBox.On("GetNumberOfInputs", blockFrom(100), mock.Anything). + Return(new(big.Int).SetUint64(2), nil) + + // RetrieveInputs fails at the transition block + s.inputBox.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 100 }), + mock.Anything, mock.Anything, + ).Return(([]iinputbox.IInputBoxInputAdded)(nil), errors.New("RPC timeout")) + + _, err := s.evmReader.fetchSealedEpochInputs(s.ctx, app, epoch) + s.Require().Error(err) + s.Require().ErrorContains(err, "RPC timeout") + s.Require().ErrorContains(err, "failed to walk input transitions") +} + +// --- Adapter cache invalidation on config change --- +// When an application's consensus address changes between block headers, +// the adapter cache must be invalidated and adapters recreated. +func (s *EvmReaderSuite) TestAdapterCacheInvalidationOnConfigChange() { + ws := &FakeWSEthClient{} + s.evmReader.wsClient = ws + s.evmReader.inputReaderEnabled = false + s.evmReader.defaultBlock = DefaultBlock_Latest + + addr := common.HexToAddress("0x4444444444444444444444444444444444444444") + consensusAddr1 := common.HexToAddress("0xAAA1") + consensusAddr2 := common.HexToAddress("0xAAA2") + + repo := newMockRepository() + // Header 1: app with consensus=addr1 + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*Application{{ + ID: 1, Name: "app", + IApplicationAddress: addr, + IConsensusAddress: consensusAddr1, + IInputBoxAddress: inputBoxAddr, + LastOutputCheckBlock: 999, // > header block → skip output check + }}, uint64(1), nil).Once() + // Header 2: consensus address changed → cache invalidation + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*Application{{ + ID: 1, Name: "app", + IApplicationAddress: addr, + IConsensusAddress: consensusAddr2, + IInputBoxAddress: inputBoxAddr, + LastOutputCheckBlock: 999, + }}, uint64(1), nil).Once() + // Header 3: same config as header 2 → cache hit + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*Application{{ + ID: 1, Name: "app", + IApplicationAddress: addr, + IConsensusAddress: consensusAddr2, + IInputBoxAddress: inputBoxAddr, + LastOutputCheckBlock: 999, + }}, uint64(1), nil).Once() + // Catch-all for sentinel header + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*Application{}, uint64(0), nil) + s.evmReader.repository = repo + + factory := newMockAdapterFactory() + factory.On("CreateAdapters", mock.Anything). + Return(newMockApplicationContract(), newMockInputBox(), nil, nil) + s.evmReader.adapterFactory = factory + + ctx, cancel := context.WithCancel(s.ctx) + ready := make(chan struct{}, 1) + errCh := make(chan error, 1) + go func() { + _, err := s.evmReader.watchForNewBlocks(ctx, ready) + errCh <- err + }() + <-ready + + // Fire 3 headers (block numbers below 999 so output check skips) + ws.fireNewHead(&types.Header{Number: big.NewInt(100)}) + ws.fireNewHead(&types.Header{Number: big.NewInt(101)}) + ws.fireNewHead(&types.Header{Number: big.NewInt(102)}) + ws.flushHeaders() + + cancel() + <-errCh + + // CreateAdapters called twice: + // Header 1: cache miss → create + // Header 2: consensus changed → invalidate + recreate + // Header 3: cache hit → skip + factory.AssertNumberOfCalls(s.T(), "CreateAdapters", 2) +} + +// --- #20: Liveness timer fires correctly after headers stop --- +// After processing headers, if no new header arrives within the liveness +// timeout, watchForNewBlocks returns a SubscriptionError. This also exercises +// the double-select fix: headers that arrive simultaneously with the timer +// are picked up by the inner non-blocking receive. +func (s *EvmReaderSuite) TestLivenessTimerFiresAfterHeadersStop() { + ws := &FakeWSEthClient{} + s.evmReader.wsClient = ws + s.evmReader.wsLivenessTimeout = 50 * time.Millisecond + s.evmReader.inputReaderEnabled = false + s.evmReader.defaultBlock = DefaultBlock_Latest + + repo := newMockRepository() + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*Application{}, uint64(0), nil) + s.evmReader.repository = repo + + ctx, cancel := context.WithCancel(s.ctx) + defer cancel() + ready := make(chan struct{}, 1) + + type watchResult struct { + headersProcessed uint64 + err error + } + resultCh := make(chan watchResult, 1) + go func() { + hp, err := s.evmReader.watchForNewBlocks(ctx, ready) + resultCh <- watchResult{hp, err} + }() + <-ready + + // Fire 3 headers, then stop sending + ws.fireNewHead(&types.Header{Number: big.NewInt(100)}) + ws.fireNewHead(&types.Header{Number: big.NewInt(101)}) + ws.fireNewHead(&types.Header{Number: big.NewInt(102)}) + + // Liveness timer should fire ~50ms after last header + select { + case r := <-resultCh: + s.Require().Equal(uint64(3), r.headersProcessed) + var subErr *SubscriptionError + s.Require().ErrorAs(r.err, &subErr) + s.Require().ErrorContains(r.err, "no new block header received") + case <-time.After(5 * time.Second): + s.FailNow("watchForNewBlocks didn't return after liveness timeout") + } +} From e59b130ca1c1d2290cf6ccc606d31bceb65f2d3b Mon Sep 17 00:00:00 2001 From: Victor Fusco <1221933+vfusco@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:06:30 -0300 Subject: [PATCH 14/14] refactor(evmreader): consolidate input fetch into unified fetchInputs --- internal/evmreader/edge_cases_test.go | 136 ++++++++++++++++- internal/evmreader/error_paths_test.go | 53 ++++++- internal/evmreader/input.go | 100 ++++++++++--- internal/evmreader/input_test.go | 2 +- internal/evmreader/output_test.go | 17 +-- internal/evmreader/sealedepochs.go | 195 ++----------------------- internal/repository/postgres/epoch.go | 4 +- 7 files changed, 288 insertions(+), 219 deletions(-) diff --git a/internal/evmreader/edge_cases_test.go b/internal/evmreader/edge_cases_test.go index ce4f09756..4c4b4c3ee 100644 --- a/internal/evmreader/edge_cases_test.go +++ b/internal/evmreader/edge_cases_test.go @@ -6,6 +6,7 @@ package evmreader import ( "context" "errors" + "math" "math/big" "time" @@ -58,7 +59,8 @@ func (s *EvmReaderSuite) TestFetchApplicationInputsDuplicateAndOutOfOrderEvents( makeInputEvent(addr, 1, 100), // duplicate }, nil) - inputs, err := s.evmReader.fetchApplicationInputs(s.ctx, app, 10, 200) + prevValue := new(big.Int).SetUint64(0) // repo returns 0 inputs + inputs, err := s.evmReader.fetchInputs(s.ctx, app, 10, 200, prevValue, 0, math.MaxUint64) s.Require().NoError(err) // 3 unique inputs, sorted by index @@ -68,6 +70,130 @@ func (s *EvmReaderSuite) TestFetchApplicationInputsDuplicateAndOutOfOrderEvents( s.Require().Equal(uint64(2), inputs[2].Index) } +// --- Bounds filtering: inputs outside [lowerBound, upperBound) are excluded --- +// When RetrieveInputs returns events with indices outside the requested bounds, +// fetchInputs must exclude them. This exercises the bounds filtering added in the +// unified fetchInputs (C2 consolidation). +func (s *EvmReaderSuite) TestFetchInputsBoundsFiltering() { + addr := common.HexToAddress("0x3333333333333333333333333333333333333333") + + inputSrc := &MockInputBox{} + app := appContracts{ + application: &Application{ + Name: "test-app", + IApplicationAddress: addr, + IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 10, + }, + inputSource: inputSrc, + } + + // On-chain: 0 inputs before block 100, 5 from block 100 + inputSrc.On("GetNumberOfInputs", blockRange(0, 100), mock.Anything). + Return(new(big.Int).SetUint64(0), nil) + inputSrc.On("GetNumberOfInputs", blockFrom(100), mock.Anything). + Return(new(big.Int).SetUint64(5), nil) + + // RetrieveInputs returns indices 0..4, but we only want [2, 4) + inputSrc.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 100 }), + mock.Anything, mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{ + makeInputEvent(addr, 0, 100), // below lowerBound → excluded + makeInputEvent(addr, 1, 100), // below lowerBound → excluded + makeInputEvent(addr, 2, 100), // in bounds → included + makeInputEvent(addr, 3, 100), // in bounds → included + makeInputEvent(addr, 4, 100), // >= upperBound → excluded + }, nil) + + prevValue := new(big.Int).SetUint64(0) + inputs, err := s.evmReader.fetchInputs(s.ctx, app, 10, 200, prevValue, 2, 4) + s.Require().NoError(err) + + // Only indices 2 and 3 should be included + s.Require().Len(inputs, 2) + s.Require().Equal(uint64(2), inputs[0].Index) + s.Require().Equal(uint64(3), inputs[1].Index) +} + +// --- Bounds filtering: upperBound == lowerBound yields zero inputs --- +// When lowerBound == upperBound, the half-open range [lb, ub) is empty, +// so no inputs should be returned even if the chain has matching events. +func (s *EvmReaderSuite) TestFetchInputsEmptyBoundsRange() { + addr := common.HexToAddress("0x3333333333333333333333333333333333333333") + + inputSrc := &MockInputBox{} + app := appContracts{ + application: &Application{ + Name: "test-app", + IApplicationAddress: addr, + IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 10, + }, + inputSource: inputSrc, + } + + // On-chain: 0 inputs before block 100, 2 from block 100 + inputSrc.On("GetNumberOfInputs", blockRange(0, 100), mock.Anything). + Return(new(big.Int).SetUint64(0), nil) + inputSrc.On("GetNumberOfInputs", blockFrom(100), mock.Anything). + Return(new(big.Int).SetUint64(2), nil) + + inputSrc.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 100 }), + mock.Anything, mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{ + makeInputEvent(addr, 0, 100), + makeInputEvent(addr, 1, 100), + }, nil) + + // lowerBound == upperBound == 5 → empty range + prevValue := new(big.Int).SetUint64(0) + inputs, err := s.evmReader.fetchInputs(s.ctx, app, 10, 200, prevValue, 5, 5) + s.Require().NoError(err) + s.Require().Empty(inputs) +} + +// --- Bounds filtering: boundary-exact inclusion/exclusion --- +// Verifies the half-open [lowerBound, upperBound) semantics precisely: +// lowerBound is inclusive, upperBound is exclusive. +func (s *EvmReaderSuite) TestFetchInputsBoundaryExactness() { + addr := common.HexToAddress("0x3333333333333333333333333333333333333333") + + inputSrc := &MockInputBox{} + app := appContracts{ + application: &Application{ + Name: "test-app", + IApplicationAddress: addr, + IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 10, + }, + inputSource: inputSrc, + } + + // On-chain: 0 inputs before block 100, 3 from block 100 + inputSrc.On("GetNumberOfInputs", blockRange(0, 100), mock.Anything). + Return(new(big.Int).SetUint64(0), nil) + inputSrc.On("GetNumberOfInputs", blockFrom(100), mock.Anything). + Return(new(big.Int).SetUint64(3), nil) + + inputSrc.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 100 }), + mock.Anything, mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{ + makeInputEvent(addr, 0, 100), + makeInputEvent(addr, 1, 100), + makeInputEvent(addr, 2, 100), + }, nil) + + // Range [1, 2): only index 1 should be included + prevValue := new(big.Int).SetUint64(0) + inputs, err := s.evmReader.fetchInputs(s.ctx, app, 10, 200, prevValue, 1, 2) + s.Require().NoError(err) + s.Require().Len(inputs, 1) + s.Require().Equal(uint64(1), inputs[0].Index) +} + // --- #18: Adversarial EpochSealed data (LowerBound > UpperBound) --- // When a sealed event has InputIndexLowerBound > InputIndexUpperBound, // the code skips input fetching and stores the epoch without crashing. @@ -139,7 +265,7 @@ func (s *SealedEpochsSuite) TestSealedEpochLowerBoundGreaterThanUpperBound() { } // --- #10: RetrieveInputs failure at a specific block --- -// When RetrieveInputs fails during fetchSealedEpochInputs, the error must +// When RetrieveInputs fails during fetchInputs, the error must // propagate through FindTransitions back to the caller. func (s *SealedEpochsSuite) TestFetchSealedEpochInputsRetrieveFailure() { app := appContracts{ @@ -173,7 +299,11 @@ func (s *SealedEpochsSuite) TestFetchSealedEpochInputsRetrieveFailure() { mock.Anything, mock.Anything, ).Return(([]iinputbox.IInputBoxInputAdded)(nil), errors.New("RPC timeout")) - _, err := s.evmReader.fetchSealedEpochInputs(s.ctx, app, epoch) + prevValue := new(big.Int).SetUint64(epoch.InputIndexLowerBound) + _, err := s.evmReader.fetchInputs(s.ctx, app, + epoch.FirstBlock, epoch.LastBlock, + prevValue, + epoch.InputIndexLowerBound, epoch.InputIndexUpperBound) s.Require().Error(err) s.Require().ErrorContains(err, "RPC timeout") s.Require().ErrorContains(err, "failed to walk input transitions") diff --git a/internal/evmreader/error_paths_test.go b/internal/evmreader/error_paths_test.go index 9fc317d25..0c0fd0c5d 100644 --- a/internal/evmreader/error_paths_test.go +++ b/internal/evmreader/error_paths_test.go @@ -72,7 +72,7 @@ func (s *EvmReaderSuite) TestCreateEpochsAndInputsErrorDoesNotAdvanceCheckpoint( } // --- Priority 2: Sealed epoch input count mismatch → error, no epoch stored --- -// When fetchSealedEpochInputs returns fewer inputs than the sealed event +// When fetchInputs returns fewer inputs than the sealed event // declares, processSealedEpochEvent must return an error and NOT store the epoch. func (s *SealedEpochsSuite) TestSealedEpochInputCountMismatchReturnsError() { const sealBlock uint64 = 200 @@ -384,6 +384,55 @@ func (s *EvmReaderSuite) TestOutputBlockRegressionDoesNotWriteToDb() { repo.AssertNumberOfCalls(s.T(), "GetNumberOfExecutedOutputs", 0) } +// --- Input count mismatch in IConsensus path → app skipped, no checkpoint advance --- +// When the on-chain counter delta at endBlock disagrees with the number of inputs +// returned by RetrieveInputs (e.g., missing event), the app must be skipped +// (not stored) and its checkpoint must NOT advance. +func (s *EvmReaderSuite) TestIConsensusInputCountMismatchSkipsApp() { + addr := common.HexToAddress("0x6666666666666666666666666666666666666666") + + inputSrc := &MockInputBox{} + app := &Application{ + ID: 1, + Name: "test-app", + IApplicationAddress: addr, + IInputBoxAddress: inputBoxAddr, + DataAvailability: DataAvailability_InputBox[:], + EpochLength: 10, + LastInputCheckBlock: 100, + } + + // On-chain counter says 2 new inputs, but RetrieveInputs only returns 1 + inputSrc.On("GetNumberOfInputs", blockRange(0, 105), mock.Anything). + Return(new(big.Int).SetUint64(0), nil) + inputSrc.On("GetNumberOfInputs", blockFrom(105), mock.Anything). + Return(new(big.Int).SetUint64(2), nil) + + // RetrieveInputs at 105 only returns 1 event (missing second input) + inputSrc.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 105 }), + mock.Anything, mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{ + makeInputEvent(addr, 0, 105), + }, nil) + + apps := []appContracts{ + {application: app, inputSource: inputSrc}, + } + + repo := newMockRepository() + repo.On("GetNumberOfInputs", mock.Anything, mock.Anything). + Return(uint64(0), nil) + s.evmReader.repository = repo + + err := s.evmReader.readAndStoreInputs(s.ctx, 100, 110, apps) + s.Require().NoError(err) // per-app failure doesn't abort + + // App was skipped: counter says 2 new, but only 1 fetched → no DB writes + repo.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) + repo.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) +} + // --- EpochLength=0 sets app inoperable --- // When an application with EpochLength=0 reaches the epoch indexing logic, // it must be set inoperable to prevent division-by-zero in calculateEpochIndex. @@ -391,7 +440,7 @@ func (s *EvmReaderSuite) TestEpochLengthZeroSetsAppInoperable() { addr := common.HexToAddress("0x5555555555555555555555555555555555555555") inputSrc := &MockInputBox{} - // On-chain: constant 0 inputs (no transitions, fetchApplicationInputs succeeds) + // On-chain: constant 0 inputs (no transitions, fetchInputs succeeds) inputSrc.On("GetNumberOfInputs", mock.Anything, mock.Anything). Return(new(big.Int).SetUint64(0), nil) diff --git a/internal/evmreader/input.go b/internal/evmreader/input.go index c1f17b451..574ad2df5 100644 --- a/internal/evmreader/input.go +++ b/internal/evmreader/input.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "math" "math/big" . "github.com/cartesi/rollups-node/internal/model" @@ -388,7 +389,19 @@ func (r *Service) readInputsFromBlockchain( var appInputsMap = make(map[common.Address][]*Input) for _, app := range apps { - inputs, err := r.fetchApplicationInputs(ctx, app, startBlock, endBlock) + inputCount, err := r.repository.GetNumberOfInputs( + ctx, app.application.IApplicationAddress.String()) + if err != nil { + r.Logger.Error("Error getting input count for application", + "application", app.application.Name, + "error", err.Error(), + ) + continue + } + prevValue := new(big.Int).SetUint64(inputCount) + inputs, err := r.fetchInputs( + ctx, app, startBlock, endBlock, + prevValue, 0, math.MaxUint64) if err != nil { r.Logger.Error("Error fetching inputs for application", "application", app.application.Name, @@ -398,38 +411,82 @@ func (r *Service) readInputsFromBlockchain( ) continue } + + // Validate input count: the on-chain counter delta should match + // the number of inputs fetched. This mirrors the DaveConsensus + // sealed epoch validation at sealedepochs.go and guards against + // silent input loss from FindTransitions missing a transition. + endCallOpts := &bind.CallOpts{ + Context: ctx, + BlockNumber: new(big.Int).SetUint64(endBlock), + } + endCount, err := app.inputSource.GetNumberOfInputs( + endCallOpts, app.application.IApplicationAddress) + if err != nil { + r.Logger.Error("Error getting end-block input count", + "application", app.application.Name, + "end_block", endBlock, + "error", err, + ) + continue + } + expectedNew := endCount.Uint64() - inputCount + if uint64(len(inputs)) != expectedNew { + r.Logger.Error( + "Input count mismatch: on-chain delta does not match fetched inputs", + "application", app.application.Name, + "db_count", inputCount, + "on_chain_end_count", endCount.Uint64(), + "expected_new", expectedNew, + "got", len(inputs), + "start_block", startBlock, + "end_block", endBlock, + ) + continue + } + appInputsMap[app.application.IApplicationAddress] = inputs } return appInputsMap, nil } -func (r *Service) fetchApplicationInputs( +// fetchInputs locates blocks where new inputs were added via FindTransitions +// and accumulates them. The prevValue, lowerBound, and upperBound are caller- +// determined: IConsensus passes prevValue from the DB input count with full +// bounds [0, MaxUint64), while DaveConsensus sealed epochs use the epoch's +// InputIndexLowerBound as prevValue and the epoch's own index range as bounds. +func (r *Service) fetchInputs( ctx context.Context, app appContracts, startBlock, endBlock uint64, + prevValue *big.Int, + lowerBound, upperBound uint64, ) ([]*Input, error) { - r.Logger.Debug("Fetching inputs for application", + r.Logger.Debug("Fetching inputs", "application", app.application.Name, "start_block", startBlock, "end_block", endBlock, + "lower_bound", lowerBound, + "upper_bound", upperBound, ) - // Define oracle function that returns the number of inputs at a given block oracle := func(ctx context.Context, block uint64) (*big.Int, error) { callOpts := &bind.CallOpts{ Context: ctx, BlockNumber: new(big.Int).SetUint64(block), } - numInputs, err := app.inputSource.GetNumberOfInputs(callOpts, app.application.IApplicationAddress) + numInputs, err := app.inputSource.GetNumberOfInputs( + callOpts, app.application.IApplicationAddress) if err != nil { - return nil, fmt.Errorf("failed to get number of inputs at block %d: %w", block, err) + return nil, fmt.Errorf( + "failed to get number of inputs at block %d: %w", + block, err) } return numInputs, nil } var sortedInputs []*Input - // Define onHit function that accumulates inputs at transition blocks onHit := func(block uint64) error { filterOpts := &bind.FilterOpts{ Context: ctx, @@ -442,43 +499,44 @@ func (r *Service) fetchApplicationInputs( nil, ) if err != nil { - return fmt.Errorf("failed to retrieve inputs at block %d: %w", block, err) + return fmt.Errorf( + "failed to retrieve inputs at block %d: %w", + block, err) } for _, event := range inputEvents { + idx := event.Index.Uint64() + if idx < lowerBound || idx >= upperBound { + continue + } input := &Input{ - Index: event.Index.Uint64(), + Index: idx, Status: InputCompletionStatus_None, RawData: event.Input, BlockNumber: event.Raw.BlockNumber, TransactionReference: event.Raw.TxHash, } var duplicate bool - sortedInputs, duplicate = insertSorted(sortByInputIndex, sortedInputs, input) + sortedInputs, duplicate = insertSorted( + sortByInputIndex, sortedInputs, input) if duplicate { r.Logger.Warn("Duplicate input event detected, skipping", "application", app.application.Name, "index", input.Index, "block", input.BlockNumber, ) - continue } } return nil } - inputCount, err := r.repository.GetNumberOfInputs(ctx, app.application.IApplicationAddress.String()) - if err != nil { - return nil, fmt.Errorf("failed to get number of inputs from repository: %w", err) - } - prevValue := new(big.Int).SetUint64(inputCount) - - // Use FindTransitions to find blocks where inputs were added - _, err = ethutil.FindTransitions(ctx, startBlock, endBlock, prevValue, oracle, onHit) + _, err := ethutil.FindTransitions( + ctx, startBlock, endBlock, prevValue, oracle, onHit) if err != nil { - return nil, fmt.Errorf("failed to walk input transitions: %w", err) + return nil, fmt.Errorf( + "failed to walk input transitions: %w", err) } - r.Logger.Debug("Fetched inputs for application", + r.Logger.Debug("Fetched inputs", "application", app.application.Name, "start_block", startBlock, "end_block", endBlock, diff --git a/internal/evmreader/input_test.go b/internal/evmreader/input_test.go index 783ab9ae2..e797a475f 100644 --- a/internal/evmreader/input_test.go +++ b/internal/evmreader/input_test.go @@ -158,7 +158,7 @@ func (s *EvmReaderSuite) TestItUpdatesLastInputCheckBlockWhenThereIsNoInputs() { "GetNumberOfInputs", mock.Anything, mock.Anything, - ).Return(new(big.Int).SetUint64(0), nil).Times(4) + ).Return(new(big.Int).SetUint64(0), nil) // Start service ready := make(chan struct{}, 1) diff --git a/internal/evmreader/output_test.go b/internal/evmreader/output_test.go index b52961925..43fd597cc 100644 --- a/internal/evmreader/output_test.go +++ b/internal/evmreader/output_test.go @@ -579,18 +579,11 @@ func (s *EvmReaderSuite) setupOutputMismatchTest() { mock.Anything, ).Return([]iinputbox.IInputBoxInputAdded{inputAddedEvent0}, nil) - s.inputBox.On("GetNumberOfInputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(0), nil).Once() - s.inputBox.On("GetNumberOfInputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(1), nil).Once() - s.inputBox.On("GetNumberOfInputs", - mock.Anything, - mock.Anything, - ).Return(new(big.Int).SetUint64(0), nil).Times(4) + // On-chain: 0 inputs before block 0x11, 1 from block 0x11 + s.inputBox.On("GetNumberOfInputs", blockRange(0, 0x11), mock.Anything). + Return(new(big.Int).SetUint64(0), nil) + s.inputBox.On("GetNumberOfInputs", blockFrom(0x11), mock.Anything). + Return(new(big.Int).SetUint64(1), nil) s.contractFactory.On("CreateAdapters", mock.MatchedBy(func(app *Application) bool { diff --git a/internal/evmreader/sealedepochs.go b/internal/evmreader/sealedepochs.go index c15ddfef0..7622539db 100644 --- a/internal/evmreader/sealedepochs.go +++ b/internal/evmreader/sealedepochs.go @@ -14,7 +14,6 @@ import ( "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/common" ) func (r *Service) initializeNewApplicationSealedEpochSync( @@ -336,7 +335,11 @@ func (r *Service) processSealedEpochEvent( var inputs []*Input if epoch.InputIndexUpperBound > epoch.InputIndexLowerBound { var err error - inputs, err = r.fetchSealedEpochInputs(ctx, app, epoch) + prevValue := new(big.Int).SetUint64(epoch.InputIndexLowerBound) + inputs, err = r.fetchInputs(ctx, app, + epoch.FirstBlock, epoch.LastBlock, + prevValue, + epoch.InputIndexLowerBound, epoch.InputIndexUpperBound) if err != nil { return fmt.Errorf("failed to fetch inputs for epoch %d: %w", epoch.Index, err) } @@ -374,181 +377,6 @@ func (r *Service) processSealedEpochEvent( return nil } -// fetchSealedEpochInputs fetches inputs for a sealed epoch using the epoch's own -// block range and contract-authoritative input bounds. Unlike the open epoch path, -// it does not rely on the DB's LastInputCheckBlock or GetNumberOfInputs, because: -// - With PRT's overlapping block boundaries, the overlap block may contain inputs -// for this epoch that were added after the previous epoch was sealed. -// - The open epoch may have already stored some inputs, making the DB count higher -// than what FindTransitions expects as prevValue. -func (r *Service) fetchSealedEpochInputs( - ctx context.Context, - app appContracts, - epoch *Epoch, -) ([]*Input, error) { - r.Logger.Debug("Fetching inputs for sealed epoch", - "application", app.application.Name, - "epoch_index", epoch.Index, - "input_lower_bound", epoch.InputIndexLowerBound, - "input_upper_bound", epoch.InputIndexUpperBound, - "first_block", epoch.FirstBlock, - "last_block", epoch.LastBlock, - ) - - oracle := func(ctx context.Context, block uint64) (*big.Int, error) { - callOpts := &bind.CallOpts{ - Context: ctx, - BlockNumber: new(big.Int).SetUint64(block), - } - numInputs, err := app.inputSource.GetNumberOfInputs(callOpts, app.application.IApplicationAddress) - if err != nil { - return nil, fmt.Errorf("failed to get number of inputs at block %d: %w", block, err) - } - return numInputs, nil - } - - var sortedInputs []*Input - onHit := func(block uint64) error { - filterOpts := &bind.FilterOpts{ - Context: ctx, - Start: block, - End: &block, - } - inputEvents, err := app.inputSource.RetrieveInputs( - filterOpts, - []common.Address{app.application.IApplicationAddress}, - nil, - ) - if err != nil { - return fmt.Errorf("failed to retrieve inputs at block %d: %w", block, err) - } - for _, event := range inputEvents { - if event.Index.Uint64() >= epoch.InputIndexLowerBound && - event.Index.Uint64() < epoch.InputIndexUpperBound { - input := &Input{ - Index: event.Index.Uint64(), - Status: InputCompletionStatus_None, - RawData: event.Input, - BlockNumber: event.Raw.BlockNumber, - TransactionReference: event.Raw.TxHash, - } - var duplicate bool - sortedInputs, duplicate = insertSorted(sortByInputIndex, sortedInputs, input) - if duplicate { - r.Logger.Warn("Duplicate input event detected, skipping", - "application", app.application.Name, - "index", input.Index, - "block", input.BlockNumber, - ) - } - } - } - return nil - } - - prevValue := new(big.Int).SetUint64(epoch.InputIndexLowerBound) - _, err := ethutil.FindTransitions(ctx, epoch.FirstBlock, epoch.LastBlock, prevValue, oracle, onHit) - if err != nil { - return nil, fmt.Errorf("failed to walk input transitions: %w", err) - } - - r.Logger.Debug("Fetched inputs for sealed epoch", - "application", app.application.Name, - "epoch_index", epoch.Index, - "input_count", len(sortedInputs), - ) - return sortedInputs, nil -} - -func (r *Service) fetchInputsForEpoch( - ctx context.Context, - app appContracts, - epochIndex uint64, - startBlock, endBlock uint64, - lowerBound, upperBound uint64, -) ([]*Input, error) { - r.Logger.Debug("Fetching inputs for epoch", - "application", app.application.Name, - "epoch_index", epochIndex, - "input_lower_bound", lowerBound, - "input_upper_bound", upperBound, - "epoch_first_block", startBlock, - "epoch_last_block", endBlock, - ) - - // Define oracle function that returns the number of inputs at a given block - oracle := func(ctx context.Context, block uint64) (*big.Int, error) { - callOpts := &bind.CallOpts{ - Context: ctx, - BlockNumber: new(big.Int).SetUint64(block), - } - numInputs, err := app.inputSource.GetNumberOfInputs(callOpts, app.application.IApplicationAddress) - if err != nil { - return nil, fmt.Errorf("failed to get number of inputs at block %d: %w", block, err) - } - return numInputs, nil - } - - var sortedInputs []*Input - // Define onHit function that accumulates inputs at transition blocks - onHit := func(block uint64) error { - filterOpts := &bind.FilterOpts{ - Context: ctx, - Start: block, - End: &block, - } - inputEvents, err := app.inputSource.RetrieveInputs( - filterOpts, - []common.Address{app.application.IApplicationAddress}, - nil, - ) - if err != nil { - return fmt.Errorf("failed to retrieve inputs at block %d: %w", block, err) - } - for _, event := range inputEvents { - if event.Index.Uint64() >= lowerBound && event.Index.Uint64() < upperBound { - input := &Input{ - Index: event.Index.Uint64(), - Status: InputCompletionStatus_None, - RawData: event.Input, - BlockNumber: event.Raw.BlockNumber, - TransactionReference: event.Raw.TxHash, - } - var duplicate bool - sortedInputs, duplicate = insertSorted(sortByInputIndex, sortedInputs, input) - if duplicate { - r.Logger.Warn("Duplicate input event detected, skipping", - "application", app.application.Name, - "index", input.Index, - "block", input.BlockNumber, - ) - continue - } - } - } - return nil - } - - inputCount, err := r.repository.GetNumberOfInputs(ctx, app.application.IApplicationAddress.String()) - if err != nil { - return nil, fmt.Errorf("failed to get number of inputs from repository: %w", err) - } - prevValue := new(big.Int).SetUint64(inputCount) - - // Use FindTransitions to find blocks where inputs were added - _, err = ethutil.FindTransitions(ctx, startBlock, endBlock, prevValue, oracle, onHit) - if err != nil { - return nil, fmt.Errorf("failed to walk input transitions: %w", err) - } - - r.Logger.Debug("Fetched inputs for epoch", - "application", app.application.Name, - "epoch_index", epochIndex, - "input_count", len(sortedInputs), - ) - return sortedInputs, nil -} - func (r *Service) processApplicationOpenEpoch( ctx context.Context, app appContracts, @@ -591,8 +419,17 @@ func (r *Service) processApplicationOpenEpoch( } // Fetch inputs for this epoch from the InputBox - inputs, err := r.fetchInputsForEpoch(ctx, app, nextEpochNumber, lastInputCheckBlock, - mostRecentBlockNumber, openEpoch.InputIndexLowerBound, math.MaxUint64) + inputCount, err := r.repository.GetNumberOfInputs( + ctx, app.application.IApplicationAddress.String()) + if err != nil { + return fmt.Errorf( + "failed to get number of inputs from repository: %w", err) + } + prevValue := new(big.Int).SetUint64(inputCount) + inputs, err := r.fetchInputs(ctx, app, + lastInputCheckBlock, mostRecentBlockNumber, + prevValue, + openEpoch.InputIndexLowerBound, math.MaxUint64) if err != nil { return fmt.Errorf("failed to fetch inputs for epoch %d: %w", openEpoch.Index, err) } diff --git a/internal/repository/postgres/epoch.go b/internal/repository/postgres/epoch.go index abd7d688e..bd1222bc5 100644 --- a/internal/repository/postgres/epoch.go +++ b/internal/repository/postgres/epoch.go @@ -155,7 +155,9 @@ func (r *PostgresRepository) CreateEpochsAndInputs( whereClause, ) - sqlStr, args := inputInsertStmt.QUERY(inputSelectQuery).Sql() + sqlStr, args := inputInsertStmt.QUERY(inputSelectQuery). + ON_CONFLICT(table.Input.EpochApplicationID, table.Input.Index). + DO_NOTHING().Sql() batch.Queue(sqlStr, args...) }