From 4d866abb0c49faf9cd6a5f8be89b820acf709611 Mon Sep 17 00:00:00 2001 From: olegshmuelov <45327364+olegshmuelov@users.noreply.github.com> Date: Sun, 20 Apr 2025 11:01:19 +0300 Subject: [PATCH 01/53] draft --- eth/executionclient/execution_client.go | 44 +++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/eth/executionclient/execution_client.go b/eth/executionclient/execution_client.go index a51076a3e8..052437debd 100644 --- a/eth/executionclient/execution_client.go +++ b/eth/executionclient/execution_client.go @@ -13,6 +13,7 @@ import ( ethcommon "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" "go.opentelemetry.io/otel/metric" semconv "go.opentelemetry.io/otel/semconv/v1.26.0" "go.uber.org/zap" @@ -411,6 +412,8 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo } defer sub.Unsubscribe() + var lastFinalized uint64 + for { select { case <-ctx.Done(): @@ -426,13 +429,48 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo return fromBlock, fmt.Errorf("subscription: %w", err) case header := <-heads: - if header.Number.Uint64() < ec.followDistance { - continue + ec.logger.Debug("New head received", + zap.Uint64("head_number", header.Number.Uint64()), + zap.String("head_hash", header.Hash().Hex()), + zap.String("head_parent_hash", header.ParentHash.Hex())) + + //if header.Number.Uint64() < ec.followDistance { + // continue + //} + //toBlock := header.Number.Uint64() - ec.followDistance + //if toBlock < fromBlock { + // continue + //} + + finalizedBlock, err := ec.client.HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) + if err != nil { + ec.logger.Error(elResponseErrMsg, + zap.String("operation", "HeaderByNumber"), + zap.Error(err)) + return fromBlock, fmt.Errorf("get finalized block: %w", err) + } + toBlock := finalizedBlock.Number.Uint64() + + if toBlock != lastFinalized { + finalizedEpoch := toBlock / 32 + ec.logger.Info("⏱ Finalized block changed", + zap.Uint64("new_finalized", toBlock), + zap.Uint64("epoch", finalizedEpoch), + zap.Uint64("previous_finalized", lastFinalized)) + lastFinalized = toBlock } - toBlock := header.Number.Uint64() - ec.followDistance + + // Wait until the finalized block number (toBlock) catches up to the block we want to start syncing from (fromBlock). + // For example, if we last processed block 123456, fromBlock = 123457. + // If Ethereum finality is only at 123454, we must wait until it reaches 123457 to continue. + // This prevents fetching logs from unfinalized (and potentially reorged) blocks. if toBlock < fromBlock { + ec.logger.Info("Waiting for finalized block to reach fromBlock", + zap.Uint64("from_block", fromBlock), + zap.Uint64("finalized_block", toBlock)) continue } + logStream, fetchErrors := ec.fetchLogsInBatches(ctx, fromBlock, toBlock) for block := range logStream { logs <- block From aa46e71512bc064c3ec4ef251ff9b30cb7d546a7 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Mon, 28 Apr 2025 12:07:52 +0200 Subject: [PATCH 02/53] refactor(execution_client.go): rename logs channel to logsCh for clarity and consistency refactor(execution_client.go): rename heads channel to headersCh for better naming refactor(execution_client.go): update variable names for improved readability refactor(execution_client.go): update log messages for consistency and clarity --- eth/executionclient/execution_client.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/eth/executionclient/execution_client.go b/eth/executionclient/execution_client.go index 052437debd..3ab22c9018 100644 --- a/eth/executionclient/execution_client.go +++ b/eth/executionclient/execution_client.go @@ -385,10 +385,10 @@ func (ec *ExecutionClient) isClosed() bool { // streamLogsToChan streams ongoing logs from the given block to the given channel. // streamLogsToChan *always* returns the last block it fetched, even if it errored. // TODO: consider handling "websocket: read limit exceeded" error and reducing batch size (syncSmartContractsEvents has code for this) -func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- BlockLogs, fromBlock uint64) (lastBlock uint64, err error) { - heads := make(chan *ethtypes.Header) +func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logsCh chan<- BlockLogs, fromBlock uint64) (lastBlock uint64, err error) { + headersCh := make(chan *ethtypes.Header) - // Generally, execution client can stream logs using SubscribeFilterLogs, but we chose to use SubscribeNewHead + FilterLogs. + // Generally, execution client can stream logsCh using SubscribeFilterLogs, but we chose to use SubscribeNewHead + FilterLogs. // // We must receive all events as they determine the state of the ssv network, so a discrepancy can result in slashing. // Therefore, we must be sure that we don't miss any log while streaming. @@ -403,12 +403,12 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo // It also allowed us to implement more 'atomic' behaviour easier: // We can revert the tx if there was an error in processing all the events of a block. // So we can restart from this block once everything is good. - sub, err := ec.client.SubscribeNewHead(ctx, heads) + sub, err := ec.client.SubscribeNewHead(ctx, headersCh) if err != nil { ec.logger.Error(elResponseErrMsg, zap.String("operation", "SubscribeNewHead"), zap.Error(err)) - return fromBlock, fmt.Errorf("subscribe heads: %w", err) + return fromBlock, fmt.Errorf("subscribe headersCh: %w", err) } defer sub.Unsubscribe() @@ -428,8 +428,8 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo } return fromBlock, fmt.Errorf("subscription: %w", err) - case header := <-heads: - ec.logger.Debug("New head received", + case header := <-headersCh: + ec.logger.Debug("new head received", zap.Uint64("head_number", header.Number.Uint64()), zap.String("head_hash", header.Hash().Hex()), zap.String("head_parent_hash", header.ParentHash.Hex())) @@ -453,7 +453,7 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo if toBlock != lastFinalized { finalizedEpoch := toBlock / 32 - ec.logger.Info("⏱ Finalized block changed", + ec.logger.Info("⏱ finalized block changed", zap.Uint64("new_finalized", toBlock), zap.Uint64("epoch", finalizedEpoch), zap.Uint64("previous_finalized", lastFinalized)) @@ -463,9 +463,9 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo // Wait until the finalized block number (toBlock) catches up to the block we want to start syncing from (fromBlock). // For example, if we last processed block 123456, fromBlock = 123457. // If Ethereum finality is only at 123454, we must wait until it reaches 123457 to continue. - // This prevents fetching logs from unfinalized (and potentially reorged) blocks. + // This prevents fetching logsCh from unfinalized (and potentially reorged) blocks. if toBlock < fromBlock { - ec.logger.Info("Waiting for finalized block to reach fromBlock", + ec.logger.Info("waiting for finalized block to reach fromBlock", zap.Uint64("from_block", fromBlock), zap.Uint64("finalized_block", toBlock)) continue @@ -473,12 +473,12 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo logStream, fetchErrors := ec.fetchLogsInBatches(ctx, fromBlock, toBlock) for block := range logStream { - logs <- block + logsCh <- block lastBlock = block.BlockNumber } if err := <-fetchErrors; err != nil { // If we get an error while fetching, we return the last block we fetched. - return lastBlock, fmt.Errorf("fetch logs: %w", err) + return lastBlock, fmt.Errorf("fetch logsCh: %w", err) } fromBlock = toBlock + 1 observability.RecordUint64Value(ctx, fromBlock, lastProcessedBlockGauge.Record, metric.WithAttributes(semconv.ServerAddress(ec.nodeAddr))) From 58c07af9940c61dc9842c2f3e90d419eb62a2b89 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Tue, 29 Apr 2025 13:27:13 +0200 Subject: [PATCH 03/53] update [skip ci] --- eth/executionclient/execution_client.go | 27 ++++++------------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/eth/executionclient/execution_client.go b/eth/executionclient/execution_client.go index 3ab22c9018..ece68b4ac3 100644 --- a/eth/executionclient/execution_client.go +++ b/eth/executionclient/execution_client.go @@ -49,7 +49,6 @@ var _ Provider = &ExecutionClient{} var ( ErrClosed = fmt.Errorf("closed") - ErrNotConnected = fmt.Errorf("not connected") ErrBadInput = fmt.Errorf("bad input") ErrNothingToSync = errors.New("nothing to sync") ) @@ -63,10 +62,7 @@ type ExecutionClient struct { contractAddress ethcommon.Address // optional - logger *zap.Logger - // followDistance defines an offset into the past from the head block such that the block - // at this offset will be considered as very likely finalized. - followDistance uint64 // TODO: consider reading the finalized checkpoint from consensus layer + logger *zap.Logger connectionTimeout time.Duration reconnectionInitialInterval time.Duration reconnectionMaxInterval time.Duration @@ -88,7 +84,6 @@ func New(ctx context.Context, nodeAddr string, contractAddr ethcommon.Address, o nodeAddr: nodeAddr, contractAddress: contractAddr, logger: zap.NewNop(), - followDistance: DefaultFollowDistance, connectionTimeout: DefaultConnectionTimeout, reconnectionInitialInterval: DefaultReconnectionInitialInterval, reconnectionMaxInterval: DefaultReconnectionMaxInterval, @@ -127,17 +122,15 @@ func (ec *ExecutionClient) Close() error { // FetchHistoricalLogs retrieves historical logs emitted by the contract starting from fromBlock. func (ec *ExecutionClient) FetchHistoricalLogs(ctx context.Context, fromBlock uint64) (logs <-chan BlockLogs, errors <-chan error, err error) { - currentBlock, err := ec.client.BlockNumber(ctx) + finalizedBlock, err := ec.client.HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) if err != nil { ec.logger.Error(elResponseErrMsg, - zap.String("method", "eth_blockNumber"), + zap.String("method", "eth_getBlockByNumber"), zap.Error(err)) - return nil, nil, fmt.Errorf("failed to get current block: %w", err) - } - if currentBlock < ec.followDistance { - return nil, nil, ErrNothingToSync + return nil, nil, fmt.Errorf("failed to get finalized block: %w", err) } - toBlock := currentBlock - ec.followDistance + + toBlock := finalizedBlock.Number.Uint64() if toBlock < fromBlock { return nil, nil, ErrNothingToSync } @@ -434,14 +427,6 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logsCh chan<- B zap.String("head_hash", header.Hash().Hex()), zap.String("head_parent_hash", header.ParentHash.Hex())) - //if header.Number.Uint64() < ec.followDistance { - // continue - //} - //toBlock := header.Number.Uint64() - ec.followDistance - //if toBlock < fromBlock { - // continue - //} - finalizedBlock, err := ec.client.HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) if err != nil { ec.logger.Error(elResponseErrMsg, From f8a1592f720143a86b277f18902fa7808b216a60 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Tue, 29 Apr 2025 13:40:39 +0200 Subject: [PATCH 04/53] refactor(common_test.go, event_handler_test.go, defaults.go, execution_client_test.go, multi_client.go, multi_client_test.go, options.go): remove follow distance configuration option as it is no longer needed [skip ci] --- cli/operator/node.go | 2 -- eth/ethtest/common_test.go | 1 - eth/eventhandler/event_handler_test.go | 2 +- eth/executionclient/defaults.go | 1 - eth/executionclient/execution_client_test.go | 8 +++++--- eth/executionclient/multi_client.go | 7 +------ eth/executionclient/multi_client_test.go | 3 --- eth/executionclient/options.go | 16 ---------------- 8 files changed, 7 insertions(+), 33 deletions(-) diff --git a/cli/operator/node.go b/cli/operator/node.go index b8f0e5e8cc..0ff155456c 100644 --- a/cli/operator/node.go +++ b/cli/operator/node.go @@ -219,7 +219,6 @@ var StartNodeCmd = &cobra.Command{ executionAddrList[0], ethcommon.HexToAddress(networkConfig.RegistryContractAddr), executionclient.WithLogger(logger), - executionclient.WithFollowDistance(executionclient.DefaultFollowDistance), executionclient.WithConnectionTimeout(cfg.ExecutionClient.ConnectionTimeout), executionclient.WithReconnectionInitialInterval(executionclient.DefaultReconnectionInitialInterval), executionclient.WithReconnectionMaxInterval(executionclient.DefaultReconnectionMaxInterval), @@ -237,7 +236,6 @@ var StartNodeCmd = &cobra.Command{ executionAddrList, ethcommon.HexToAddress(networkConfig.RegistryContractAddr), executionclient.WithLoggerMulti(logger), - executionclient.WithFollowDistanceMulti(executionclient.DefaultFollowDistance), executionclient.WithConnectionTimeoutMulti(cfg.ExecutionClient.ConnectionTimeout), executionclient.WithReconnectionInitialIntervalMulti(executionclient.DefaultReconnectionInitialInterval), executionclient.WithReconnectionMaxIntervalMulti(executionclient.DefaultReconnectionMaxInterval), diff --git a/eth/ethtest/common_test.go b/eth/ethtest/common_test.go index 437e69581e..0d3812fe76 100644 --- a/eth/ethtest/common_test.go +++ b/eth/ethtest/common_test.go @@ -173,7 +173,6 @@ func (e *TestEnv) setup( addr, contractAddr, executionclient.WithLogger(logger), - executionclient.WithFollowDistance(*e.followDistance), ) if err != nil { return err diff --git a/eth/eventhandler/event_handler_test.go b/eth/eventhandler/event_handler_test.go index fdbb1ddd3f..b1e0ce645b 100644 --- a/eth/eventhandler/event_handler_test.go +++ b/eth/eventhandler/event_handler_test.go @@ -115,7 +115,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NotEmpty(t, contractCode) // Create a client and connect to the simulator - client, err := executionclient.New(ctx, addr, contractAddr, executionclient.WithLogger(logger), executionclient.WithFollowDistance(0)) + client, err := executionclient.New(ctx, addr, contractAddr, executionclient.WithLogger(logger)) require.NoError(t, err) contractFilterer, err := client.Filterer() diff --git a/eth/executionclient/defaults.go b/eth/executionclient/defaults.go index 79da289744..573222cd11 100644 --- a/eth/executionclient/defaults.go +++ b/eth/executionclient/defaults.go @@ -9,7 +9,6 @@ const ( DefaultReconnectionInitialInterval = 1 * time.Second DefaultReconnectionMaxInterval = 64 * time.Second DefaultHealthInvalidationInterval = 24 * time.Second // TODO: decide on this value, for now choosing the node prober interval but it should probably be a bit less than block interval - DefaultFollowDistance = 8 // TODO ALAN: revert DefaultHistoricalLogsBatchSize = 200 defaultLogBuf = 8 * 1024 diff --git a/eth/executionclient/execution_client_test.go b/eth/executionclient/execution_client_test.go index d0ea99df24..51fd2ac1d6 100644 --- a/eth/executionclient/execution_client_test.go +++ b/eth/executionclient/execution_client_test.go @@ -79,6 +79,7 @@ func TestFetchHistoricalLogs(t *testing.T) { } sim.Commit() + // FIXME: replace with finalized // Create a client and connect to the simulator const followDistance = 8 client, err := New( @@ -86,7 +87,6 @@ func TestFetchHistoricalLogs(t *testing.T) { addr, contractAddr, WithLogger(logger), - WithFollowDistance(followDistance), WithConnectionTimeout(2*time.Second), WithReconnectionInitialInterval(2*time.Second), ) @@ -153,9 +153,10 @@ func TestStreamLogs(t *testing.T) { } sim.Commit() + // FIXME: replace with finalized // Create a client and connect to the simulator const followDistance = 2 - client, err := New(ctx, addr, contractAddr, WithLogger(logger), WithFollowDistance(followDistance)) + client, err := New(ctx, addr, contractAddr, WithLogger(logger)) require.NoError(t, err) err = client.Healthy(ctx) @@ -453,8 +454,9 @@ func TestSimSSV(t *testing.T) { } require.NotEmpty(t, contractCode) + // FIXME: replace with finalized // Create a client and connect to the simulator - client, err := New(ctx, addr, contractAddr, WithLogger(logger), WithFollowDistance(0)) + client, err := New(ctx, addr, contractAddr, WithLogger(logger)) require.NoError(t, err) err = client.Healthy(ctx) diff --git a/eth/executionclient/multi_client.go b/eth/executionclient/multi_client.go index 75532011cc..6742ac3c91 100644 --- a/eth/executionclient/multi_client.go +++ b/eth/executionclient/multi_client.go @@ -52,10 +52,7 @@ var _ Provider = &MultiClient{} // This shouldn't cause significant duty misses. type MultiClient struct { // optional - logger *zap.Logger - // followDistance defines an offset into the past from the head block such that the block - // at this offset will be considered as very likely finalized. - followDistance uint64 // TODO: consider reading the finalized checkpoint from consensus layer + logger *zap.Logger connectionTimeout time.Duration reconnectionInitialInterval time.Duration reconnectionMaxInterval time.Duration @@ -91,7 +88,6 @@ func NewMulti( clientsMu: make([]sync.Mutex, len(nodeAddrs)), contractAddress: contractAddr, logger: zap.NewNop(), - followDistance: DefaultFollowDistance, connectionTimeout: DefaultConnectionTimeout, reconnectionInitialInterval: DefaultReconnectionInitialInterval, reconnectionMaxInterval: DefaultReconnectionMaxInterval, @@ -152,7 +148,6 @@ func (mc *MultiClient) connect(ctx context.Context, clientIndex int) error { mc.nodeAddrs[clientIndex], mc.contractAddress, WithLogger(logger), - WithFollowDistance(mc.followDistance), WithConnectionTimeout(mc.connectionTimeout), WithReconnectionInitialInterval(mc.reconnectionInitialInterval), WithReconnectionMaxInterval(mc.reconnectionMaxInterval), diff --git a/eth/executionclient/multi_client_test.go b/eth/executionclient/multi_client_test.go index 8299d13210..3e5bd0b073 100644 --- a/eth/executionclient/multi_client_test.go +++ b/eth/executionclient/multi_client_test.go @@ -59,7 +59,6 @@ func TestNewMulti_WithOptions(t *testing.T) { contractAddr := ethcommon.HexToAddress("0x1234") customLogger := zap.NewExample() - const customFollowDistance = uint64(10) const customTimeout = 100 * time.Millisecond const customReconnectionInterval = 10 * time.Millisecond const customReconnectionMaxInterval = 1 * time.Second @@ -72,7 +71,6 @@ func TestNewMulti_WithOptions(t *testing.T) { addresses, contractAddr, WithLoggerMulti(customLogger), - WithFollowDistanceMulti(customFollowDistance), WithConnectionTimeoutMulti(customTimeout), WithReconnectionInitialIntervalMulti(customReconnectionInterval), WithReconnectionMaxIntervalMulti(customReconnectionMaxInterval), @@ -83,7 +81,6 @@ func TestNewMulti_WithOptions(t *testing.T) { require.NoError(t, err) require.NotNil(t, mc) require.Equal(t, customLogger.Named("execution_client_multi"), mc.logger) - require.EqualValues(t, customFollowDistance, mc.followDistance) require.EqualValues(t, customTimeout, mc.connectionTimeout) require.EqualValues(t, customReconnectionInterval, mc.reconnectionInitialInterval) require.EqualValues(t, customReconnectionMaxInterval, mc.reconnectionMaxInterval) diff --git a/eth/executionclient/options.go b/eth/executionclient/options.go index c8e8d134e9..6272af042b 100644 --- a/eth/executionclient/options.go +++ b/eth/executionclient/options.go @@ -26,22 +26,6 @@ func WithLoggerMulti(logger *zap.Logger) OptionMulti { } } -// WithFollowDistance sets finalization offset (a block at this offset into the past -// from the head block will be considered as very likely finalized). -func WithFollowDistance(offset uint64) Option { - return func(s *ExecutionClient) { - s.followDistance = offset - } -} - -// WithFollowDistanceMulti sets finalization offset (a block at this offset into the past -// from the head block will be considered as very likely finalized). -func WithFollowDistanceMulti(offset uint64) OptionMulti { - return func(s *MultiClient) { - s.followDistance = offset - } -} - // WithConnectionTimeout sets timeout for network connection to eth1 node. func WithConnectionTimeout(timeout time.Duration) Option { return func(s *ExecutionClient) { From 837c45d95eabfcf4c9b587e43200b22b76451882 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Tue, 29 Apr 2025 16:58:36 +0200 Subject: [PATCH 05/53] refactor(execution_client.go): remove unused reconnect method and related logic to simplify the codebase and improve maintainability --- eth/executionclient/execution_client.go | 37 +++---------------------- 1 file changed, 4 insertions(+), 33 deletions(-) diff --git a/eth/executionclient/execution_client.go b/eth/executionclient/execution_client.go index ece68b4ac3..740412bab1 100644 --- a/eth/executionclient/execution_client.go +++ b/eth/executionclient/execution_client.go @@ -21,7 +21,6 @@ import ( "github.com/ssvlabs/ssv/eth/contract" "github.com/ssvlabs/ssv/logging/fields" "github.com/ssvlabs/ssv/observability" - "github.com/ssvlabs/ssv/utils/tasks" ) //go:generate go tool -modfile=../../tool.mod mockgen -package=executionclient -destination=./mocks.go -source=./execution_client.go @@ -257,8 +256,7 @@ func (ec *ExecutionClient) StreamLogs(ctx context.Context, fromBlock uint64) <-c tries = 0 } - ec.logger.Error("failed to stream registry events, reconnecting", zap.Error(err)) - ec.reconnect(ctx) // TODO: ethclient implements reconnection, consider removing this logic after thorough testing + ec.logger.Error("failed to stream registry events, will reconnect", zap.Error(err)) fromBlock = lastBlock + 1 } } @@ -415,11 +413,11 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logsCh chan<- B case <-ec.closed: return fromBlock, ErrClosed - case err := <-sub.Err(): - if err == nil { + case subErr := <-sub.Err(): + if subErr == nil { return fromBlock, ErrClosed } - return fromBlock, fmt.Errorf("subscription: %w", err) + return fromBlock, fmt.Errorf("subscription: %w", subErr) case header := <-headersCh: ec.logger.Debug("new head received", @@ -493,33 +491,6 @@ func (ec *ExecutionClient) connect(ctx context.Context) error { return nil } -// reconnect tries to reconnect multiple times with an exponent interval. -// It panics when reconnecting limit is reached. -// It must not be called twice in parallel. -func (ec *ExecutionClient) reconnect(ctx context.Context) { - logger := ec.logger.With(fields.Address(ec.nodeAddr)) - - start := time.Now() - tasks.ExecWithInterval(func(lastTick time.Duration) (stop bool, cont bool) { - logger.Info("reconnecting") - if err := ec.connect(ctx); err != nil { - if ec.isClosed() { - return true, false - } - // continue until reaching to limit, and then panic as Ethereum execution client connection is required - if lastTick >= ec.reconnectionMaxInterval { - logger.Panic("failed to reconnect", zap.Error(err)) - } else { - logger.Warn("could not reconnect, still trying", zap.Error(err)) - } - return false, false - } - return true, false - }, ec.reconnectionInitialInterval, ec.reconnectionMaxInterval+(ec.reconnectionInitialInterval)) - - logger.Info("reconnected to execution client", zap.Duration("took", time.Since(start))) -} - func (ec *ExecutionClient) Filterer() (*contract.ContractFilterer, error) { return contract.NewContractFilterer(ec.contractAddress, ec.client) } From 4d230e7a337fa687ec29d6f1b7511c4022f19bb3 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Tue, 29 Apr 2025 17:58:15 +0200 Subject: [PATCH 06/53] refactor(node.go): remove unused reconnection interval options to simplify code refactor(defaults.go): remove reconnection interval constants as they are no longer used refactor(execution_client.go): remove reconnection interval fields to streamline struct refactor(execution_client_test.go): remove reconnection interval option from test refactor(multi_client.go): remove reconnection interval options to enhance clarity refactor(multi_client_test.go): remove reconnection interval checks from test refactor(options.go): remove reconnection interval options as they are no longer used --- cli/operator/node.go | 4 -- eth/executionclient/defaults.go | 9 +---- eth/executionclient/execution_client.go | 39 +++++++++----------- eth/executionclient/execution_client_test.go | 2 +- eth/executionclient/multi_client.go | 30 ++++++--------- eth/executionclient/multi_client_test.go | 6 --- eth/executionclient/options.go | 28 -------------- 7 files changed, 33 insertions(+), 85 deletions(-) diff --git a/cli/operator/node.go b/cli/operator/node.go index 0ff155456c..3ebcda8fa5 100644 --- a/cli/operator/node.go +++ b/cli/operator/node.go @@ -220,8 +220,6 @@ var StartNodeCmd = &cobra.Command{ ethcommon.HexToAddress(networkConfig.RegistryContractAddr), executionclient.WithLogger(logger), executionclient.WithConnectionTimeout(cfg.ExecutionClient.ConnectionTimeout), - executionclient.WithReconnectionInitialInterval(executionclient.DefaultReconnectionInitialInterval), - executionclient.WithReconnectionMaxInterval(executionclient.DefaultReconnectionMaxInterval), executionclient.WithHealthInvalidationInterval(executionclient.DefaultHealthInvalidationInterval), executionclient.WithSyncDistanceTolerance(cfg.ExecutionClient.SyncDistanceTolerance), ) @@ -237,8 +235,6 @@ var StartNodeCmd = &cobra.Command{ ethcommon.HexToAddress(networkConfig.RegistryContractAddr), executionclient.WithLoggerMulti(logger), executionclient.WithConnectionTimeoutMulti(cfg.ExecutionClient.ConnectionTimeout), - executionclient.WithReconnectionInitialIntervalMulti(executionclient.DefaultReconnectionInitialInterval), - executionclient.WithReconnectionMaxIntervalMulti(executionclient.DefaultReconnectionMaxInterval), executionclient.WithHealthInvalidationIntervalMulti(executionclient.DefaultHealthInvalidationInterval), executionclient.WithSyncDistanceToleranceMulti(cfg.ExecutionClient.SyncDistanceTolerance), ) diff --git a/eth/executionclient/defaults.go b/eth/executionclient/defaults.go index 573222cd11..0116804a4b 100644 --- a/eth/executionclient/defaults.go +++ b/eth/executionclient/defaults.go @@ -5,14 +5,9 @@ import ( ) const ( - DefaultConnectionTimeout = 10 * time.Second - DefaultReconnectionInitialInterval = 1 * time.Second - DefaultReconnectionMaxInterval = 64 * time.Second - DefaultHealthInvalidationInterval = 24 * time.Second // TODO: decide on this value, for now choosing the node prober interval but it should probably be a bit less than block interval + DefaultConnectionTimeout = 10 * time.Second + DefaultHealthInvalidationInterval = 24 * time.Second // TODO: decide on this value, for now choosing the node prober interval but it should probably be a bit less than block interval // TODO ALAN: revert DefaultHistoricalLogsBatchSize = 200 defaultLogBuf = 8 * 1024 - maxReconnectionAttempts = 5000 - reconnectionBackoffFactor = 2 - healthCheckInterval = 30 * time.Second ) diff --git a/eth/executionclient/execution_client.go b/eth/executionclient/execution_client.go index 740412bab1..08f89ca32e 100644 --- a/eth/executionclient/execution_client.go +++ b/eth/executionclient/execution_client.go @@ -61,12 +61,10 @@ type ExecutionClient struct { contractAddress ethcommon.Address // optional - logger *zap.Logger - connectionTimeout time.Duration - reconnectionInitialInterval time.Duration - reconnectionMaxInterval time.Duration - healthInvalidationInterval time.Duration - logBatchSize uint64 + logger *zap.Logger + connectionTimeout time.Duration + healthInvalidationInterval time.Duration + logBatchSize uint64 syncDistanceTolerance uint64 syncProgressFn func(context.Context) (*ethereum.SyncProgress, error) @@ -80,15 +78,13 @@ type ExecutionClient struct { // New creates a new instance of ExecutionClient. func New(ctx context.Context, nodeAddr string, contractAddr ethcommon.Address, opts ...Option) (*ExecutionClient, error) { client := &ExecutionClient{ - nodeAddr: nodeAddr, - contractAddress: contractAddr, - logger: zap.NewNop(), - connectionTimeout: DefaultConnectionTimeout, - reconnectionInitialInterval: DefaultReconnectionInitialInterval, - reconnectionMaxInterval: DefaultReconnectionMaxInterval, - healthInvalidationInterval: DefaultHealthInvalidationInterval, - logBatchSize: DefaultHistoricalLogsBatchSize, // TODO Make batch of logs adaptive depending on "websocket: read limit" - closed: make(chan struct{}), + nodeAddr: nodeAddr, + contractAddress: contractAddr, + logger: zap.NewNop(), + connectionTimeout: DefaultConnectionTimeout, + healthInvalidationInterval: DefaultHealthInvalidationInterval, + logBatchSize: DefaultHistoricalLogsBatchSize, // TODO Make batch of logs adaptive depending on "websocket: read limit" + closed: make(chan struct{}), } for _, opt := range opts { opt(client) @@ -223,10 +219,10 @@ func (ec *ExecutionClient) fetchLogsInBatches(ctx context.Context, startBlock, e // StreamLogs subscribes to events emitted by the contract. func (ec *ExecutionClient) StreamLogs(ctx context.Context, fromBlock uint64) <-chan BlockLogs { - logs := make(chan BlockLogs) + logsCh := make(chan BlockLogs) go func() { - defer close(logs) + defer close(logsCh) tries := 0 for { select { @@ -235,7 +231,7 @@ func (ec *ExecutionClient) StreamLogs(ctx context.Context, fromBlock uint64) <-c case <-ec.closed: return default: - lastBlock, err := ec.streamLogsToChan(ctx, logs, fromBlock) + lastBlock, err := ec.streamLogsToChan(ctx, logsCh, fromBlock) if errors.Is(err, ErrClosed) || errors.Is(err, context.Canceled) { // Closed gracefully. return @@ -251,18 +247,19 @@ func (ec *ExecutionClient) StreamLogs(ctx context.Context, fromBlock uint64) <-c if tries > 2 { ec.logger.Fatal("failed to stream registry events", zap.Error(err)) } + if lastBlock > fromBlock { - // Successfully streamed some logs, reset tries. + // Successfully streamed some logsCh, reset tries. tries = 0 } - ec.logger.Error("failed to stream registry events, will reconnect", zap.Error(err)) + ec.logger.Error("failed to stream registry events, resubscribing", zap.Error(err)) fromBlock = lastBlock + 1 } } }() - return logs + return logsCh } var errSyncing = fmt.Errorf("syncing") diff --git a/eth/executionclient/execution_client_test.go b/eth/executionclient/execution_client_test.go index 51fd2ac1d6..36ced91730 100644 --- a/eth/executionclient/execution_client_test.go +++ b/eth/executionclient/execution_client_test.go @@ -80,6 +80,7 @@ func TestFetchHistoricalLogs(t *testing.T) { sim.Commit() // FIXME: replace with finalized + // FIXME: check builtin reconnection mechanism // Create a client and connect to the simulator const followDistance = 8 client, err := New( @@ -88,7 +89,6 @@ func TestFetchHistoricalLogs(t *testing.T) { contractAddr, WithLogger(logger), WithConnectionTimeout(2*time.Second), - WithReconnectionInitialInterval(2*time.Second), ) require.NoError(t, err) diff --git a/eth/executionclient/multi_client.go b/eth/executionclient/multi_client.go index 6742ac3c91..2a47086d5f 100644 --- a/eth/executionclient/multi_client.go +++ b/eth/executionclient/multi_client.go @@ -52,13 +52,11 @@ var _ Provider = &MultiClient{} // This shouldn't cause significant duty misses. type MultiClient struct { // optional - logger *zap.Logger - connectionTimeout time.Duration - reconnectionInitialInterval time.Duration - reconnectionMaxInterval time.Duration - healthInvalidationInterval time.Duration - logBatchSize uint64 - syncDistanceTolerance uint64 + logger *zap.Logger + connectionTimeout time.Duration + healthInvalidationInterval time.Duration + logBatchSize uint64 + syncDistanceTolerance uint64 contractAddress ethcommon.Address chainID atomic.Pointer[big.Int] @@ -83,15 +81,13 @@ func NewMulti( } multiClient := &MultiClient{ - nodeAddrs: nodeAddrs, - clients: make([]SingleClientProvider, len(nodeAddrs)), // initialized with nil values (not connected) - clientsMu: make([]sync.Mutex, len(nodeAddrs)), - contractAddress: contractAddr, - logger: zap.NewNop(), - connectionTimeout: DefaultConnectionTimeout, - reconnectionInitialInterval: DefaultReconnectionInitialInterval, - reconnectionMaxInterval: DefaultReconnectionMaxInterval, - logBatchSize: DefaultHistoricalLogsBatchSize, + nodeAddrs: nodeAddrs, + clients: make([]SingleClientProvider, len(nodeAddrs)), // initialized with nil values (not connected) + clientsMu: make([]sync.Mutex, len(nodeAddrs)), + contractAddress: contractAddr, + logger: zap.NewNop(), + connectionTimeout: DefaultConnectionTimeout, + logBatchSize: DefaultHistoricalLogsBatchSize, } for _, opt := range opts { @@ -149,8 +145,6 @@ func (mc *MultiClient) connect(ctx context.Context, clientIndex int) error { mc.contractAddress, WithLogger(logger), WithConnectionTimeout(mc.connectionTimeout), - WithReconnectionInitialInterval(mc.reconnectionInitialInterval), - WithReconnectionMaxInterval(mc.reconnectionMaxInterval), WithHealthInvalidationInterval(mc.healthInvalidationInterval), WithSyncDistanceTolerance(mc.syncDistanceTolerance), ) diff --git a/eth/executionclient/multi_client_test.go b/eth/executionclient/multi_client_test.go index 3e5bd0b073..789877b1ee 100644 --- a/eth/executionclient/multi_client_test.go +++ b/eth/executionclient/multi_client_test.go @@ -60,8 +60,6 @@ func TestNewMulti_WithOptions(t *testing.T) { customLogger := zap.NewExample() const customTimeout = 100 * time.Millisecond - const customReconnectionInterval = 10 * time.Millisecond - const customReconnectionMaxInterval = 1 * time.Second const customHealthInvalidationInterval = 50 * time.Millisecond const customLogBatchSize = 11 const customSyncDistanceTolerance = 12 @@ -72,8 +70,6 @@ func TestNewMulti_WithOptions(t *testing.T) { contractAddr, WithLoggerMulti(customLogger), WithConnectionTimeoutMulti(customTimeout), - WithReconnectionInitialIntervalMulti(customReconnectionInterval), - WithReconnectionMaxIntervalMulti(customReconnectionMaxInterval), WithHealthInvalidationIntervalMulti(customHealthInvalidationInterval), WithLogBatchSizeMulti(customLogBatchSize), WithSyncDistanceToleranceMulti(customSyncDistanceTolerance), @@ -82,8 +78,6 @@ func TestNewMulti_WithOptions(t *testing.T) { require.NotNil(t, mc) require.Equal(t, customLogger.Named("execution_client_multi"), mc.logger) require.EqualValues(t, customTimeout, mc.connectionTimeout) - require.EqualValues(t, customReconnectionInterval, mc.reconnectionInitialInterval) - require.EqualValues(t, customReconnectionMaxInterval, mc.reconnectionMaxInterval) require.EqualValues(t, customHealthInvalidationInterval, mc.healthInvalidationInterval) require.EqualValues(t, customLogBatchSize, mc.logBatchSize) require.EqualValues(t, customSyncDistanceTolerance, mc.syncDistanceTolerance) diff --git a/eth/executionclient/options.go b/eth/executionclient/options.go index 6272af042b..03890ccc87 100644 --- a/eth/executionclient/options.go +++ b/eth/executionclient/options.go @@ -40,34 +40,6 @@ func WithConnectionTimeoutMulti(timeout time.Duration) OptionMulti { } } -// WithReconnectionInitialInterval sets initial reconnection interval. -func WithReconnectionInitialInterval(interval time.Duration) Option { - return func(s *ExecutionClient) { - s.reconnectionInitialInterval = interval - } -} - -// WithReconnectionInitialIntervalMulti sets initial reconnection interval. -func WithReconnectionInitialIntervalMulti(interval time.Duration) OptionMulti { - return func(s *MultiClient) { - s.reconnectionInitialInterval = interval - } -} - -// WithReconnectionMaxInterval sets max reconnection interval. -func WithReconnectionMaxInterval(interval time.Duration) Option { - return func(s *ExecutionClient) { - s.reconnectionMaxInterval = interval - } -} - -// WithReconnectionMaxIntervalMulti sets max reconnection interval. -func WithReconnectionMaxIntervalMulti(interval time.Duration) OptionMulti { - return func(s *MultiClient) { - s.reconnectionMaxInterval = interval - } -} - // WithHealthInvalidationInterval sets health invalidation interval. 0 disables caching. func WithHealthInvalidationInterval(interval time.Duration) Option { return func(s *ExecutionClient) { From 476f4e3908f894f9fda74670085ef6157a6dc5b0 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Thu, 1 May 2025 17:33:19 +0200 Subject: [PATCH 07/53] fix(execution_client.go): update logging method names to improve clarity and consistency --- eth/executionclient/execution_client.go | 6 +- eth/executionclient/execution_client_test.go | 326 +++++++++---------- 2 files changed, 151 insertions(+), 181 deletions(-) diff --git a/eth/executionclient/execution_client.go b/eth/executionclient/execution_client.go index 7c38c0c50d..ce12196a91 100644 --- a/eth/executionclient/execution_client.go +++ b/eth/executionclient/execution_client.go @@ -347,7 +347,7 @@ func (ec *ExecutionClient) SubscribeFilterLogs(ctx context.Context, q ethereum.F logs, err := ec.client.SubscribeFilterLogs(ctx, q, ch) if err != nil { ec.logger.Error(elResponseErrMsg, - zap.String("method", "EthSubscribe"), + zap.String("method", "eth_subscribe(logs)"), zap.Error(err)) return nil, err } @@ -400,7 +400,7 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logsCh chan<- B sub, err := ec.client.SubscribeNewHead(ctx, headersCh) if err != nil { ec.logger.Error(elResponseErrMsg, - zap.String("operation", "SubscribeNewHead"), + zap.String("method", "eth_subscribe(newHeads)"), zap.Error(err)) return fromBlock, fmt.Errorf("subscribe headersCh: %w", err) } @@ -431,7 +431,7 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logsCh chan<- B finalizedBlock, err := ec.client.HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) if err != nil { ec.logger.Error(elResponseErrMsg, - zap.String("operation", "HeaderByNumber"), + zap.String("method", "eth_getBlockByNumber"), zap.Error(err)) return fromBlock, fmt.Errorf("get finalized block: %w", err) } diff --git a/eth/executionclient/execution_client_test.go b/eth/executionclient/execution_client_test.go index 0d4e5861ae..4324dc0d76 100644 --- a/eth/executionclient/execution_client_test.go +++ b/eth/executionclient/execution_client_test.go @@ -47,6 +47,7 @@ const callableAbi = "[{\"anonymous\":false,\"inputs\":[],\"name\":\"Called\",\"t const callableBin = "6080604052348015600f57600080fd5b5060998061001e6000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c806334e2292114602d575b600080fd5b60336035565b005b7f81fab7a4a0aa961db47eefc81f143a5220e8c8495260dd65b1356f1d19d3c7b860405160405180910390a156fea2646970667358221220029436d24f3ac598ceca41d4d712e13ced6d70727f4cdc580667de66d2f51d8b64736f6c63430008010033" const blocksWithLogsLength = 30 +const finalityDistance = 32 // func simTestBackend(testAddr ethcommon.Address) *simulator.Backend { return simulator.NewBackend( @@ -153,22 +154,26 @@ func (env *testEnv) createBlocksWithLogs(contract *bind.BoundContract, count int return nil } +// finalize mines 32 blocks (finalityDistance) so that HeaderByNumber("finalized") advances +func (env *testEnv) finalize() { + for i := 0; i < finalityDistance; i++ { + env.sim.Commit() + } +} + // TestFetchHistoricalLogs tests the FetchHistoricalLogs function of the client. func TestFetchHistoricalLogs(t *testing.T) { logger := zaptest.NewLogger(t) - t.Run("successfully fetches historical logs within follow distance", func(t *testing.T) { + t.Run("successfully fetches historical logs up to finalized block", func(t *testing.T) { env := setupTestEnv(t, 1*time.Second) contract, err := env.deployCallableContract() require.NoError(t, err) // Create a client and connect to the simulator - const followDistance = 8 err = env.createClient( WithLogger(logger), - WithFollowDistance(followDistance), WithConnectionTimeout(2*time.Second), - WithReconnectionInitialInterval(2*time.Second), ) require.NoError(t, err) @@ -176,6 +181,9 @@ func TestFetchHistoricalLogs(t *testing.T) { err = env.createBlocksWithLogs(contract, blocksWithLogsLength, 0) require.NoError(t, err) + // Commit one extra empty block so that the previous blocks become “finalized” + env.sim.Commit() + // Fetch all logs history starting from block 0 var fetchedLogs []ethtypes.Log logs, fetchErrCh, err := env.client.FetchHistoricalLogs(env.ctx, 0) @@ -186,9 +194,6 @@ func TestFetchHistoricalLogs(t *testing.T) { } require.NotEmpty(t, fetchedLogs) - expectedSeenLogs := blocksWithLogsLength - followDistance - require.Equal(t, expectedSeenLogs, len(fetchedLogs)) - select { case err := <-fetchErrCh: require.NoError(t, err) @@ -197,60 +202,6 @@ func TestFetchHistoricalLogs(t *testing.T) { } }) - t.Run("error when currentBlock < followDistance", func(t *testing.T) { - env := setupTestEnv(t, 1*time.Second) - _, err := env.deployCallableContract() - require.NoError(t, err) - - // Create a client with a large followDistance - const followDistance = 100 // Much larger than the current block number - err = env.createClient( - WithLogger(logger), - WithFollowDistance(followDistance), - WithConnectionTimeout(2*time.Second), - WithReconnectionInitialInterval(2*time.Second), - ) - require.NoError(t, err) - - // Fetch logs - should fail because the currentBlock < followDistance - logs, fetchErrCh, err := env.client.FetchHistoricalLogs(env.ctx, 0) - require.ErrorIs(t, err, ErrNothingToSync) - require.Nil(t, logs) - require.Nil(t, fetchErrCh) - }) - - t.Run("error when toBlock < fromBlock", func(t *testing.T) { - env := setupTestEnv(t, 1*time.Second) - contract, err := env.deployCallableContract() - require.NoError(t, err) - - // Create a client - const followDistance = 8 - err = env.createClient( - WithLogger(logger), - WithFollowDistance(followDistance), - WithConnectionTimeout(2*time.Second), - WithReconnectionInitialInterval(2*time.Second), - ) - require.NoError(t, err) - - // Create some blocks - err = env.createBlocksWithLogs(contract, 10, 0) - require.NoError(t, err) - - // Fetch logs with fromBlock > toBlock - currentBlock, err := env.client.client.BlockNumber(env.ctx) - require.NoError(t, err) - - // Set fromBlock to a value greater than the currentBlock - followDistance - fromBlock := currentBlock - followDistance + 10 - - logs, fetchErrCh, err := env.client.FetchHistoricalLogs(env.ctx, fromBlock) - require.ErrorIs(t, err, ErrNothingToSync) - require.Nil(t, logs) - require.Nil(t, fetchErrCh) - }) - t.Run("error when BlockNumber fails", func(t *testing.T) { env := setupTestEnv(t, 1*time.Second) _, err := env.deployCallableContract() @@ -259,9 +210,7 @@ func TestFetchHistoricalLogs(t *testing.T) { // Create a client - connection should succeed initially err = env.createClient( WithLogger(logger), - WithFollowDistance(8), WithConnectionTimeout(100*time.Millisecond), - WithReconnectionInitialInterval(100*time.Millisecond), ) require.NoError(t, err) // Connection is established initially @@ -274,7 +223,7 @@ func TestFetchHistoricalLogs(t *testing.T) { require.Error(t, err) require.Nil(t, logs) require.Nil(t, fetchErrCh) - require.ErrorContains(t, err, "failed to get current block") + require.ErrorContains(t, err, "failed to get finalized block") }) } @@ -290,59 +239,41 @@ func TestStreamLogs(t *testing.T) { require.NoError(t, err) // Create a client and connect to the simulator - const followDistance = 2 - err = env.createClient(WithLogger(logger), WithFollowDistance(followDistance)) + err = env.createClient(WithLogger(logger)) require.NoError(t, err) - logs := env.client.StreamLogs(env.ctx, 0) + logsCh := env.client.StreamLogs(env.ctx, 0) var streamedLogs []ethtypes.Log var streamedLogsCount atomic.Int64 go func() { - // Receive emitted events, this func will exit when the test exits. - for block := range logs { + for block := range logsCh { streamedLogs = append(streamedLogs, block.Logs...) streamedLogsCount.Add(int64(len(block.Logs))) } }() - // Create blocks with transactions - delay := time.Millisecond * 10 + // Emit blocks with events + delay := 10 * time.Millisecond err = env.createBlocksWithLogs(contract, blocksWithLogsLength, delay) require.NoError(t, err) - // Wait for blocksWithLogsLength-followDistance blocks to be streamed. - Wait1: - for { - select { - case <-env.ctx.Done(): - require.Failf(t, "timed out", "err: %v, streamedLogsCount: %d", env.ctx.Err(), streamedLogsCount.Load()) - case <-time.After(time.Millisecond * 5): - if streamedLogsCount.Load() == int64(blocksWithLogsLength-followDistance) { - break Wait1 - } - } - } + // Commit one empty block to allow previous block to become finalized + env.sim.Commit() + time.Sleep(delay) - // Create empty blocks with no transactions to advance the chain - // followDistance blocks ahead. - for i := 0; i < followDistance; i++ { - env.sim.Commit() - time.Sleep(delay) - } - // Wait for streamed logs to advance accordingly. - Wait2: + // Wait until we've received all events for { select { case <-env.ctx.Done(): - require.Failf(t, "timed out", "err: %v, streamedLogsCount: %d", env.ctx.Err(), streamedLogsCount.Load()) - case <-time.After(time.Millisecond * 5): + require.Failf(t, "timed out before receiving all logs", "got %d/%d", streamedLogsCount.Load(), blocksWithLogsLength) + case <-time.After(5 * time.Millisecond): if streamedLogsCount.Load() == int64(blocksWithLogsLength) { - break Wait2 + goto Done } } } - require.NotEmpty(t, streamedLogs) - require.Equal(t, blocksWithLogsLength, len(streamedLogs)) + Done: + require.Len(t, streamedLogs, blocksWithLogsLength) }) t.Run("returns when context is canceled", func(t *testing.T) { @@ -359,30 +290,22 @@ func TestStreamLogs(t *testing.T) { err = env.createClient(WithLogger(logger)) require.NoError(t, err) - // Create a context that can be canceled + // Use a cancelable context ctx, cancel := context.WithCancel(env.ctx) - defer cancel() - // Start streaming logs - logs := env.client.StreamLogs(ctx, 0) - - // Set up a channel to detect when the log channel is closed + logsCh := env.client.StreamLogs(ctx, 0) done := make(chan struct{}) go func() { - // This goroutine should exit when the log channel is closed - for range logs { - // Just consume logs + for range logsCh { } close(done) }() - // Cancel the context to trigger the first return case - cancel() + cancel() // cancel immediately - // Wait for the log channel to be closed select { case <-done: - // Success - the log channel was closed + // success case <-time.After(1 * time.Second): require.Fail(t, "StreamLogs did not return when context was canceled") } @@ -398,31 +321,23 @@ func TestStreamLogs(t *testing.T) { _, err = env.deployCallableContract() require.NoError(t, err) - // Create a client and connect to the simulator - // Don't register cleanup since we'll explicitly close the client in this test + // Create a client without automatic cleanup err = env.createClientWithCleanup(false, WithLogger(logger)) require.NoError(t, err) - // Start streaming logs - logs := env.client.StreamLogs(env.ctx, 0) - - // Set up a channel to detect when the log channel is closed + logsCh := env.client.StreamLogs(env.ctx, 0) done := make(chan struct{}) go func() { - // This goroutine should exit when the log channel is closed - for range logs { - // Just consume logs + for range logsCh { } close(done) }() - // Close the client to trigger the second return case require.NoError(t, env.client.Close()) - // Wait for the log channel to be closed select { case <-done: - // Success - the log channel was closed + // success case <-time.After(1 * time.Second): require.Fail(t, "StreamLogs did not return when client was closed") } @@ -633,7 +548,8 @@ func (env *testEnv) deploySimContract() (*simcontract.Simcontract, error) { return simcontract.NewSimcontract(contractAddr, env.sim.Client()) } -// TestSimSSV deploys the simplified SSVNetwork contract to generate events and receive at the client. +// TestSimSSV deploys the simplified SSVNetwork contract to generate events and receive them +// only after their blocks have been finalized (i.e. after an extra empty block is mined). func TestSimSSV(t *testing.T) { logger, err := zap.NewDevelopment() require.NoError(t, err) @@ -645,40 +561,65 @@ func TestSimSSV(t *testing.T) { require.NoError(t, err) // Create a client and connect to the simulator - err = env.createClient(WithLogger(logger), WithFollowDistance(0)) + err = env.createClient(WithLogger(logger)) require.NoError(t, err) logs := env.client.StreamLogs(env.ctx, 0) + // helper to read next finalized block + nextBlk := func() BlockLogs { + for { + blk := <-logs + if len(blk.Logs) > 0 { + return blk + } + } + } + // Emit event OperatorAdded - tx, err := boundContract.RegisterOperator(env.auth, ethcommon.Hex2Bytes("0xb24454393691331ee6eba4ffa2dbb2600b9859f908c3e648b6c6de9e1dea3e9329866015d08355c8d451427762b913d1"), big.NewInt(100_000_000)) + tx, err := boundContract.RegisterOperator( + env.auth, + ethcommon.Hex2Bytes("0xb24454393691331ee6eba4ffa2dbb2600b9859f908c3e648b6c6de9e1dea3e9329866015d08355c8d451427762b913d1"), + big.NewInt(100_000_000), + ) require.NoError(t, err) - env.sim.Commit() + + env.finalize() // mine && finalize + receipt, err := env.sim.Client().TransactionReceipt(env.ctx, tx.Hash()) - if err != nil { - t.Errorf("get receipt: %v", err) - } + require.NoError(t, err) require.Equal(t, uint64(0x1), receipt.Status) - block := <-logs - require.NotEmpty(t, block.Logs) - require.Equal(t, ethcommon.HexToHash("0xd839f31c14bd632f424e307b36abff63ca33684f77f28e35dc13718ef338f7f4"), block.Logs[0].Topics[0]) + + blk := nextBlk() + require.NotEmpty(t, blk.Logs) + require.Equal( + t, + ethcommon.HexToHash("0xd839f31c14bd632f424e307b36abff63ca33684f77f28e35dc13718ef338f7f4"), + blk.Logs[0].Topics[0], + ) // Emit event OperatorRemoved tx, err = boundContract.RemoveOperator(env.auth, 1) require.NoError(t, err) - env.sim.Commit() + + env.finalize() + receipt, err = env.sim.Client().TransactionReceipt(env.ctx, tx.Hash()) - if err != nil { - t.Errorf("get receipt: %v", err) - } + require.NoError(t, err) require.Equal(t, uint64(0x1), receipt.Status) - block = <-logs - require.NotEmpty(t, block.Logs) - require.Equal(t, ethcommon.HexToHash("0x0e0ba6c2b04de36d6d509ec5bd155c43a9fe862f8052096dd54f3902a74cca3e"), block.Logs[0].Topics[0]) + + blk = nextBlk() + require.NotEmpty(t, blk.Logs) + require.Equal( + t, + ethcommon.HexToHash("0x0e0ba6c2b04de36d6d509ec5bd155c43a9fe862f8052096dd54f3902a74cca3e"), + blk.Logs[0].Topics[0], + ) // Emit event ValidatorAdded tx, err = boundContract.RegisterValidator( - env.auth, ethcommon.Hex2Bytes("0x1"), + env.auth, + ethcommon.Hex2Bytes("0x1"), []uint64{1, 2, 3}, ethcommon.Hex2Bytes("0x2"), big.NewInt(100_000_000), @@ -688,17 +629,23 @@ func TestSimSSV(t *testing.T) { Index: 1, Active: true, Balance: big.NewInt(100_000_000), - }) + }, + ) require.NoError(t, err) - env.sim.Commit() + + env.finalize() + receipt, err = env.sim.Client().TransactionReceipt(env.ctx, tx.Hash()) - if err != nil { - t.Errorf("get receipt: %v", err) - } + require.NoError(t, err) require.Equal(t, uint64(0x1), receipt.Status) - block = <-logs - require.NotEmpty(t, block.Logs) - require.Equal(t, ethcommon.HexToHash("0x48a3ea0796746043948f6341d17ff8200937b99262a0b48c2663b951ed7114e5"), block.Logs[0].Topics[0]) + + blk = nextBlk() + require.NotEmpty(t, blk.Logs) + require.Equal( + t, + ethcommon.HexToHash("0x48a3ea0796746043948f6341d17ff8200937b99262a0b48c2663b951ed7114e5"), + blk.Logs[0].Topics[0], + ) // Emit event ValidatorRemoved tx, err = boundContract.RemoveValidator( @@ -711,17 +658,23 @@ func TestSimSSV(t *testing.T) { Index: 1, Active: true, Balance: big.NewInt(100_000_000), - }) + }, + ) require.NoError(t, err) - env.sim.Commit() + + env.finalize() + receipt, err = env.sim.Client().TransactionReceipt(env.ctx, tx.Hash()) - if err != nil { - t.Errorf("get receipt: %v", err) - } + require.NoError(t, err) require.Equal(t, uint64(0x1), receipt.Status) - block = <-logs - require.NotEmpty(t, block.Logs) - require.Equal(t, ethcommon.HexToHash("0xccf4370403e5fbbde0cd3f13426479dcd8a5916b05db424b7a2c04978cf8ce6e"), block.Logs[0].Topics[0]) + + blk = nextBlk() + require.NotEmpty(t, blk.Logs) + require.Equal( + t, + ethcommon.HexToHash("0xccf4370403e5fbbde0cd3f13426479dcd8a5916b05db424b7a2c04978cf8ce6e"), + blk.Logs[0].Topics[0], + ) // Emit event ClusterLiquidated tx, err = boundContract.Liquidate( @@ -734,17 +687,23 @@ func TestSimSSV(t *testing.T) { Index: 1, Active: true, Balance: big.NewInt(100_000_000), - }) + }, + ) require.NoError(t, err) - env.sim.Commit() + + env.finalize() + receipt, err = env.sim.Client().TransactionReceipt(env.ctx, tx.Hash()) - if err != nil { - t.Errorf("get receipt: %v", err) - } + require.NoError(t, err) require.Equal(t, uint64(0x1), receipt.Status) - block = <-logs - require.NotEmpty(t, block.Logs) - require.Equal(t, ethcommon.HexToHash("0x1fce24c373e07f89214e9187598635036111dbb363e99f4ce498488cdc66e688"), block.Logs[0].Topics[0]) + + blk = nextBlk() + require.NotEmpty(t, blk.Logs) + require.Equal( + t, + ethcommon.HexToHash("0x1fce24c373e07f89214e9187598635036111dbb363e99f4ce498488cdc66e688"), + blk.Logs[0].Topics[0], + ) // Emit event ClusterReactivated tx, err = boundContract.Reactivate( @@ -757,17 +716,23 @@ func TestSimSSV(t *testing.T) { Index: 1, Active: true, Balance: big.NewInt(100_000_000), - }) + }, + ) require.NoError(t, err) - env.sim.Commit() + + env.finalize() + receipt, err = env.sim.Client().TransactionReceipt(env.ctx, tx.Hash()) - if err != nil { - t.Errorf("get receipt: %v", err) - } + require.NoError(t, err) require.Equal(t, uint64(0x1), receipt.Status) - block = <-logs - require.NotEmpty(t, block.Logs) - require.Equal(t, ethcommon.HexToHash("0xc803f8c01343fcdaf32068f4c283951623ef2b3fa0c547551931356f456b6859"), block.Logs[0].Topics[0]) + + blk = nextBlk() + require.NotEmpty(t, blk.Logs) + require.Equal( + t, + ethcommon.HexToHash("0xc803f8c01343fcdaf32068f4c283951623ef2b3fa0c547551931356f456b6859"), + blk.Logs[0].Topics[0], + ) // Emit event FeeRecipientAddressUpdated tx, err = boundContract.SetFeeRecipientAddress( @@ -775,15 +740,20 @@ func TestSimSSV(t *testing.T) { ethcommon.HexToAddress("0x1"), ) require.NoError(t, err) - env.sim.Commit() + + env.finalize() + receipt, err = env.sim.Client().TransactionReceipt(env.ctx, tx.Hash()) - if err != nil { - t.Errorf("get receipt: %v", err) - } + require.NoError(t, err) require.Equal(t, uint64(0x1), receipt.Status) - block = <-logs - require.NotEmpty(t, block.Logs) - require.Equal(t, ethcommon.HexToHash("0x259235c230d57def1521657e7c7951d3b385e76193378bc87ef6b56bc2ec3548"), block.Logs[0].Topics[0]) + + blk = nextBlk() + require.NotEmpty(t, blk.Logs) + require.Equal( + t, + ethcommon.HexToHash("0x259235c230d57def1521657e7c7951d3b385e76193378bc87ef6b56bc2ec3548"), + blk.Logs[0].Topics[0], + ) } // TestFilterLogs tests the FilterLogs method of the client. From 1c2db0546b31b4164b7661a466356331e6ce4532 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Thu, 1 May 2025 17:41:38 +0200 Subject: [PATCH 08/53] clean up --- eth/executionclient/execution_client_test.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/eth/executionclient/execution_client_test.go b/eth/executionclient/execution_client_test.go index 4324dc0d76..9da69d540f 100644 --- a/eth/executionclient/execution_client_test.go +++ b/eth/executionclient/execution_client_test.go @@ -43,11 +43,13 @@ Example contract to test event emission: function Call() public { emit Called(); } } */ -const callableAbi = "[{\"anonymous\":false,\"inputs\":[],\"name\":\"Called\",\"type\":\"event\"},{\"inputs\":[],\"name\":\"Call\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}]" -const callableBin = "6080604052348015600f57600080fd5b5060998061001e6000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c806334e2292114602d575b600080fd5b60336035565b005b7f81fab7a4a0aa961db47eefc81f143a5220e8c8495260dd65b1356f1d19d3c7b860405160405180910390a156fea2646970667358221220029436d24f3ac598ceca41d4d712e13ced6d70727f4cdc580667de66d2f51d8b64736f6c63430008010033" +const ( + callableAbi = "[{\"anonymous\":false,\"inputs\":[],\"name\":\"Called\",\"type\":\"event\"},{\"inputs\":[],\"name\":\"Call\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}]" + callableBin = "6080604052348015600f57600080fd5b5060998061001e6000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c806334e2292114602d575b600080fd5b60336035565b005b7f81fab7a4a0aa961db47eefc81f143a5220e8c8495260dd65b1356f1d19d3c7b860405160405180910390a156fea2646970667358221220029436d24f3ac598ceca41d4d712e13ced6d70727f4cdc580667de66d2f51d8b64736f6c63430008010033" -const blocksWithLogsLength = 30 -const finalityDistance = 32 // + blocksWithLogsLength = 30 + finalityDistance = 32 +) func simTestBackend(testAddr ethcommon.Address) *simulator.Backend { return simulator.NewBackend( From 7b7507c952a3e9315fa5920d07c5e995303fe832 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Sun, 4 May 2025 20:18:12 +0700 Subject: [PATCH 09/53] test(execution_client_test.go): update test case for chain reorganization logs to verify logs are received only after blocks are finalized and reorgs before finalization do not affect the final result --- eth/executionclient/execution_client_test.go | 192 +++++++++---------- 1 file changed, 96 insertions(+), 96 deletions(-) diff --git a/eth/executionclient/execution_client_test.go b/eth/executionclient/execution_client_test.go index 9da69d540f..4eb33edb52 100644 --- a/eth/executionclient/execution_client_test.go +++ b/eth/executionclient/execution_client_test.go @@ -183,7 +183,7 @@ func TestFetchHistoricalLogs(t *testing.T) { err = env.createBlocksWithLogs(contract, blocksWithLogsLength, 0) require.NoError(t, err) - // Commit one extra empty block so that the previous blocks become “finalized” + // Commit one extra empty block so that the previous blocks become "finalized" env.sim.Commit() // Fetch all logs history starting from block 0 @@ -421,105 +421,105 @@ func TestFetchLogsInBatches(t *testing.T) { }) } -// TestChainReorganizationLogs check that the client receives removed logs correctly. +// TestChainReorganizationLogs check that the client receives logs only after blocks are finalized +// and that reorgs before finalization don't affect the final result. // Steps: // 1. Deploy the Callable contract. -// 2. Set up an event subscription. -// 3. Save the current block which will serve as parent for the fork. -// 4. Send a transaction. -// 5. Check that the event was included. -// 6. Fork by using the parent block as ancestor. -// 7. Mine two blocks to trigger a reorg. -// 8. Check that the event was removed. +// 2. Set up an event subscription via StreamLogs. +// 3. Create a transaction and mine a block but don't finalize it. +// 4. Verify no logs are received (since block isn't finalized). +// 5. Create a fork from the parent block and add a different transaction. +// 6. Finalize the fork blocks. +// 7. Verify we receive logs only after finalization. func TestChainReorganizationLogs(t *testing.T) { - // TODO: fix reorg test - // logger := zaptest.NewLogger(t) - // const testTimeout = 2 * time.Second - // ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - // defer cancel() - - // sim := simTestBackend(testAddr) - - // rpcServer, _ := sim.Node.RPCHandler() - // httpsrv := httptest.NewServer(rpcServer.WebsocketHandler([]string{"*"})) - // defer rpcServer.Stop() - // defer httpsrv.Close() - - // addr := httpToWebSocketURL(httpsrv.URL) - - // // 1. - // parsed, _ := abi.JSON(strings.NewReader(callableAbi)) - // auth, _ := bind.NewKeyedTransactorWithChainID(testKey, big.NewInt(1337)) - // contractAddr, _, contract, err := bind.DeployContract(auth, parsed, ethcommon.FromHex(callableBin), sim) - // if err != nil { - // t.Errorf("deploying contract: %v", err) - // } - // sim.Commit() - - // // Connect the client - // const followDistance = 8 - // client, err := New(ctx, addr, contractAddr, WithLogger(logger), WithFollowDistance(followDistance)) - // require.NoError(t, err) - - // isReady, err := client.IsReady(ctx) - // require.NoError(t, err) - // require.True(t, isReady) - // // 2. - // logs := client.StreamLogs(ctx, 0) - // // 3. - // var parent *ethtypes.Header - // var goodTransactions []ethcommon.Hash - // // 4. Send some transactions - // for i := 0; i < followDistance/2; i++ { - // // Call contract to trigger event emit - // tx, err := contract.Transact(auth, "Call") - // if err != nil { - // t.Errorf("transacting: %v", err) - // } - // sim.Commit() - // if i == 0 { - // goodTransactions = append(goodTransactions, tx.Hash()) - // parent = sim.Blockchain.CurrentBlock() - // } - // } - // // 5. Fork off the chain after the first transaction - // if err := sim.Fork(context.Background(), parent.Hash()); err != nil { - // t.Errorf("forking: %v", err) - // } - // // 6. Add more blocks and 1 transaction after the fork - // for i := 0; i < followDistance; i++ { - // if i == 1 { - // tx, err := contract.Transact(auth, "Call") - // if err != nil { - // t.Errorf("transacting: %v", err) - // } - // goodTransactions = append(goodTransactions, tx.Hash()) - // } - // sim.Commit() - // t.Log("committed block") - // } - // // 5. - // for i, hash := range goodTransactions { - // select { - // case log := <-logs: - // require.NotEmpty(t, log) - // require.Equal(t, hash, log.TxHash) - // t.Logf("got log from good transaction %d", i) - // case <-ctx.Done(): - // t.Fatal("context canceled") - // } - // } - // select { - // case <-logs: - // t.Fatal("should not receive log") - // case <-ctx.Done(): - // } - // if sim.Blockchain.CurrentBlock().Number.Uint64() != uint64(13) { - // t.Error("wrong chain length") - // } - // require.NoError(t, client.Close()) - // require.NoError(t, sim.Close()) + logger := zaptest.NewLogger(t) + env := setupTestEnv(t, 2*time.Second) + + // 1. Deploy the contract + contract, err := env.deployCallableContract() + require.NoError(t, err) + + // 2. Create a client and set up subscription + err = env.createClient(WithLogger(logger)) + require.NoError(t, err) + + logsCh := env.client.StreamLogs(env.ctx, 0) + + // Save parent block for forking later + parentBlock, err := env.sim.Client().BlockByNumber(env.ctx, nil) + require.NoError(t, err) + + // Create a map to track transaction hashes and their corresponding blocks + txHashes := make(map[ethcommon.Hash]uint64) + + // 3. Create a transaction on the original chain + originalTx, err := contract.Transact(env.auth, "Call") + require.NoError(t, err) + + env.sim.Commit() + + // Record the original transaction and its block number + latestBlock, err := env.sim.Client().BlockByNumber(env.ctx, nil) + require.NoError(t, err) + + originalBlockNum := latestBlock.NumberU64() + txHashes[originalTx.Hash()] = originalBlockNum + t.Logf("original chain block number: %d, tx hash: %s", originalBlockNum, originalTx.Hash().Hex()) + + // 4. No logs should be received since the block isn't finalized + select { + case log := <-logsCh: + require.Fail(t, "received logs from unfinalized block", "log", log) + case <-time.After(100 * time.Millisecond): + // no logs + } + + // 5. Create a fork from the parent block + require.NoError(t, env.sim.Fork(parentBlock.Hash())) + + // Create a different transaction on the fork + forkTx, err := contract.Transact(env.auth, "Call") + require.NoError(t, err) + + env.sim.Commit() + + // Record the fork transaction and its block number + latestBlock, err = env.sim.Client().BlockByNumber(env.ctx, nil) + require.NoError(t, err) + + forkBlockNum := latestBlock.NumberU64() + txHashes[forkTx.Hash()] = forkBlockNum + t.Logf("fork chain block number: %d, tx hash: %s", forkBlockNum, forkTx.Hash().Hex()) + + // Still no logs should be received since the fork isn't finalized + select { + case log := <-logsCh: + require.Fail(t, "received logs from unfinalized fork", "log", log) + case <-time.After(100 * time.Millisecond): + // no logs + } + + // 6. Finalize the fork + env.finalize() + + // 7. Verify we receive logs only after finalization + var receivedLog BlockLogs + select { + case receivedLog = <-logsCh: + // received logs + case <-time.After(1 * time.Second): + require.Fail(t, "did not receive logs after finalization") + } + + require.NotEmpty(t, receivedLog.Logs) + + // Verify we received the transaction hash that's in our map and log is from the expected block + txHash := receivedLog.Logs[0].TxHash + blockNum, found := txHashes[txHash] + + require.True(t, found, txHash.Hex()) + require.Equal(t, blockNum, receivedLog.BlockNumber) } // deploySimContract deploys the SSV simulator contract. From e0429a5cbb0f23dd4808421df544826fb302f355 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Sun, 4 May 2025 21:50:30 +0700 Subject: [PATCH 10/53] refactor(common_test.go): rename followDistance to finalityBlocks for clarity and consistency feat(common_test.go): add MineAndFinalize method to ensure finality by mining blocks feat(eth_e2e_test.go): replace CloseFollowDistance calls with MineAndFinalize for block finality --- eth/ethtest/common_test.go | 18 +++++++++--------- eth/ethtest/eth_e2e_test.go | 34 ++++++++++++++++++++-------------- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/eth/ethtest/common_test.go b/eth/ethtest/common_test.go index 0d3812fe76..685a9a590c 100644 --- a/eth/ethtest/common_test.go +++ b/eth/ethtest/common_test.go @@ -64,7 +64,7 @@ type TestEnv struct { httpSrv *httptest.Server validatorCtrl *mocks.MockController mockCtrl *gomock.Controller - followDistance *uint64 + finalityBlocks uint64 } func (e *TestEnv) shutdown() { @@ -89,8 +89,8 @@ func (e *TestEnv) setup( validatorsCount uint64, operatorsCount uint64, ) error { - if e.followDistance == nil { - e.SetDefaultFollowDistance() + if e.finalityBlocks == 0 { + e.SetDefaultFinalityBlocks() } logger := zaptest.NewLogger(t) @@ -205,14 +205,14 @@ func (e *TestEnv) setup( return nil } -func (e *TestEnv) SetDefaultFollowDistance() { - // 8 is current production offset - value := uint64(8) - e.followDistance = &value +func (e *TestEnv) SetDefaultFinalityBlocks() { + // 32 is the number of blocks for Ethereum finality + e.finalityBlocks = 32 } -func (e *TestEnv) CloseFollowDistance(blockNum *uint64) { - for i := uint64(0); i < *e.followDistance; i++ { +// MineAndFinalize mines enough blocks to ensure finality +func (e *TestEnv) MineAndFinalize(blockNum *uint64) { + for i := uint64(0); i < e.finalityBlocks; i++ { commitBlock(e.sim, blockNum) } } diff --git a/eth/ethtest/eth_e2e_test.go b/eth/ethtest/eth_e2e_test.go index 9b95c88cb6..36c0c2539a 100644 --- a/eth/ethtest/eth_e2e_test.go +++ b/eth/ethtest/eth_e2e_test.go @@ -2,13 +2,13 @@ package ethtest import ( "context" - "fmt" "math/big" "testing" "time" ethcommon "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -45,7 +45,7 @@ func TestEthExecLayer(t *testing.T) { expectedNonce := registrystorage.Nonce(0) testEnv := TestEnv{} - testEnv.SetDefaultFollowDistance() + testEnv.SetDefaultFinalityBlocks() defer testEnv.shutdown() err := testEnv.setup(t, ctx, testAddresses, 7, 4) @@ -83,7 +83,7 @@ func TestEthExecLayer(t *testing.T) { opAddedInput.prepare(ops, auth) opAddedInput.produce() - testEnv.CloseFollowDistance(&blockNum) + testEnv.MineAndFinalize(&blockNum) } // BLOCK 3: VALIDATOR ADDED: @@ -96,15 +96,21 @@ func TestEthExecLayer(t *testing.T) { valAddInput := NewTestValidatorRegisteredInput(common) valAddInput.prepare(validators, shares, ops, auth, &expectedNonce, []uint32{0, 1}) valAddInput.produce() - testEnv.CloseFollowDistance(&blockNum) + testEnv.MineAndFinalize(&blockNum) + + // Check the finalized block number (EventSyncer uses it to determine the range of blocks to process) + finalizedBlock, err := testEnv.sim.Client().HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) + require.NoError(t, err) + + finalizedBlockNum := finalizedBlock.Number.Uint64() // Run SyncHistory lastHandledBlockNum, err = eventSyncer.SyncHistory(ctx, lastHandledBlockNum) require.NoError(t, err) - //check all the events were handled correctly and block number was increased - require.Equal(t, blockNum-*testEnv.followDistance, lastHandledBlockNum) - fmt.Println("lastHandledBlockNum", lastHandledBlockNum) + // EventSyncer.SyncHistory processes blocks only up to the finalized block number + // Check that the last handled block number is equal to the finalized block number + require.Equal(t, finalizedBlockNum, lastHandledBlockNum) // Check that operators were successfully registered operators, err := nodeStorage.ListOperators(nil, 0, 10) @@ -154,7 +160,7 @@ func TestEthExecLayer(t *testing.T) { valAddInput := NewTestValidatorRegisteredInput(common) valAddInput.prepare(validators, shares, ops, auth, &expectedNonce, []uint32{2, 3, 4, 5, 6}) valAddInput.produce() - testEnv.CloseFollowDistance(&blockNum) + testEnv.MineAndFinalize(&blockNum) // Wait until the state is changed time.Sleep(time.Millisecond * 5000) @@ -185,7 +191,7 @@ func TestEthExecLayer(t *testing.T) { cluster, ) valExit.produce() - testEnv.CloseFollowDistance(&blockNum) + testEnv.MineAndFinalize(&blockNum) // Wait to make sure the state is not changed time.Sleep(time.Millisecond * 500) @@ -210,7 +216,7 @@ func TestEthExecLayer(t *testing.T) { cluster, ) valRemove.produce() - testEnv.CloseFollowDistance(&blockNum) + testEnv.MineAndFinalize(&blockNum) // Wait until the state is changed time.Sleep(time.Millisecond * 500) @@ -240,7 +246,7 @@ func TestEthExecLayer(t *testing.T) { }, }) clusterLiquidate.produce() - testEnv.CloseFollowDistance(&blockNum) + testEnv.MineAndFinalize(&blockNum) // Wait until the state is changed time.Sleep(time.Millisecond * 300) @@ -280,7 +286,7 @@ func TestEthExecLayer(t *testing.T) { }, }) clusterReactivated.produce() - testEnv.CloseFollowDistance(&blockNum) + testEnv.MineAndFinalize(&blockNum) // Wait until the state is changed time.Sleep(time.Millisecond * 300) @@ -303,7 +309,7 @@ func TestEthExecLayer(t *testing.T) { opRemoved := NewOperatorRemovedEventInput(common) opRemoved.prepare([]uint64{1, 2}, auth) opRemoved.produce() - testEnv.CloseFollowDistance(&blockNum) + testEnv.MineAndFinalize(&blockNum) // TODO: this should be adjusted when eth/eventhandler/handlers.go#L109 is resolved } @@ -317,7 +323,7 @@ func TestEthExecLayer(t *testing.T) { {auth, &testAddrBob}, }) setFeeRecipient.produce() - testEnv.CloseFollowDistance(&blockNum) + testEnv.MineAndFinalize(&blockNum) // Wait until the state is changed time.Sleep(time.Millisecond * 300) From f66bb3615f1bb144081c2695710bf3ccbdf6e704 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Mon, 5 May 2025 13:25:09 +0700 Subject: [PATCH 11/53] feat(executionclient): add DefaultFinalityDistance constant with value 32 for better readability and maintainability refactor(execution_client): update usage of finality distance constant to use DefaultFinalityDistance constant for consistency and clarity test(execution_client_test): update finalize function to use DefaultFinalityDistance constant instead of hardcoded value for improved maintainability [skip ci] --- eth/executionclient/defaults.go | 1 + eth/executionclient/execution_client.go | 2 +- eth/executionclient/execution_client_test.go | 5 ++--- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/eth/executionclient/defaults.go b/eth/executionclient/defaults.go index 0116804a4b..50ac7fa37c 100644 --- a/eth/executionclient/defaults.go +++ b/eth/executionclient/defaults.go @@ -5,6 +5,7 @@ import ( ) const ( + DefaultFinalityDistance = 32 // Default number of locks for finality distance DefaultConnectionTimeout = 10 * time.Second DefaultHealthInvalidationInterval = 24 * time.Second // TODO: decide on this value, for now choosing the node prober interval but it should probably be a bit less than block interval // TODO ALAN: revert diff --git a/eth/executionclient/execution_client.go b/eth/executionclient/execution_client.go index ce12196a91..1ea1c2a0fd 100644 --- a/eth/executionclient/execution_client.go +++ b/eth/executionclient/execution_client.go @@ -438,7 +438,7 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logsCh chan<- B toBlock := finalizedBlock.Number.Uint64() if toBlock != lastFinalized { - finalizedEpoch := toBlock / 32 + finalizedEpoch := toBlock / DefaultFinalityDistance ec.logger.Info("⏱ finalized block changed", zap.Uint64("new_finalized", toBlock), zap.Uint64("epoch", finalizedEpoch), diff --git a/eth/executionclient/execution_client_test.go b/eth/executionclient/execution_client_test.go index 4eb33edb52..429def5207 100644 --- a/eth/executionclient/execution_client_test.go +++ b/eth/executionclient/execution_client_test.go @@ -48,7 +48,6 @@ const ( callableBin = "6080604052348015600f57600080fd5b5060998061001e6000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c806334e2292114602d575b600080fd5b60336035565b005b7f81fab7a4a0aa961db47eefc81f143a5220e8c8495260dd65b1356f1d19d3c7b860405160405180910390a156fea2646970667358221220029436d24f3ac598ceca41d4d712e13ced6d70727f4cdc580667de66d2f51d8b64736f6c63430008010033" blocksWithLogsLength = 30 - finalityDistance = 32 ) func simTestBackend(testAddr ethcommon.Address) *simulator.Backend { @@ -156,9 +155,9 @@ func (env *testEnv) createBlocksWithLogs(contract *bind.BoundContract, count int return nil } -// finalize mines 32 blocks (finalityDistance) so that HeaderByNumber("finalized") advances +// finalize mines 32 blocks (DefaultFinalityDistance ) so that HeaderByNumber("finalized") advances func (env *testEnv) finalize() { - for i := 0; i < finalityDistance; i++ { + for i := 0; i < DefaultFinalityDistance; i++ { env.sim.Commit() } } From 5fd0b4123e0f3028bf4996858b0ad51b0dd8b5a8 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Mon, 5 May 2025 10:08:04 +0200 Subject: [PATCH 12/53] Update eth/executionclient/defaults.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- eth/executionclient/defaults.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eth/executionclient/defaults.go b/eth/executionclient/defaults.go index 50ac7fa37c..15d7afa994 100644 --- a/eth/executionclient/defaults.go +++ b/eth/executionclient/defaults.go @@ -5,7 +5,7 @@ import ( ) const ( - DefaultFinalityDistance = 32 // Default number of locks for finality distance + DefaultFinalityDistance = 32 // Default number of blocks for finality distance DefaultConnectionTimeout = 10 * time.Second DefaultHealthInvalidationInterval = 24 * time.Second // TODO: decide on this value, for now choosing the node prober interval but it should probably be a bit less than block interval // TODO ALAN: revert From ffe54fbf2f14056d76a9d469240c41d1b936cfe0 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Mon, 5 May 2025 10:08:52 +0200 Subject: [PATCH 13/53] Update eth/executionclient/execution_client.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- eth/executionclient/execution_client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eth/executionclient/execution_client.go b/eth/executionclient/execution_client.go index 1ea1c2a0fd..432e248586 100644 --- a/eth/executionclient/execution_client.go +++ b/eth/executionclient/execution_client.go @@ -255,7 +255,7 @@ func (ec *ExecutionClient) StreamLogs(ctx context.Context, fromBlock uint64) <-c } if lastBlock > fromBlock { - // Successfully streamed some logsCh, reset tries. + // Successfully streamed some logs, reset tries. tries = 0 } From fe53254b7ff5a90ba57c0a164944503f15a2fc84 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Mon, 5 May 2025 21:42:22 +0700 Subject: [PATCH 14/53] support finalized blocks --- eth/ethtest/common_test.go | 4 +- eth/eventhandler/event_handler_test.go | 310 ++++++++++++++++++------- eth/executionclient/mocks.go | 2 + 3 files changed, 226 insertions(+), 90 deletions(-) diff --git a/eth/ethtest/common_test.go b/eth/ethtest/common_test.go index 685a9a590c..27c20098bb 100644 --- a/eth/ethtest/common_test.go +++ b/eth/ethtest/common_test.go @@ -206,8 +206,7 @@ func (e *TestEnv) setup( } func (e *TestEnv) SetDefaultFinalityBlocks() { - // 32 is the number of blocks for Ethereum finality - e.finalityBlocks = 32 + e.finalityBlocks = executionclient.DefaultFinalityDistance } // MineAndFinalize mines enough blocks to ensure finality @@ -217,6 +216,7 @@ func (e *TestEnv) MineAndFinalize(blockNum *uint64) { } } +// commitBlock creates a new block and increments block counter func commitBlock(sim *simulator.Backend, blockNum *uint64) { sim.Commit() *blockNum++ diff --git a/eth/eventhandler/event_handler_test.go b/eth/eventhandler/event_handler_test.go index ac90f36e29..f075e4f3b0 100644 --- a/eth/eventhandler/event_handler_test.go +++ b/eth/eventhandler/event_handler_test.go @@ -104,6 +104,7 @@ func TestHandleBlockEventsStream(t *testing.T) { if err != nil { t.Errorf("deploying contract: %v", err) } + sim.Commit() // Check contract code at the simulated blockchain @@ -143,7 +144,6 @@ func TestHandleBlockEventsStream(t *testing.T) { sharesData3, err := generateSharesData(validatorData3, ops, testAddr, 3) require.NoError(t, err) - blockNum := uint64(0x1) currentSlot.SetSlot(100) t.Run("test OperatorAdded event handle", func(t *testing.T) { @@ -156,11 +156,15 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) _, err = boundContract.RegisterOperator(auth, packedOperatorPubKey, big.NewInt(100_000_000)) require.NoError(t, err) - } + sim.Commit() + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } - block := <-logs + // Get a block with logs (skipping any empty blocks) + block := getBlockWithLogs(logs) require.NotEmpty(t, block.Logs) require.Equal(t, ethcommon.HexToHash("0xd839f31c14bd632f424e307b36abff63ca33684f77f28e35dc13718ef338f7f4"), block.Logs[0].Topics[0]) @@ -177,9 +181,10 @@ func TestHandleBlockEventsStream(t *testing.T) { // Handle the event lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - require.Equal(t, blockNum+1, lastProcessedBlock) + + // The block number should match the block where the events were emitted + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) - blockNum++ // Check storage for the new operators operators, err = eh.nodeStorage.ListOperators(nil, 0, 0) @@ -280,9 +285,13 @@ func TestHandleBlockEventsStream(t *testing.T) { Balance: big.NewInt(100_000_000), }) require.NoError(t, err) + sim.Commit() + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } - block := <-logs + block := getBlockWithLogs(logs) require.NotEmpty(t, block.Logs) require.Equal(t, ethcommon.HexToHash("0x48a3ea0796746043948f6341d17ff8200937b99262a0b48c2663b951ed7114e5"), block.Logs[0].Topics[0]) @@ -293,9 +302,10 @@ func TestHandleBlockEventsStream(t *testing.T) { }() lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) + + // The block number should match the block where the events were emitted + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) - require.Equal(t, blockNum+1, lastProcessedBlock) - blockNum++ requireKeyManagerDataToExist(t, eh, 1, validatorData1) @@ -331,22 +341,27 @@ func TestHandleBlockEventsStream(t *testing.T) { Balance: big.NewInt(100_000_000), }) require.NoError(t, err) + sim.Commit() + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } - block = <-logs + block := getBlockWithLogs(logs) require.NotEmpty(t, block.Logs) require.Equal(t, ethcommon.HexToHash("0x48a3ea0796746043948f6341d17ff8200937b99262a0b48c2663b951ed7114e5"), block.Logs[0].Topics[0]) - eventsCh = make(chan executionclient.BlockLogs) + eventsCh := make(chan executionclient.BlockLogs) go func() { defer close(eventsCh) eventsCh <- block }() - lastProcessedBlock, err = eh.HandleBlockEventsStream(ctx, eventsCh, false) + lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) + + // Use the block's actual blockNumber + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) - require.Equal(t, blockNum+1, lastProcessedBlock) - blockNum++ requireKeyManagerDataToNotExist(t, eh, 1, validatorData2) @@ -381,22 +396,27 @@ func TestHandleBlockEventsStream(t *testing.T) { Balance: big.NewInt(100_000_000), }) require.NoError(t, err) + sim.Commit() + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } - block = <-logs + block := getBlockWithLogs(logs) require.NotEmpty(t, block.Logs) require.Equal(t, ethcommon.HexToHash("0x48a3ea0796746043948f6341d17ff8200937b99262a0b48c2663b951ed7114e5"), block.Logs[0].Topics[0]) - eventsCh = make(chan executionclient.BlockLogs) + eventsCh := make(chan executionclient.BlockLogs) go func() { defer close(eventsCh) eventsCh <- block }() - lastProcessedBlock, err = eh.HandleBlockEventsStream(ctx, eventsCh, false) + lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) + + // Use the block's actual blockNumber + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) - require.Equal(t, blockNum+1, lastProcessedBlock) - blockNum++ requireKeyManagerDataToExist(t, eh, 2, validatorData2) @@ -436,22 +456,27 @@ func TestHandleBlockEventsStream(t *testing.T) { Balance: big.NewInt(100_000_000), }) require.NoError(t, err) + sim.Commit() + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } - block = <-logs + block := getBlockWithLogs(logs) require.NotEmpty(t, block.Logs) require.Equal(t, ethcommon.HexToHash("0x48a3ea0796746043948f6341d17ff8200937b99262a0b48c2663b951ed7114e5"), block.Logs[0].Topics[0]) - eventsCh = make(chan executionclient.BlockLogs) + eventsCh := make(chan executionclient.BlockLogs) go func() { defer close(eventsCh) eventsCh <- block }() - lastProcessedBlock, err = eh.HandleBlockEventsStream(ctx, eventsCh, false) + lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) + + // Use the block's actual blockNumber + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) - require.Equal(t, blockNum+1, lastProcessedBlock) - blockNum++ requireKeyManagerDataToNotExist(t, eh, 2, validatorData3) @@ -485,22 +510,27 @@ func TestHandleBlockEventsStream(t *testing.T) { Balance: big.NewInt(100_000_000), }) require.NoError(t, err) + sim.Commit() + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } - block = <-logs + block := getBlockWithLogs(logs) require.NotEmpty(t, block.Logs) require.Equal(t, ethcommon.HexToHash("0x48a3ea0796746043948f6341d17ff8200937b99262a0b48c2663b951ed7114e5"), block.Logs[0].Topics[0]) - eventsCh = make(chan executionclient.BlockLogs) + eventsCh := make(chan executionclient.BlockLogs) go func() { defer close(eventsCh) eventsCh <- block }() - lastProcessedBlock, err = eh.HandleBlockEventsStream(ctx, eventsCh, false) + lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) + + // Use the block's actual blockNumber + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) - require.Equal(t, blockNum+1, lastProcessedBlock) - blockNum++ requireKeyManagerDataToExist(t, eh, 3, validatorData3) @@ -535,22 +565,27 @@ func TestHandleBlockEventsStream(t *testing.T) { Balance: big.NewInt(100_000_000), }) require.NoError(t, err) + sim.Commit() + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } - block = <-logs + block := getBlockWithLogs(logs) require.NotEmpty(t, block.Logs) require.Equal(t, ethcommon.HexToHash("0x48a3ea0796746043948f6341d17ff8200937b99262a0b48c2663b951ed7114e5"), block.Logs[0].Topics[0]) - eventsCh = make(chan executionclient.BlockLogs) + eventsCh := make(chan executionclient.BlockLogs) go func() { defer close(eventsCh) eventsCh <- block }() - lastProcessedBlock, err = eh.HandleBlockEventsStream(ctx, eventsCh, false) + lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) + + // Use the block's actual blockNumber + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) - require.Equal(t, blockNum+1, lastProcessedBlock) - blockNum++ requireKeyManagerDataToExist(t, eh, 4, validatorData4) @@ -579,9 +614,13 @@ func TestHandleBlockEventsStream(t *testing.T) { []uint64{1, 2, 3, 4}, ) require.NoError(t, err) + sim.Commit() + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } - block := <-logs + block := getBlockWithLogs(logs) require.NotEmpty(t, block.Logs) require.Equal(t, ethcommon.HexToHash("0xb4b20ffb2eb1f020be3df600b2287914f50c07003526d3a9d89a9dd12351828c"), block.Logs[0].Topics[0]) @@ -590,11 +629,10 @@ func TestHandleBlockEventsStream(t *testing.T) { defer close(eventsCh) eventsCh <- block }() - lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - require.Equal(t, blockNum+1, lastProcessedBlock) + // Use the block's actual blockNumber + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) - blockNum++ }) t.Run("ValidatorExited incorrect owner address", func(t *testing.T) { @@ -606,9 +644,13 @@ func TestHandleBlockEventsStream(t *testing.T) { []uint64{1, 2, 3, 4}, ) require.NoError(t, err) + sim.Commit() + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } - block := <-logs + block := getBlockWithLogs(logs) require.NotEmpty(t, block.Logs) require.Equal(t, ethcommon.HexToHash("0xb4b20ffb2eb1f020be3df600b2287914f50c07003526d3a9d89a9dd12351828c"), block.Logs[0].Topics[0]) @@ -619,9 +661,9 @@ func TestHandleBlockEventsStream(t *testing.T) { }() lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - require.Equal(t, blockNum+1, lastProcessedBlock) + // Use the block's actual blockNumber + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) - blockNum++ }) // Receive event, unmarshall, parse, check parse event is not nil or with an error, @@ -646,9 +688,13 @@ func TestHandleBlockEventsStream(t *testing.T) { []uint64{1, 2, 3, 4}, ) require.NoError(t, err) + sim.Commit() + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } - block := <-logs + block := getBlockWithLogs(logs) require.NotEmpty(t, block.Logs) require.Equal(t, ethcommon.HexToHash("0xb4b20ffb2eb1f020be3df600b2287914f50c07003526d3a9d89a9dd12351828c"), block.Logs[0].Topics[0]) @@ -657,11 +703,10 @@ func TestHandleBlockEventsStream(t *testing.T) { defer close(eventsCh) eventsCh <- block }() - lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - require.Equal(t, blockNum+1, lastProcessedBlock) + // Use the block's actual blockNumber + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) - blockNum++ // Check the validator is in the validator shares storage. shares := eh.nodeStorage.Shares().List(nil) @@ -670,6 +715,38 @@ func TestHandleBlockEventsStream(t *testing.T) { require.True(t, exists) require.NotNil(t, valShare) }) + + t.Run("ValidatorExited incorrect event public key", func(t *testing.T) { + pk := validatorData1.masterPubKey.Serialize() + // Corrupt the public key + pk[len(pk)-1] ^= 1 + + _, err = boundContract.ExitValidator( + auth, + pk, + []uint64{1, 2, 3, 4}, + ) + require.NoError(t, err) + + sim.Commit() + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } + + block := getBlockWithLogs(logs) + require.NotEmpty(t, block.Logs) + require.Equal(t, ethcommon.HexToHash("0xb4b20ffb2eb1f020be3df600b2287914f50c07003526d3a9d89a9dd12351828c"), block.Logs[0].Topics[0]) + + eventsCh := make(chan executionclient.BlockLogs) + go func() { + defer close(eventsCh) + eventsCh <- block + }() + lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) + // Use the block's actual blockNumber + require.Equal(t, block.BlockNumber, lastProcessedBlock) + require.NoError(t, err) + }) }) t.Run("test ValidatorRemoved event handling", func(t *testing.T) { @@ -691,9 +768,13 @@ func TestHandleBlockEventsStream(t *testing.T) { Balance: big.NewInt(100_000_000), }) require.NoError(t, err) + sim.Commit() + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } - block := <-logs + block := getBlockWithLogs(logs) require.NotEmpty(t, block.Logs) require.Equal(t, ethcommon.HexToHash("0xccf4370403e5fbbde0cd3f13426479dcd8a5916b05db424b7a2c04978cf8ce6e"), block.Logs[0].Topics[0]) @@ -704,9 +785,9 @@ func TestHandleBlockEventsStream(t *testing.T) { }() lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - require.Equal(t, blockNum+1, lastProcessedBlock) + // Use the block's actual blockNumber + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) - blockNum++ // Check the validator's shares are still present in the state after incorrect ValidatorRemoved event valShare, exists := eh.nodeStorage.Shares().Get(nil, validatorData1.masterPubKey.Serialize()) @@ -729,9 +810,13 @@ func TestHandleBlockEventsStream(t *testing.T) { Balance: big.NewInt(100_000_000), }) require.NoError(t, err) + sim.Commit() + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } - block := <-logs + block := getBlockWithLogs(logs) require.NotEmpty(t, block.Logs) require.Equal(t, ethcommon.HexToHash("0xccf4370403e5fbbde0cd3f13426479dcd8a5916b05db424b7a2c04978cf8ce6e"), block.Logs[0].Topics[0]) @@ -742,9 +827,9 @@ func TestHandleBlockEventsStream(t *testing.T) { }() lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - require.Equal(t, blockNum+1, lastProcessedBlock) + // Use the block's actual blockNumber + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) - blockNum++ // Check the validator's shares are still present in the state after incorrect ValidatorRemoved event valShare, exists := eh.nodeStorage.Shares().Get(nil, validatorData1.masterPubKey.Serialize()) @@ -775,9 +860,13 @@ func TestHandleBlockEventsStream(t *testing.T) { Balance: big.NewInt(100_000_000), }) require.NoError(t, err) + sim.Commit() + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } - block := <-logs + block := getBlockWithLogs(logs) require.NotEmpty(t, block.Logs) require.Equal(t, ethcommon.HexToHash("0xccf4370403e5fbbde0cd3f13426479dcd8a5916b05db424b7a2c04978cf8ce6e"), block.Logs[0].Topics[0]) @@ -788,9 +877,9 @@ func TestHandleBlockEventsStream(t *testing.T) { }() lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - require.Equal(t, blockNum+1, lastProcessedBlock) + // Use the block's actual blockNumber + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) - blockNum++ // Check the validator was removed from the validator shares storage. shares := eh.nodeStorage.Shares().List(nil) @@ -817,9 +906,14 @@ func TestHandleBlockEventsStream(t *testing.T) { Balance: big.NewInt(100_000_000), }) require.NoError(t, err) + sim.Commit() - block := <-logs + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } + + block := getBlockWithLogs(logs) require.NotEmpty(t, block.Logs) require.Equal(t, ethcommon.HexToHash("0x1fce24c373e07f89214e9187598635036111dbb363e99f4ce498488cdc66e688"), block.Logs[0].Topics[0]) @@ -838,9 +932,8 @@ func TestHandleBlockEventsStream(t *testing.T) { require.False(t, share.Liquidated) lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - require.Equal(t, blockNum+1, lastProcessedBlock) + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) - blockNum++ share, exists = eh.nodeStorage.Shares().Get(nil, valPubKey) require.True(t, exists) @@ -878,9 +971,14 @@ func TestHandleBlockEventsStream(t *testing.T) { Balance: big.NewInt(100_000_000), }) require.NoError(t, err) + sim.Commit() - block := <-logs + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } + + block := getBlockWithLogs(logs) require.NotEmpty(t, block.Logs) require.Equal(t, ethcommon.HexToHash("0xc803f8c01343fcdaf32068f4c283951623ef2b3fa0c547551931356f456b6859"), block.Logs[0].Topics[0]) @@ -893,7 +991,7 @@ func TestHandleBlockEventsStream(t *testing.T) { currentSlot.SetSlot(1000) lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - require.Equal(t, blockNum+1, lastProcessedBlock) + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) // check that slashing data was bumped @@ -910,7 +1008,6 @@ func TestHandleBlockEventsStream(t *testing.T) { require.True(t, found) require.Equal(t, highestProposal, currentSlot.GetSlot()) - blockNum++ }) // Liquidated event is far in the future @@ -928,9 +1025,14 @@ func TestHandleBlockEventsStream(t *testing.T) { Balance: big.NewInt(100_000_000), }) require.NoError(t, err) + sim.Commit() - block := <-logs + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } + + block := getBlockWithLogs(logs) require.NotEmpty(t, block.Logs) require.Equal(t, ethcommon.HexToHash("0x1fce24c373e07f89214e9187598635036111dbb363e99f4ce498488cdc66e688"), block.Logs[0].Topics[0]) @@ -941,9 +1043,8 @@ func TestHandleBlockEventsStream(t *testing.T) { }() lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - require.Equal(t, blockNum+1, lastProcessedBlock) + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) - blockNum++ }) // Reactivate event @@ -962,9 +1063,14 @@ func TestHandleBlockEventsStream(t *testing.T) { Balance: big.NewInt(100_000_000), }) require.NoError(t, err) + sim.Commit() - block := <-logs + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } + + block := getBlockWithLogs(logs) require.NotEmpty(t, block.Logs) require.Equal(t, ethcommon.HexToHash("0xc803f8c01343fcdaf32068f4c283951623ef2b3fa0c547551931356f456b6859"), block.Logs[0].Topics[0]) @@ -984,7 +1090,7 @@ func TestHandleBlockEventsStream(t *testing.T) { currentSlot.SetSlot(100) lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - require.Equal(t, blockNum+1, lastProcessedBlock) + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) // check that slashing data is greater than current epoch @@ -1001,8 +1107,6 @@ func TestHandleBlockEventsStream(t *testing.T) { require.True(t, found) require.Greater(t, highestProposal, currentSlot.GetSlot()) - blockNum++ - share, exists = eh.nodeStorage.Shares().Get(nil, valPubKey) require.True(t, exists) require.NotNil(t, share) @@ -1016,9 +1120,14 @@ func TestHandleBlockEventsStream(t *testing.T) { testAddr2, ) require.NoError(t, err) + sim.Commit() - block := <-logs + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } + + block := getBlockWithLogs(logs) require.NotEmpty(t, block.Logs) require.Equal(t, ethcommon.HexToHash("0x259235c230d57def1521657e7c7951d3b385e76193378bc87ef6b56bc2ec3548"), block.Logs[0].Topics[0]) @@ -1029,9 +1138,9 @@ func TestHandleBlockEventsStream(t *testing.T) { }() lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - require.Equal(t, blockNum+1, lastProcessedBlock) + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) - blockNum++ + // Check if the fee recipient was updated recipientData, _, err := eh.nodeStorage.GetRecipientData(nil, testAddr) require.NoError(t, err) @@ -1066,8 +1175,11 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } - block := <-logs + block := getBlockWithLogs(logs) require.NotEmpty(t, block.Logs) require.Equal(t, ethcommon.HexToHash("0xd839f31c14bd632f424e307b36abff63ca33684f77f28e35dc13718ef338f7f4"), block.Logs[0].Topics[0]) require.Equal(t, ethcommon.HexToHash("0x0e0ba6c2b04de36d6d509ec5bd155c43a9fe862f8052096dd54f3902a74cca3e"), block.Logs[1].Topics[0]) @@ -1080,9 +1192,8 @@ func TestHandleBlockEventsStream(t *testing.T) { // Handle the event lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - require.Equal(t, blockNum+1, lastProcessedBlock) + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) - blockNum++ // #TODO: Fails until we fix the OperatorAdded: handlers.go #108 // Check storage for the new operators @@ -1144,8 +1255,11 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } - block := <-logs + block := getBlockWithLogs(logs) require.NotEmpty(t, block.Logs) require.Equal(t, ethcommon.HexToHash("0x48a3ea0796746043948f6341d17ff8200937b99262a0b48c2663b951ed7114e5"), block.Logs[0].Topics[0]) require.Equal(t, ethcommon.HexToHash("0xccf4370403e5fbbde0cd3f13426479dcd8a5916b05db424b7a2c04978cf8ce6e"), block.Logs[1].Topics[0]) @@ -1157,9 +1271,8 @@ func TestHandleBlockEventsStream(t *testing.T) { }() lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - require.Equal(t, blockNum+1, lastProcessedBlock) + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) - blockNum++ valShare, exists = eh.nodeStorage.Shares().Get(nil, valPubKey) require.False(t, exists) @@ -1208,8 +1321,11 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } - block := <-logs + block := getBlockWithLogs(logs) require.NotEmpty(t, block.Logs) require.Equal(t, ethcommon.HexToHash("0x1fce24c373e07f89214e9187598635036111dbb363e99f4ce498488cdc66e688"), block.Logs[0].Topics[0]) require.Equal(t, ethcommon.HexToHash("0xc803f8c01343fcdaf32068f4c283951623ef2b3fa0c547551931356f456b6859"), block.Logs[1].Topics[0]) @@ -1221,9 +1337,8 @@ func TestHandleBlockEventsStream(t *testing.T) { }() lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - require.Equal(t, blockNum+1, lastProcessedBlock) + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) - blockNum++ share, exists = eh.nodeStorage.Shares().Get(nil, valPubKey) require.True(t, exists) @@ -1239,12 +1354,15 @@ func TestHandleBlockEventsStream(t *testing.T) { // Call the contract method _, err = boundContract.RemoveOperator(auth, 100500) require.NoError(t, err) + sim.Commit() + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } - block := <-logs + block := getBlockWithLogs(logs) require.NotEmpty(t, block.Logs) require.Equal(t, ethcommon.HexToHash("0x0e0ba6c2b04de36d6d509ec5bd155c43a9fe862f8052096dd54f3902a74cca3e"), block.Logs[0].Topics[0]) - eventsCh := make(chan executionclient.BlockLogs) go func() { defer close(eventsCh) @@ -1258,9 +1376,8 @@ func TestHandleBlockEventsStream(t *testing.T) { // Handle the event lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - require.Equal(t, blockNum+1, lastProcessedBlock) + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) - blockNum++ // Check if the operator wasn't removed successfully operators, err = eh.nodeStorage.ListOperators(nil, 0, 0) @@ -1285,8 +1402,11 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } - block := <-logs + block := getBlockWithLogs(logs) require.NotEmpty(t, block.Logs) require.Equal(t, ethcommon.HexToHash("0xd839f31c14bd632f424e307b36abff63ca33684f77f28e35dc13718ef338f7f4"), block.Logs[0].Topics[0]) @@ -1303,9 +1423,9 @@ func TestHandleBlockEventsStream(t *testing.T) { // Handle OperatorAdded event lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - require.Equal(t, blockNum+1, lastProcessedBlock) + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) - blockNum++ + // Check storage for the new operator operators, err = eh.nodeStorage.ListOperators(nil, 0, 0) require.NoError(t, err) @@ -1315,9 +1435,13 @@ func TestHandleBlockEventsStream(t *testing.T) { // Call the contract method _, err = boundContract.RemoveOperator(auth, 4) require.NoError(t, err) + sim.Commit() + for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + sim.Commit() + } - block = <-logs + block = getBlockWithLogs(logs) require.NotEmpty(t, block.Logs) require.Equal(t, ethcommon.HexToHash("0x0e0ba6c2b04de36d6d509ec5bd155c43a9fe862f8052096dd54f3902a74cca3e"), block.Logs[0].Topics[0]) @@ -1333,9 +1457,8 @@ func TestHandleBlockEventsStream(t *testing.T) { // Handle OperatorRemoved event lastProcessedBlock, err = eh.HandleBlockEventsStream(ctx, eventsCh, false) - require.Equal(t, blockNum+1, lastProcessedBlock) + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) - blockNum++ // List operators and check that the operator was removed operators, err = eh.nodeStorage.ListOperators(nil, 0, 0) @@ -1689,3 +1812,14 @@ func requireKeyManagerDataToNotExist(t *testing.T, eh *EventHandler, expectedAcc require.NoError(t, err) require.False(t, found) } + +// getBlockWithLogs is a helper function to get a block with logs from the channel. +func getBlockWithLogs(logs <-chan executionclient.BlockLogs) executionclient.BlockLogs { + for { + block := <-logs + if len(block.Logs) > 0 { + return block + } + // Skip empty blocks + } +} diff --git a/eth/executionclient/mocks.go b/eth/executionclient/mocks.go index c03249a6a5..f9171642d4 100644 --- a/eth/executionclient/mocks.go +++ b/eth/executionclient/mocks.go @@ -24,6 +24,7 @@ import ( type MockProvider struct { ctrl *gomock.Controller recorder *MockProviderMockRecorder + isgomock struct{} } // MockProviderMockRecorder is the mock recorder for MockProvider. @@ -195,6 +196,7 @@ func (mr *MockProviderMockRecorder) SubscribeFilterLogs(ctx, q, ch any) *gomock. type MockSingleClientProvider struct { ctrl *gomock.Controller recorder *MockSingleClientProviderMockRecorder + isgomock struct{} } // MockSingleClientProviderMockRecorder is the mock recorder for MockSingleClientProvider. From 309eb637fb8ad2b12938a65cae8d54af323bebc2 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Mon, 5 May 2025 21:44:30 +0700 Subject: [PATCH 15/53] lint --- eth/eventhandler/event_handler_test.go | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/eth/eventhandler/event_handler_test.go b/eth/eventhandler/event_handler_test.go index f075e4f3b0..4e196f6213 100644 --- a/eth/eventhandler/event_handler_test.go +++ b/eth/eventhandler/event_handler_test.go @@ -182,7 +182,6 @@ func TestHandleBlockEventsStream(t *testing.T) { // Handle the event lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - // The block number should match the block where the events were emitted require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) @@ -303,7 +302,6 @@ func TestHandleBlockEventsStream(t *testing.T) { lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - // The block number should match the block where the events were emitted require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) @@ -359,7 +357,6 @@ func TestHandleBlockEventsStream(t *testing.T) { lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - // Use the block's actual blockNumber require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) @@ -414,7 +411,6 @@ func TestHandleBlockEventsStream(t *testing.T) { lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - // Use the block's actual blockNumber require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) @@ -474,7 +470,6 @@ func TestHandleBlockEventsStream(t *testing.T) { lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - // Use the block's actual blockNumber require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) @@ -528,7 +523,6 @@ func TestHandleBlockEventsStream(t *testing.T) { lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - // Use the block's actual blockNumber require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) @@ -583,7 +577,6 @@ func TestHandleBlockEventsStream(t *testing.T) { lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - // Use the block's actual blockNumber require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) @@ -630,7 +623,7 @@ func TestHandleBlockEventsStream(t *testing.T) { eventsCh <- block }() lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - // Use the block's actual blockNumber + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) }) @@ -661,7 +654,7 @@ func TestHandleBlockEventsStream(t *testing.T) { }() lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - // Use the block's actual blockNumber + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) }) @@ -704,7 +697,7 @@ func TestHandleBlockEventsStream(t *testing.T) { eventsCh <- block }() lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - // Use the block's actual blockNumber + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) @@ -743,7 +736,7 @@ func TestHandleBlockEventsStream(t *testing.T) { eventsCh <- block }() lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - // Use the block's actual blockNumber + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) }) @@ -785,7 +778,7 @@ func TestHandleBlockEventsStream(t *testing.T) { }() lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - // Use the block's actual blockNumber + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) @@ -827,7 +820,7 @@ func TestHandleBlockEventsStream(t *testing.T) { }() lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - // Use the block's actual blockNumber + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) @@ -877,7 +870,7 @@ func TestHandleBlockEventsStream(t *testing.T) { }() lastProcessedBlock, err := eh.HandleBlockEventsStream(ctx, eventsCh, false) - // Use the block's actual blockNumber + require.Equal(t, block.BlockNumber, lastProcessedBlock) require.NoError(t, err) From 0a1bc187e176cebe331bd53b9e15e5f8fbb1d787 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Tue, 6 May 2025 13:21:25 +0700 Subject: [PATCH 16/53] feat(executionclient): introduce SlotsPerEpoch constant to improve readability and use it to calculate DefaultFinalityDistance for better maintainability --- eth/executionclient/defaults.go | 4 +++- eth/executionclient/execution_client.go | 2 +- eth/executionclient/execution_client_test.go | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/eth/executionclient/defaults.go b/eth/executionclient/defaults.go index 15d7afa994..02d49d148c 100644 --- a/eth/executionclient/defaults.go +++ b/eth/executionclient/defaults.go @@ -5,9 +5,11 @@ import ( ) const ( - DefaultFinalityDistance = 32 // Default number of blocks for finality distance + SlotsPerEpoch = 32 + DefaultFinalityDistance = SlotsPerEpoch * 2 DefaultConnectionTimeout = 10 * time.Second DefaultHealthInvalidationInterval = 24 * time.Second // TODO: decide on this value, for now choosing the node prober interval but it should probably be a bit less than block interval + // TODO ALAN: revert DefaultHistoricalLogsBatchSize = 200 defaultLogBuf = 8 * 1024 diff --git a/eth/executionclient/execution_client.go b/eth/executionclient/execution_client.go index 432e248586..0f0d571c0c 100644 --- a/eth/executionclient/execution_client.go +++ b/eth/executionclient/execution_client.go @@ -438,7 +438,7 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logsCh chan<- B toBlock := finalizedBlock.Number.Uint64() if toBlock != lastFinalized { - finalizedEpoch := toBlock / DefaultFinalityDistance + finalizedEpoch := toBlock / SlotsPerEpoch ec.logger.Info("⏱ finalized block changed", zap.Uint64("new_finalized", toBlock), zap.Uint64("epoch", finalizedEpoch), diff --git a/eth/executionclient/execution_client_test.go b/eth/executionclient/execution_client_test.go index 429def5207..3fc7c87b7c 100644 --- a/eth/executionclient/execution_client_test.go +++ b/eth/executionclient/execution_client_test.go @@ -155,7 +155,7 @@ func (env *testEnv) createBlocksWithLogs(contract *bind.BoundContract, count int return nil } -// finalize mines 32 blocks (DefaultFinalityDistance ) so that HeaderByNumber("finalized") advances +// finalize mines 64 blocks (DefaultFinalityDistance) to simulate proper finalization (2 epochs). func (env *testEnv) finalize() { for i := 0; i < DefaultFinalityDistance; i++ { env.sim.Commit() From 7b19564a33bdfea2bda3a65214f41391fdf37638 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Tue, 6 May 2025 14:18:42 +0700 Subject: [PATCH 17/53] improve tests --- eth/executionclient/execution_client_test.go | 59 +++++++++++++------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/eth/executionclient/execution_client_test.go b/eth/executionclient/execution_client_test.go index 3fc7c87b7c..fd80b6230f 100644 --- a/eth/executionclient/execution_client_test.go +++ b/eth/executionclient/execution_client_test.go @@ -182,8 +182,8 @@ func TestFetchHistoricalLogs(t *testing.T) { err = env.createBlocksWithLogs(contract, blocksWithLogsLength, 0) require.NoError(t, err) - // Commit one extra empty block so that the previous blocks become "finalized" - env.sim.Commit() + // Finalize the blocks + env.finalize() // Fetch all logs history starting from block 0 var fetchedLogs []ethtypes.Log @@ -258,8 +258,8 @@ func TestStreamLogs(t *testing.T) { err = env.createBlocksWithLogs(contract, blocksWithLogsLength, delay) require.NoError(t, err) - // Commit one empty block to allow previous block to become finalized - env.sim.Commit() + // Finalize the blocks to ensure they're processed + env.finalize() time.Sleep(delay) // Wait until we've received all events @@ -361,6 +361,9 @@ func TestFetchLogsInBatches(t *testing.T) { err = env.createBlocksWithLogs(contract, blocksWithLogsLength, 0) require.NoError(t, err) + // Finalize the blocks + env.finalize() + t.Run("startBlock is greater than endBlock", func(t *testing.T) { logChan, errChan := env.client.fetchLogsInBatches(env.ctx, 10, 5) select { @@ -466,11 +469,14 @@ func TestChainReorganizationLogs(t *testing.T) { txHashes[originalTx.Hash()] = originalBlockNum t.Logf("original chain block number: %d, tx hash: %s", originalBlockNum, originalTx.Hash().Hex()) + checkCtx, cancel := context.WithTimeout(env.ctx, 150*time.Millisecond) + defer cancel() + // 4. No logs should be received since the block isn't finalized select { case log := <-logsCh: - require.Fail(t, "received logs from unfinalized block", "log", log) - case <-time.After(100 * time.Millisecond): + require.Fail(t, "received logs from unfinalized fork", "log", log) + case <-checkCtx.Done(): // no logs } @@ -491,11 +497,14 @@ func TestChainReorganizationLogs(t *testing.T) { txHashes[forkTx.Hash()] = forkBlockNum t.Logf("fork chain block number: %d, tx hash: %s", forkBlockNum, forkTx.Hash().Hex()) + checkCtx2, cancel2 := context.WithTimeout(env.ctx, 150*time.Millisecond) + defer cancel2() + // Still no logs should be received since the fork isn't finalized select { case log := <-logsCh: require.Fail(t, "received logs from unfinalized fork", "log", log) - case <-time.After(100 * time.Millisecond): + case <-checkCtx2.Done(): // no logs } @@ -776,11 +785,14 @@ func TestFilterLogs(t *testing.T) { err = env.createBlocksWithLogs(contract, 5, 0) require.NoError(t, err) + // Finalize blocks to make them available for filtering + env.finalize() + // Test the FilterLogs method logs, err := env.client.FilterLogs(env.ctx, ethereum.FilterQuery{ Addresses: []ethcommon.Address{env.contractAddr}, FromBlock: big.NewInt(0), - ToBlock: big.NewInt(6), + ToBlock: big.NewInt(70), // 0 genesis + 1 deployed + 5 created + 64 finalized }) require.NoError(t, err) require.NotEmpty(t, logs) @@ -869,6 +881,9 @@ func TestSubscribeFilterLogs(t *testing.T) { err = env.createBlocksWithLogs(contract, 3, 10*time.Millisecond) require.NoError(t, err) + // Finalize the blocks + env.finalize() + // Wait for logs to be received wg.Wait() @@ -927,10 +942,8 @@ func TestBlockByNumber(t *testing.T) { err = env.createClient(WithLogger(logger)) require.NoError(t, err) - // Create some blocks - for i := 0; i < 5; i++ { - env.sim.Commit() - } + // Finalize the blocks + env.finalize() // Test the BlockByNumber method with specific block number block, err := env.client.BlockByNumber(env.ctx, big.NewInt(2)) @@ -938,11 +951,15 @@ func TestBlockByNumber(t *testing.T) { require.NotNil(t, block) require.Equal(t, uint64(2), block.NumberU64()) - // Test the BlockByNumber method with nil (latest block) + // Calculate the expected latest block number based on: + // - Genesis block = 0 + // - Contract deployment = +1 block + // - finalize() adds DefaultFinalityDistance blocks = +64 blocks + expectedLatestBlock := uint64(65) // 0 + 1 + 64 latestBlock, err := env.client.BlockByNumber(env.ctx, nil) require.NoError(t, err) require.NotNil(t, latestBlock) - require.Equal(t, uint64(6), latestBlock.NumberU64()) // Genesis + 1 from deploy + 5 from loop + require.Equal(t, expectedLatestBlock, latestBlock.NumberU64()) }) t.Run("error when BlockByNumber fails", func(t *testing.T) { @@ -983,10 +1000,8 @@ func TestHeaderByNumber(t *testing.T) { err = env.createClient(WithLogger(logger)) require.NoError(t, err) - // Create some blocks - for i := 0; i < 5; i++ { - env.sim.Commit() - } + // Finalize the blocks + env.finalize() // Test the HeaderByNumber method with specific block number header, err := env.client.HeaderByNumber(env.ctx, big.NewInt(2)) @@ -994,11 +1009,15 @@ func TestHeaderByNumber(t *testing.T) { require.NotNil(t, header) require.Equal(t, uint64(2), header.Number.Uint64()) - // Test the HeaderByNumber method with nil (latest block) + // Calculate the expected latest header number based on: + // - Genesis block = 0 + // - Contract deployment = +1 block + // - finalize() adds DefaultFinalityDistance blocks = +64 blocks + expectedLatestHeader := uint64(65) // 0 + 1 + 64 latestHeader, err := env.client.HeaderByNumber(env.ctx, nil) require.NoError(t, err) require.NotNil(t, latestHeader) - require.Equal(t, uint64(6), latestHeader.Number.Uint64()) // Genesis + 1 from deploy + 5 from loop + require.Equal(t, expectedLatestHeader, latestHeader.Number.Uint64()) }) t.Run("error when HeaderByNumber fails", func(t *testing.T) { From 0d35334f33efaa2209ac912dacc384c3f5ed2cea Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Tue, 6 May 2025 14:34:59 +0700 Subject: [PATCH 18/53] improve docs + timeouts --- eth/executionclient/execution_client_test.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/eth/executionclient/execution_client_test.go b/eth/executionclient/execution_client_test.go index fd80b6230f..86034d7985 100644 --- a/eth/executionclient/execution_client_test.go +++ b/eth/executionclient/execution_client_test.go @@ -103,6 +103,8 @@ func setupTestEnv(t *testing.T, testTimeout time.Duration) *testEnv { } // deployCallableContract deploys the test contract for event testing. +// Note: This method only commits ONE block, it does not finalize the deployment. +// Call env.finalize() separately if blocks need to be finalized. func (env *testEnv) deployCallableContract() (*bind.BoundContract, error) { parsed, _ := abi.JSON(strings.NewReader(callableAbi)) contractAddr, _, contract, err := bind.DeployContract( @@ -348,7 +350,7 @@ func TestStreamLogs(t *testing.T) { // TestFetchLogsInBatches tests the fetchLogsInBatches function of the client. func TestFetchLogsInBatches(t *testing.T) { logger := zaptest.NewLogger(t) - env := setupTestEnv(t, 1*time.Second) + env := setupTestEnv(t, 2*time.Second) // Deploy the contract contract, err := env.deployCallableContract() @@ -436,7 +438,7 @@ func TestFetchLogsInBatches(t *testing.T) { func TestChainReorganizationLogs(t *testing.T) { logger := zaptest.NewLogger(t) - env := setupTestEnv(t, 2*time.Second) + env := setupTestEnv(t, 3*time.Second) // 1. Deploy the contract contract, err := env.deployCallableContract() @@ -469,7 +471,7 @@ func TestChainReorganizationLogs(t *testing.T) { txHashes[originalTx.Hash()] = originalBlockNum t.Logf("original chain block number: %d, tx hash: %s", originalBlockNum, originalTx.Hash().Hex()) - checkCtx, cancel := context.WithTimeout(env.ctx, 150*time.Millisecond) + checkCtx, cancel := context.WithTimeout(env.ctx, 500*time.Millisecond) defer cancel() // 4. No logs should be received since the block isn't finalized @@ -497,7 +499,7 @@ func TestChainReorganizationLogs(t *testing.T) { txHashes[forkTx.Hash()] = forkBlockNum t.Logf("fork chain block number: %d, tx hash: %s", forkBlockNum, forkTx.Hash().Hex()) - checkCtx2, cancel2 := context.WithTimeout(env.ctx, 150*time.Millisecond) + checkCtx2, cancel2 := context.WithTimeout(env.ctx, 500*time.Millisecond) defer cancel2() // Still no logs should be received since the fork isn't finalized @@ -516,7 +518,7 @@ func TestChainReorganizationLogs(t *testing.T) { select { case receivedLog = <-logsCh: // received logs - case <-time.After(1 * time.Second): + case <-time.After(2 * time.Second): require.Fail(t, "did not receive logs after finalization") } @@ -531,6 +533,8 @@ func TestChainReorganizationLogs(t *testing.T) { } // deploySimContract deploys the SSV simulator contract. +// Note: This method only commits ONE block, it does not finalize the deployment. +// Call env.finalize() separately if blocks need to be finalized. func (env *testEnv) deploySimContract() (*simcontract.Simcontract, error) { parsed, _ := abi.JSON(strings.NewReader(simcontract.SimcontractMetaData.ABI)) contractAddr, _, _, err := bind.DeployContract( @@ -564,7 +568,7 @@ func TestSimSSV(t *testing.T) { logger, err := zap.NewDevelopment() require.NoError(t, err) - env := setupTestEnv(t, 1*time.Second) + env := setupTestEnv(t, 3*time.Second) // Deploy the SSV contract boundContract, err := env.deploySimContract() From 4ee20a460f04819b64d6a503b70bf8b812ca02c0 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Tue, 6 May 2025 23:33:32 +0700 Subject: [PATCH 19/53] restore followDistance logic back TODO: update tests [skip ci] --- eth/executionclient/config.go | 7 ++ eth/executionclient/defaults.go | 7 +- eth/executionclient/execution_client.go | 140 ++++++++++++++---------- eth/executionclient/multi_client.go | 22 ++++ eth/executionclient/options.go | 30 +++++ 5 files changed, 149 insertions(+), 57 deletions(-) diff --git a/eth/executionclient/config.go b/eth/executionclient/config.go index 63221935c3..07d7ed6044 100644 --- a/eth/executionclient/config.go +++ b/eth/executionclient/config.go @@ -12,3 +12,10 @@ type Options struct { ConnectionTimeout time.Duration `yaml:"ETH1ConnectionTimeout" env:"ETH_1_CONNECTION_TIMEOUT" env-default:"10s" env-description:"Timeout for execution client connections"` SyncDistanceTolerance uint64 `yaml:"ETH1SyncDistanceTolerance" env:"ETH_1_SYNC_DISTANCE_TOLERANCE" env-default:"5" env-description:"Maximum number of blocks behind head considered in-sync"` } + +type Fork string + +// IsFinalityActive returns true if the finality fork is active at the given epoch. +func IsFinalityActive(epoch uint64, finalityForkEpoch uint64) bool { + return finalityForkEpoch > 0 && epoch >= finalityForkEpoch +} diff --git a/eth/executionclient/defaults.go b/eth/executionclient/defaults.go index 02d49d148c..1a705907e7 100644 --- a/eth/executionclient/defaults.go +++ b/eth/executionclient/defaults.go @@ -5,8 +5,11 @@ import ( ) const ( - SlotsPerEpoch = 32 - DefaultFinalityDistance = SlotsPerEpoch * 2 + SlotsPerEpoch = 32 + DefaultFinalityDistance = SlotsPerEpoch * 2 + DefaultFollowDistance = 8 // Default follow distance for pre-finality fork + DefaultFinalityForkEpoch = 0 // Epoch at which to enable finalized blocks from execution client (0 means disabled) + DefaultConnectionTimeout = 10 * time.Second DefaultHealthInvalidationInterval = 24 * time.Second // TODO: decide on this value, for now choosing the node prober interval but it should probably be a bit less than block interval diff --git a/eth/executionclient/execution_client.go b/eth/executionclient/execution_client.go index 0f0d571c0c..099adb35b6 100644 --- a/eth/executionclient/execution_client.go +++ b/eth/executionclient/execution_client.go @@ -65,6 +65,8 @@ type ExecutionClient struct { connectionTimeout time.Duration healthInvalidationInterval time.Duration logBatchSize uint64 + followDistance uint64 // Follow distance for pre-finality fork + finalityForkEpoch uint64 // Epoch at which finality fork occurs syncDistanceTolerance uint64 syncProgressFn func(context.Context) (*ethereum.SyncProgress, error) @@ -84,6 +86,8 @@ func New(ctx context.Context, nodeAddr string, contractAddr ethcommon.Address, o connectionTimeout: DefaultConnectionTimeout, healthInvalidationInterval: DefaultHealthInvalidationInterval, logBatchSize: DefaultHistoricalLogsBatchSize, // TODO Make batch of logs adaptive depending on "websocket: read limit" + followDistance: DefaultFollowDistance, + finalityForkEpoch: DefaultFinalityForkEpoch, closed: make(chan struct{}), } for _, opt := range opts { @@ -120,15 +124,44 @@ func (ec *ExecutionClient) Close() error { // FetchHistoricalLogs retrieves historical logs emitted by the contract starting from fromBlock. func (ec *ExecutionClient) FetchHistoricalLogs(ctx context.Context, fromBlock uint64) (logs <-chan BlockLogs, errors <-chan error, err error) { - finalizedBlock, err := ec.client.HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) + // Get current block to determine which finality method to use + currentBlock, err := ec.client.BlockNumber(ctx) if err != nil { ec.logger.Error(elResponseErrMsg, - zap.String("method", "eth_getBlockByNumber"), + zap.String("method", "eth_blockNumber"), zap.Error(err)) - return nil, nil, fmt.Errorf("failed to get finalized block: %w", err) + return nil, nil, fmt.Errorf("failed to get current block: %w", err) + } + + // Calculate current epoch + currentEpoch := currentBlock / SlotsPerEpoch + + var toBlock uint64 + + // Choose between finality and follow distance based on fork status + if IsFinalityActive(currentEpoch, ec.finalityForkEpoch) { + // Post-fork: Use finalized block from execution client + finalizedBlock, err := ec.client.HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) + if err != nil { + ec.logger.Error(elResponseErrMsg, + zap.String("method", "eth_getBlockByNumber"), + zap.String("tag", "finalized"), + zap.Error(err)) + return nil, nil, fmt.Errorf("get finalized block: %w", err) + } + toBlock = finalizedBlock.Number.Uint64() + } else { + // Pre-fork: Use follow distance like the original implementation + if currentBlock < ec.followDistance { + return nil, nil, ErrNothingToSync + } + toBlock = currentBlock - ec.followDistance } - toBlock := finalizedBlock.Number.Uint64() + // Wait until the finalized block number (toBlock) catches up to the block we want to start syncing from (fromBlock). + // For example, if we last processed block 123456, fromBlock = 123457. + // If Ethereum finality is only at 123454, we must wait until it reaches 123457 to continue. + // This prevents fetching logs from unfinalized (and potentially reorged) blocks. if toBlock < fromBlock { return nil, nil, ErrNothingToSync } @@ -367,22 +400,13 @@ func (ec *ExecutionClient) FilterLogs(ctx context.Context, q ethereum.FilterQuer return logs, nil } -func (ec *ExecutionClient) isClosed() bool { - select { - case <-ec.closed: - return true - default: - return false - } -} - // streamLogsToChan streams ongoing logs from the given block to the given channel. // streamLogsToChan *always* returns the last block it fetched, even if it errored. // TODO: consider handling "websocket: read limit exceeded" error and reducing batch size (syncSmartContractsEvents has code for this) -func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logsCh chan<- BlockLogs, fromBlock uint64) (lastBlock uint64, err error) { +func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- BlockLogs, fromBlock uint64) (lastBlock uint64, err error) { headersCh := make(chan *ethtypes.Header) - // Generally, execution client can stream logsCh using SubscribeFilterLogs, but we chose to use SubscribeNewHead + FilterLogs. + // Generally, execution client can stream logs using SubscribeFilterLogs, but we chose to use SubscribeNewHead + FilterLogs. // // We must receive all events as they determine the state of the ssv network, so a discrepancy can result in slashing. // Therefore, we must be sure that we don't miss any log while streaming. @@ -400,14 +424,12 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logsCh chan<- B sub, err := ec.client.SubscribeNewHead(ctx, headersCh) if err != nil { ec.logger.Error(elResponseErrMsg, - zap.String("method", "eth_subscribe(newHeads)"), + zap.String("operation", "SubscribeNewHead"), zap.Error(err)) - return fromBlock, fmt.Errorf("subscribe headersCh: %w", err) + return fromBlock, fmt.Errorf("subscribe heads: %w", err) } defer sub.Unsubscribe() - var lastFinalized uint64 - for { select { case <-ctx.Done(): @@ -416,55 +438,54 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logsCh chan<- B case <-ec.closed: return fromBlock, ErrClosed - case subErr := <-sub.Err(): - if subErr == nil { + case err := <-sub.Err(): + if err == nil { return fromBlock, ErrClosed } - return fromBlock, fmt.Errorf("subscription: %w", subErr) + return fromBlock, fmt.Errorf("subscription: %w", err) case header := <-headersCh: - ec.logger.Debug("new head received", - zap.Uint64("head_number", header.Number.Uint64()), - zap.String("head_hash", header.Hash().Hex()), - zap.String("head_parent_hash", header.ParentHash.Hex())) - - finalizedBlock, err := ec.client.HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) - if err != nil { - ec.logger.Error(elResponseErrMsg, - zap.String("method", "eth_getBlockByNumber"), - zap.Error(err)) - return fromBlock, fmt.Errorf("get finalized block: %w", err) - } - toBlock := finalizedBlock.Number.Uint64() - - if toBlock != lastFinalized { - finalizedEpoch := toBlock / SlotsPerEpoch - ec.logger.Info("⏱ finalized block changed", - zap.Uint64("new_finalized", toBlock), - zap.Uint64("epoch", finalizedEpoch), - zap.Uint64("previous_finalized", lastFinalized)) - lastFinalized = toBlock + // Calculate current epoch + currentEpoch := header.Number.Uint64() / SlotsPerEpoch + + var toBlock uint64 + + // Choose between finality and follow distance based on fork status + if IsFinalityActive(currentEpoch, ec.finalityForkEpoch) { + // Post-fork: Use finalized block from execution client + finalizedBlock, err := ec.client.HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) + if err != nil { + ec.logger.Error(elResponseErrMsg, + zap.String("method", "eth_getBlockByNumber"), + zap.String("tag", "finalized"), + zap.Error(err)) + return fromBlock, fmt.Errorf("get finalized block: %w", err) + } + toBlock = finalizedBlock.Number.Uint64() + } else { + // Pre-fork: Use follow distance like the original implementation + if header.Number.Uint64() < ec.followDistance { + continue + } + toBlock = header.Number.Uint64() - ec.followDistance } // Wait until the finalized block number (toBlock) catches up to the block we want to start syncing from (fromBlock). // For example, if we last processed block 123456, fromBlock = 123457. // If Ethereum finality is only at 123454, we must wait until it reaches 123457 to continue. - // This prevents fetching logsCh from unfinalized (and potentially reorged) blocks. + // This prevents fetching logs from unfinalized (and potentially reorged) blocks. if toBlock < fromBlock { - ec.logger.Info("waiting for finalized block to reach fromBlock", - zap.Uint64("from_block", fromBlock), - zap.Uint64("finalized_block", toBlock)) continue } logStream, fetchErrors := ec.fetchLogsInBatches(ctx, fromBlock, toBlock) for block := range logStream { - logsCh <- block + logs <- block lastBlock = block.BlockNumber } if err := <-fetchErrors; err != nil { // If we get an error while fetching, we return the last block we fetched. - return lastBlock, fmt.Errorf("fetch logsCh: %w", err) + return lastBlock, fmt.Errorf("fetch logs: %w", err) } fromBlock = toBlock + 1 observability.RecordUint64Value(ctx, fromBlock, lastProcessedBlockGauge.Record, metric.WithAttributes(semconv.ServerAddress(ec.nodeAddr))) @@ -472,6 +493,14 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logsCh chan<- B } } +func (ec *ExecutionClient) Filterer() (*contract.ContractFilterer, error) { + return contract.NewContractFilterer(ec.contractAddress, ec.client) +} + +func (ec *ExecutionClient) ChainID(ctx context.Context) (*big.Int, error) { + return ec.client.ChainID(ctx) +} + // connect connects to Ethereum execution client. // It must not be called twice in parallel. func (ec *ExecutionClient) connect(ctx context.Context) error { @@ -494,10 +523,11 @@ func (ec *ExecutionClient) connect(ctx context.Context) error { return nil } -func (ec *ExecutionClient) Filterer() (*contract.ContractFilterer, error) { - return contract.NewContractFilterer(ec.contractAddress, ec.client) -} - -func (ec *ExecutionClient) ChainID(ctx context.Context) (*big.Int, error) { - return ec.client.ChainID(ctx) +func (ec *ExecutionClient) isClosed() bool { + select { + case <-ec.closed: + return true + default: + return false + } } diff --git a/eth/executionclient/multi_client.go b/eth/executionclient/multi_client.go index 1806fe6e61..7973b4fd85 100644 --- a/eth/executionclient/multi_client.go +++ b/eth/executionclient/multi_client.go @@ -58,6 +58,8 @@ type MultiClient struct { healthInvalidationInterval time.Duration logBatchSize uint64 syncDistanceTolerance uint64 + followDistance uint64 // Follow distance for pre-finality fork + finalityForkEpoch uint64 // Epoch at which finality fork occurred TODO: use a proper name contractAddress ethcommon.Address chainID atomic.Pointer[big.Int] @@ -89,6 +91,8 @@ func NewMulti( logger: zap.NewNop(), connectionTimeout: DefaultConnectionTimeout, logBatchSize: DefaultHistoricalLogsBatchSize, + followDistance: DefaultFollowDistance, + finalityForkEpoch: DefaultFinalityForkEpoch, } for _, opt := range opts { @@ -150,6 +154,8 @@ func (mc *MultiClient) connect(ctx context.Context, clientIndex int) error { WithConnectionTimeout(mc.connectionTimeout), WithHealthInvalidationInterval(mc.healthInvalidationInterval), WithSyncDistanceTolerance(mc.syncDistanceTolerance), + WithFollowDistance(mc.followDistance), + WithFinalityForkEpoch(mc.finalityForkEpoch), ) if err != nil { recordClientInitStatus(ctx, mc.nodeAddrs[clientIndex], false) @@ -502,3 +508,19 @@ func methodFromContext(ctx context.Context) string { } return v } + +// DescribeForkConfig returns a human-readable description of the fork configuration. +func (mc *MultiClient) DescribeForkConfig() string { + var banner string + banner += "SSV Multi-Client Configuration:\n" + banner += "--------------------------------\n" + banner += "Finality determination:\n" + banner += fmt.Sprintf(" - Follow distance: %d blocks\n", mc.followDistance) + if mc.finalityForkEpoch > 0 { + banner += fmt.Sprintf(" - Finality fork active at epoch: %d\n", mc.finalityForkEpoch) + } else { + banner += " - Finality fork: disabled\n" + } + banner += "--------------------------------\n" + return banner +} diff --git a/eth/executionclient/options.go b/eth/executionclient/options.go index 03890ccc87..d714239a54 100644 --- a/eth/executionclient/options.go +++ b/eth/executionclient/options.go @@ -81,3 +81,33 @@ func WithSyncDistanceToleranceMulti(count uint64) OptionMulti { s.syncDistanceTolerance = count } } + +// WithFollowDistance sets finalization offset (a block at this offset into the past +// from the head block will be considered as very likely finalized). +func WithFollowDistance(offset uint64) Option { + return func(s *ExecutionClient) { + s.followDistance = offset + } +} + +// WithFollowDistanceMulti sets finalization offset (a block at this offset into the past +// from the head block will be considered as very likely finalized). +func WithFollowDistanceMulti(offset uint64) OptionMulti { + return func(s *MultiClient) { + s.followDistance = offset + } +} + +// WithFinalityForkEpoch sets the epoch at which to switch from follow distance to finality signals. +func WithFinalityForkEpoch(epoch uint64) Option { + return func(s *ExecutionClient) { + s.finalityForkEpoch = epoch + } +} + +// WithFinalityForkEpochMulti sets the epoch at which to switch from follow distance to finality signals. +func WithFinalityForkEpochMulti(epoch uint64) OptionMulti { + return func(s *MultiClient) { + s.finalityForkEpoch = epoch + } +} From 209fcd760b69c1c73fdb6012bf181320a1c74781 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Wed, 7 May 2025 13:25:42 +0700 Subject: [PATCH 20/53] feat(eth/executionclient): add tests for IsFinalityActive function test(IsFinalityActive): add test cases to cover different scenarios for finality check based on the current epoch and finality fork epoch to determine if finality is active Cover cases where finality is disabled, active, and inactive Ensure the function returns the expected boolean value for each scenario --- eth/executionclient/config.go | 2 - eth/executionclient/config_test.go | 52 ++ eth/executionclient/execution_client.go | 56 +- eth/executionclient/execution_client_test.go | 715 ++++++++++++------- 4 files changed, 554 insertions(+), 271 deletions(-) create mode 100644 eth/executionclient/config_test.go diff --git a/eth/executionclient/config.go b/eth/executionclient/config.go index 07d7ed6044..0dbad59e0d 100644 --- a/eth/executionclient/config.go +++ b/eth/executionclient/config.go @@ -13,8 +13,6 @@ type Options struct { SyncDistanceTolerance uint64 `yaml:"ETH1SyncDistanceTolerance" env:"ETH_1_SYNC_DISTANCE_TOLERANCE" env-default:"5" env-description:"Maximum number of blocks behind head considered in-sync"` } -type Fork string - // IsFinalityActive returns true if the finality fork is active at the given epoch. func IsFinalityActive(epoch uint64, finalityForkEpoch uint64) bool { return finalityForkEpoch > 0 && epoch >= finalityForkEpoch diff --git a/eth/executionclient/config_test.go b/eth/executionclient/config_test.go new file mode 100644 index 0000000000..2b12d24305 --- /dev/null +++ b/eth/executionclient/config_test.go @@ -0,0 +1,52 @@ +package executionclient + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIsFinalityActive(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + currentEpoch uint64 + finalityForkEpoch uint64 + expected bool + }{ + { + name: "finality disabled when finalityForkEpoch is 0", + currentEpoch: 100, + finalityForkEpoch: 0, + expected: false, + }, + { + name: "finality inactive when current epoch is less than fork epoch", + currentEpoch: 99, + finalityForkEpoch: 100, + expected: false, + }, + { + name: "finality active when current epoch equals fork epoch", + currentEpoch: 100, + finalityForkEpoch: 100, + expected: true, + }, + { + name: "finality active when current epoch greater than fork epoch", + currentEpoch: 101, + finalityForkEpoch: 100, + expected: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + result := IsFinalityActive(tc.currentEpoch, tc.finalityForkEpoch) + require.Equal(t, tc.expected, result) + }) + } +} diff --git a/eth/executionclient/execution_client.go b/eth/executionclient/execution_client.go index 099adb35b6..2ecaf1b7e4 100644 --- a/eth/executionclient/execution_client.go +++ b/eth/executionclient/execution_client.go @@ -401,7 +401,7 @@ func (ec *ExecutionClient) FilterLogs(ctx context.Context, q ethereum.FilterQuer } // streamLogsToChan streams ongoing logs from the given block to the given channel. -// streamLogsToChan *always* returns the last block it fetched, even if it errored. +// *Always* returns the last block it fetched, even if it errored. // TODO: consider handling "websocket: read limit exceeded" error and reducing batch size (syncSmartContractsEvents has code for this) func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- BlockLogs, fromBlock uint64) (lastBlock uint64, err error) { headersCh := make(chan *ethtypes.Header) @@ -424,12 +424,14 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo sub, err := ec.client.SubscribeNewHead(ctx, headersCh) if err != nil { ec.logger.Error(elResponseErrMsg, - zap.String("operation", "SubscribeNewHead"), + zap.String("method", "eth_subscribe(newHeads)"), zap.Error(err)) return fromBlock, fmt.Errorf("subscribe heads: %w", err) } defer sub.Unsubscribe() + var lastFinalized uint64 + for { select { case <-ctx.Done(): @@ -438,43 +440,67 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo case <-ec.closed: return fromBlock, ErrClosed - case err := <-sub.Err(): - if err == nil { + case subErr := <-sub.Err(): + if subErr == nil { return fromBlock, ErrClosed } - return fromBlock, fmt.Errorf("subscription: %w", err) + return fromBlock, fmt.Errorf("subscription: %w", subErr) case header := <-headersCh: - // Calculate current epoch - currentEpoch := header.Number.Uint64() / SlotsPerEpoch + ec.logger.Debug("new head received", + zap.Uint64("head_number", header.Number.Uint64()), + zap.String("head_hash", header.Hash().Hex()), + zap.String("head_parent_hash", header.ParentHash.Hex())) + // Calculate current epoch to determine which finality approach to use + currentEpoch := header.Number.Uint64() / SlotsPerEpoch var toBlock uint64 - // Choose between finality and follow distance based on fork status if IsFinalityActive(currentEpoch, ec.finalityForkEpoch) { - // Post-fork: Use finalized block from execution client + // Post-fork: finalized block approach finalizedBlock, err := ec.client.HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) if err != nil { ec.logger.Error(elResponseErrMsg, zap.String("method", "eth_getBlockByNumber"), - zap.String("tag", "finalized"), zap.Error(err)) return fromBlock, fmt.Errorf("get finalized block: %w", err) } toBlock = finalizedBlock.Number.Uint64() + + ec.logger.Debug("using finalized block approach", + zap.Uint64("epoch", currentEpoch), + zap.Uint64("finality_fork_epoch", ec.finalityForkEpoch), + zap.Uint64("finalized_block", toBlock)) + + if toBlock != lastFinalized { + finalizedEpoch := toBlock / SlotsPerEpoch + ec.logger.Info("⏱ finalized block changed", + zap.Uint64("new_finalized", toBlock), + zap.Uint64("epoch", finalizedEpoch), + zap.Uint64("previous_finalized", lastFinalized)) + lastFinalized = toBlock + } } else { - // Pre-fork: Use follow distance like the original implementation + // Pre-fork: follow distance approach if header.Number.Uint64() < ec.followDistance { continue } toBlock = header.Number.Uint64() - ec.followDistance + + ec.logger.Debug("using follow distance approach", + zap.Uint64("epoch", currentEpoch), + zap.Uint64("finality_fork_epoch", ec.finalityForkEpoch), + zap.Uint64("head", header.Number.Uint64()), + zap.Uint64("follow_distance", ec.followDistance), + zap.Uint64("target_block", toBlock)) } - // Wait until the finalized block number (toBlock) catches up to the block we want to start syncing from (fromBlock). - // For example, if we last processed block 123456, fromBlock = 123457. - // If Ethereum finality is only at 123454, we must wait until it reaches 123457 to continue. - // This prevents fetching logs from unfinalized (and potentially reorged) blocks. + // Wait until the target block number catches up to where we need to start processing + // This prevents fetching logs from unfinalized (and potentially reorged) blocks if toBlock < fromBlock { + ec.logger.Info("waiting for finalized block to reach fromBlock", + zap.Uint64("from_block", fromBlock), + zap.Uint64("finalized_block", toBlock)) continue } diff --git a/eth/executionclient/execution_client_test.go b/eth/executionclient/execution_client_test.go index 86034d7985..20e38c82f9 100644 --- a/eth/executionclient/execution_client_test.go +++ b/eth/executionclient/execution_client_test.go @@ -168,15 +168,16 @@ func (env *testEnv) finalize() { func TestFetchHistoricalLogs(t *testing.T) { logger := zaptest.NewLogger(t) - t.Run("successfully fetches historical logs up to finalized block", func(t *testing.T) { + t.Run("post-fork: fetches historical logs up to finalized block", func(t *testing.T) { env := setupTestEnv(t, 1*time.Second) contract, err := env.deployCallableContract() require.NoError(t, err) - // Create a client and connect to the simulator + // Create a client and connect to the simulator with finality fork enabled err = env.createClient( WithLogger(logger), WithConnectionTimeout(2*time.Second), + WithFinalityForkEpoch(1), // Enable finality fork ) require.NoError(t, err) @@ -205,6 +206,100 @@ func TestFetchHistoricalLogs(t *testing.T) { } }) + t.Run("pre-fork: fetches historical logs using follow distance", func(t *testing.T) { + env := setupTestEnv(t, 1*time.Second) + contract, err := env.deployCallableContract() + require.NoError(t, err) + + // Create a client with finality fork disabled (using follow distance) + const followDistance = 8 + err = env.createClient( + WithLogger(logger), + WithConnectionTimeout(2*time.Second), + WithFollowDistance(followDistance), + WithFinalityForkEpoch(0), // Explicitly disable finality fork + ) + require.NoError(t, err) + + // Create blocks with transactions + err = env.createBlocksWithLogs(contract, blocksWithLogsLength, 0) + require.NoError(t, err) + + // Fetch all logs history starting from block 0 + var fetchedLogs []ethtypes.Log + logs, fetchErrCh, err := env.client.FetchHistoricalLogs(env.ctx, 0) + require.NoError(t, err) + + for block := range logs { + fetchedLogs = append(fetchedLogs, block.Logs...) + } + require.NotEmpty(t, fetchedLogs) + + expectedSeenLogs := blocksWithLogsLength - followDistance + require.Equal(t, expectedSeenLogs, len(fetchedLogs)) + + select { + case err := <-fetchErrCh: + require.NoError(t, err) + case <-env.ctx.Done(): + require.Fail(t, "timeout") + } + }) + + t.Run("pre-fork: error when currentBlock < followDistance", func(t *testing.T) { + env := setupTestEnv(t, 1*time.Second) + _, err := env.deployCallableContract() + require.NoError(t, err) + + // Create a client with a large followDistance and finality fork disabled + const followDistance = 100 // Much larger than the current block number + err = env.createClient( + WithLogger(logger), + WithConnectionTimeout(2*time.Second), + WithFollowDistance(followDistance), + WithFinalityForkEpoch(0), // Explicitly disable finality fork + ) + require.NoError(t, err) + + // Fetch logs - should fail because the currentBlock < followDistance + logs, fetchErrCh, err := env.client.FetchHistoricalLogs(env.ctx, 0) + require.ErrorIs(t, err, ErrNothingToSync) + require.Nil(t, logs) + require.Nil(t, fetchErrCh) + }) + + t.Run("pre-fork: error when toBlock < fromBlock", func(t *testing.T) { + env := setupTestEnv(t, 1*time.Second) + contract, err := env.deployCallableContract() + require.NoError(t, err) + + // Create a client with finality fork disabled + const followDistance = 8 + err = env.createClient( + WithLogger(logger), + WithConnectionTimeout(2*time.Second), + WithFollowDistance(followDistance), + WithFinalityForkEpoch(0), // Explicitly disable finality fork + ) + require.NoError(t, err) + + // Create some blocks + err = env.createBlocksWithLogs(contract, 10, 0) + require.NoError(t, err) + + // Fetch logs with fromBlock > toBlock + currentBlock, err := env.client.client.BlockNumber(env.ctx) + require.NoError(t, err) + + // Set fromBlock to a value greater than the currentBlock - followDistance + fromBlock := currentBlock - followDistance + 10 + + logs, fetchErrCh, err := env.client.FetchHistoricalLogs(env.ctx, fromBlock) + require.ErrorIs(t, err, ErrNothingToSync) + require.Nil(t, logs) + require.Nil(t, fetchErrCh) + }) + t.Run("error when BlockNumber fails", func(t *testing.T) { env := setupTestEnv(t, 1*time.Second) _, err := env.deployCallableContract() @@ -213,6 +308,7 @@ func TestFetchHistoricalLogs(t *testing.T) { // Create a client - connection should succeed initially err = env.createClient( WithLogger(logger), + WithFollowDistance(8), WithConnectionTimeout(100*time.Millisecond), ) require.NoError(t, err) // Connection is established initially @@ -226,12 +322,12 @@ func TestFetchHistoricalLogs(t *testing.T) { require.Error(t, err) require.Nil(t, logs) require.Nil(t, fetchErrCh) - require.ErrorContains(t, err, "failed to get finalized block") + require.ErrorContains(t, err, "failed to get current block") }) } func TestStreamLogs(t *testing.T) { - t.Run("successfully streams logs", func(t *testing.T) { + t.Run("post-fork: successfully streams logs using finality", func(t *testing.T) { logger, err := zap.NewDevelopment() require.NoError(t, err) @@ -241,8 +337,8 @@ func TestStreamLogs(t *testing.T) { contract, err := env.deployCallableContract() require.NoError(t, err) - // Create a client and connect to the simulator - err = env.createClient(WithLogger(logger)) + // Create a client and connect to the simulator with finality fork enabled + err = env.createClient(WithLogger(logger), WithFinalityForkEpoch(1)) require.NoError(t, err) logsCh := env.client.StreamLogs(env.ctx, 0) @@ -279,6 +375,66 @@ func TestStreamLogs(t *testing.T) { require.Len(t, streamedLogs, blocksWithLogsLength) }) + t.Run("pre-fork: successfully streams logs using follow distance", func(t *testing.T) { + logger, err := zap.NewDevelopment() + require.NoError(t, err) + + env := setupTestEnv(t, 2*time.Second) + + // Deploy the contract + contract, err := env.deployCallableContract() + require.NoError(t, err) + + // Create a client with explicit follow distance and disabled finality fork + const followDistance = 2 + err = env.createClient(WithLogger(logger), WithFollowDistance(followDistance), WithFinalityForkEpoch(0)) + require.NoError(t, err) + + logsCh := env.client.StreamLogs(env.ctx, 0) + var streamedLogs []ethtypes.Log + var streamedLogsCount atomic.Int64 + go func() { + for block := range logsCh { + streamedLogs = append(streamedLogs, block.Logs...) + streamedLogsCount.Add(int64(len(block.Logs))) + } + }() + + // Create blocks with transactions + delay := time.Millisecond * 10 + err = env.createBlocksWithLogs(contract, blocksWithLogsLength, delay) + require.NoError(t, err) + + // Wait for blocksWithLogsLength-followDistance blocks to be streamed. + waitForLogs := func(expectedCount int64) { + for { + select { + case <-env.ctx.Done(): + require.Failf(t, "timed out", "err: %v, streamedLogsCount: %d", env.ctx.Err(), streamedLogsCount.Load()) + case <-time.After(time.Millisecond * 5): + if streamedLogsCount.Load() == expectedCount { + return + } + } + } + } + + // With follow distance, we expect to see (blocksWithLogsLength - followDistance) logs initially + waitForLogs(int64(blocksWithLogsLength - followDistance)) + + // Create empty blocks with no transactions to advance the chain + // followDistance blocks ahead to see the remaining logs + for i := 0; i < followDistance; i++ { + env.sim.Commit() + time.Sleep(delay) + } + + // Now we should see all logs + waitForLogs(int64(blocksWithLogsLength)) + + require.Len(t, streamedLogs, blocksWithLogsLength) + }) + t.Run("returns when context is canceled", func(t *testing.T) { logger, err := zap.NewDevelopment() require.NoError(t, err) @@ -435,101 +591,206 @@ func TestFetchLogsInBatches(t *testing.T) { // 5. Create a fork from the parent block and add a different transaction. // 6. Finalize the fork blocks. // 7. Verify we receive logs only after finalization. - func TestChainReorganizationLogs(t *testing.T) { - logger := zaptest.NewLogger(t) - env := setupTestEnv(t, 3*time.Second) + t.Run("post-fork: handles reorg correctly with finality", func(t *testing.T) { + logger := zaptest.NewLogger(t) + env := setupTestEnv(t, 3*time.Second) - // 1. Deploy the contract - contract, err := env.deployCallableContract() - require.NoError(t, err) + // Add some blocks to the chain to ensure we run the test on a fork + env.finalize() - // 2. Create a client and set up subscription - err = env.createClient(WithLogger(logger)) - require.NoError(t, err) + // 1. Deploy the contract + contract, err := env.deployCallableContract() + require.NoError(t, err) - logsCh := env.client.StreamLogs(env.ctx, 0) + // 2. Create a client and set up subscription with finality fork enabled + err = env.createClient(WithLogger(logger), WithFinalityForkEpoch(1)) + require.NoError(t, err) - // Save parent block for forking later - parentBlock, err := env.sim.Client().BlockByNumber(env.ctx, nil) - require.NoError(t, err) + currentBlock, err := env.sim.Client().BlockNumber(env.ctx) + require.NoError(t, err) - // Create a map to track transaction hashes and their corresponding blocks - txHashes := make(map[ethcommon.Hash]uint64) + logsCh := env.client.StreamLogs(env.ctx, currentBlock) - // 3. Create a transaction on the original chain - originalTx, err := contract.Transact(env.auth, "Call") - require.NoError(t, err) + // Save parent block for forking later + parentBlock, err := env.sim.Client().BlockByNumber(env.ctx, nil) + require.NoError(t, err) - env.sim.Commit() + // Create a map to track transaction hashes and their corresponding blocks + txHashes := make(map[ethcommon.Hash]uint64) - // Record the original transaction and its block number - latestBlock, err := env.sim.Client().BlockByNumber(env.ctx, nil) - require.NoError(t, err) + // 3. Create a transaction on the original chain + originalTx, err := contract.Transact(env.auth, "Call") + require.NoError(t, err) - originalBlockNum := latestBlock.NumberU64() - txHashes[originalTx.Hash()] = originalBlockNum - t.Logf("original chain block number: %d, tx hash: %s", originalBlockNum, originalTx.Hash().Hex()) + env.sim.Commit() - checkCtx, cancel := context.WithTimeout(env.ctx, 500*time.Millisecond) - defer cancel() + // Record the original transaction and its block number + latestBlock, err := env.sim.Client().BlockByNumber(env.ctx, nil) + require.NoError(t, err) - // 4. No logs should be received since the block isn't finalized - select { - case log := <-logsCh: - require.Fail(t, "received logs from unfinalized fork", "log", log) - case <-checkCtx.Done(): - // no logs - } + originalBlockNum := latestBlock.NumberU64() + txHashes[originalTx.Hash()] = originalBlockNum + t.Logf("original chain block number: %d, tx hash: %s", originalBlockNum, originalTx.Hash().Hex()) - // 5. Create a fork from the parent block - require.NoError(t, env.sim.Fork(parentBlock.Hash())) + checkCtx, cancel := context.WithTimeout(env.ctx, 500*time.Millisecond) + defer cancel() - // Create a different transaction on the fork - forkTx, err := contract.Transact(env.auth, "Call") - require.NoError(t, err) + // 4. No logs should be received since the block isn't finalized + select { + case log := <-logsCh: + require.Fail(t, "received logs from unfinalized fork", "log", log) + case <-checkCtx.Done(): + // no logs + } - env.sim.Commit() + // 5. Create a fork from the parent block + require.NoError(t, env.sim.Fork(parentBlock.Hash())) - // Record the fork transaction and its block number - latestBlock, err = env.sim.Client().BlockByNumber(env.ctx, nil) - require.NoError(t, err) + // Create a different transaction on the fork + forkTx, err := contract.Transact(env.auth, "Call") + require.NoError(t, err) - forkBlockNum := latestBlock.NumberU64() - txHashes[forkTx.Hash()] = forkBlockNum - t.Logf("fork chain block number: %d, tx hash: %s", forkBlockNum, forkTx.Hash().Hex()) + env.sim.Commit() - checkCtx2, cancel2 := context.WithTimeout(env.ctx, 500*time.Millisecond) - defer cancel2() + // Record the fork transaction and its block number + latestBlock, err = env.sim.Client().BlockByNumber(env.ctx, nil) + require.NoError(t, err) - // Still no logs should be received since the fork isn't finalized - select { - case log := <-logsCh: - require.Fail(t, "received logs from unfinalized fork", "log", log) - case <-checkCtx2.Done(): - // no logs - } + forkBlockNum := latestBlock.NumberU64() + txHashes[forkTx.Hash()] = forkBlockNum + t.Logf("fork chain block number: %d, tx hash: %s", forkBlockNum, forkTx.Hash().Hex()) - // 6. Finalize the fork - env.finalize() + checkCtx2, cancel2 := context.WithTimeout(env.ctx, 500*time.Millisecond) + defer cancel2() - // 7. Verify we receive logs only after finalization - var receivedLog BlockLogs - select { - case receivedLog = <-logsCh: - // received logs - case <-time.After(2 * time.Second): - require.Fail(t, "did not receive logs after finalization") - } + // Still no logs should be received since the fork isn't finalized + select { + case log := <-logsCh: + require.Fail(t, "received logs from unfinalized fork", "log", log) + case <-checkCtx2.Done(): + // no logs + } + + // 6. Finalize the fork + env.finalize() + + // 7. Verify we receive logs only after finalization + var receivedLog BlockLogs + select { + case receivedLog = <-logsCh: + // received logs + case <-time.After(2 * time.Second): + require.Fail(t, "did not receive logs after finalization") + } + + require.NotEmpty(t, receivedLog.Logs) + + // Verify we received the transaction hash that's in our map and log is from the expected block + txHash := receivedLog.Logs[0].TxHash + blockNum, found := txHashes[txHash] + + require.True(t, found, txHash.Hex()) + require.Equal(t, blockNum, receivedLog.BlockNumber) + }) + + t.Run("pre-fork: handles reorg correctly with follow distance", func(t *testing.T) { + logger := zaptest.NewLogger(t) + env := setupTestEnv(t, 3*time.Second) + + // 1. Deploy the contract + contract, err := env.deployCallableContract() + require.NoError(t, err) + + // 2. Create a client with follow distance mechanism (finality fork disabled) + const followDistance = 5 + err = env.createClient( + WithLogger(logger), + WithFollowDistance(followDistance), + WithFinalityForkEpoch(0), // Explicitly disable finality fork + ) + require.NoError(t, err) + + // Mine one block to increase block number + env.sim.Commit() + + logsCh := env.client.StreamLogs(env.ctx, 0) + + // Save parent block for forking later + parentBlock, err := env.sim.Client().BlockByNumber(env.ctx, nil) + require.NoError(t, err) + + // Create a map to track transaction hashes and their corresponding blocks + txHashes := make(map[ethcommon.Hash]uint64) + + // 3. Create a transaction on the original chain + originalTx, err := contract.Transact(env.auth, "Call") + require.NoError(t, err) + + env.sim.Commit() + + // Record the original transaction and its block number + latestBlock, err := env.sim.Client().BlockByNumber(env.ctx, nil) + require.NoError(t, err) + + originalBlockNum := latestBlock.NumberU64() + txHashes[originalTx.Hash()] = originalBlockNum + t.Logf("original chain block number: %d, tx hash: %s", originalBlockNum, originalTx.Hash().Hex()) + + // With follow distance, no logs should be received since we're within follow distance + select { + case log := <-logsCh: + require.Fail(t, "received logs from block within follow distance", "log", log) + case <-time.After(500 * time.Millisecond): + // no logs - this is expected + } + + // 4. Create a fork from the parent block + require.NoError(t, env.sim.Fork(parentBlock.Hash())) + + // Create a different transaction on the fork + forkTx, err := contract.Transact(env.auth, "Call") + require.NoError(t, err) + + env.sim.Commit() + + // Record the fork transaction and its block number + latestBlock, err = env.sim.Client().BlockByNumber(env.ctx, nil) + require.NoError(t, err) - require.NotEmpty(t, receivedLog.Logs) + forkBlockNum := latestBlock.NumberU64() + txHashes[forkTx.Hash()] = forkBlockNum + t.Logf("fork chain block number: %d, tx hash: %s", forkBlockNum, forkTx.Hash().Hex()) - // Verify we received the transaction hash that's in our map and log is from the expected block - txHash := receivedLog.Logs[0].TxHash - blockNum, found := txHashes[txHash] + // 5. Mine enough blocks to pass the follow distance + for i := 0; i < followDistance; i++ { + env.sim.Commit() + } + + // Verify transaction was successful, even if no logs were produced + receipt, err := env.sim.Client().TransactionReceipt(env.ctx, forkTx.Hash()) + require.NoError(t, err) + require.Equal(t, uint64(1), receipt.Status, "Transaction should be successful") - require.True(t, found, txHash.Hex()) - require.Equal(t, blockNum, receivedLog.BlockNumber) + // 6. Check if we receive logs after passing follow distance (may not in all environments) + logsReceived := false + var receivedLog BlockLogs + select { + case receivedLog = <-logsCh: + logsReceived = true + case <-time.After(500 * time.Millisecond): + t.Log("No logs received after passing follow distance (this is acceptable in some test environments)") + } + + // If logs were received, verify they match expectations + if logsReceived && len(receivedLog.Logs) > 0 { + // Verify we received the transaction hash that's in our map and log is from the expected block + txHash := receivedLog.Logs[0].TxHash + blockNum, found := txHashes[txHash] + require.True(t, found, txHash.Hex()) + require.Equal(t, blockNum, receivedLog.BlockNumber) + } + }) } // deploySimContract deploys the SSV simulator contract. @@ -565,209 +826,155 @@ func (env *testEnv) deploySimContract() (*simcontract.Simcontract, error) { // TestSimSSV deploys the simplified SSVNetwork contract to generate events and receive them // only after their blocks have been finalized (i.e. after an extra empty block is mined). func TestSimSSV(t *testing.T) { - logger, err := zap.NewDevelopment() - require.NoError(t, err) + t.Run("post-fork: receives contract events after block finalization", func(t *testing.T) { + logger, err := zap.NewDevelopment() + require.NoError(t, err) - env := setupTestEnv(t, 3*time.Second) + env := setupTestEnv(t, 3*time.Second) - // Deploy the SSV contract - boundContract, err := env.deploySimContract() - require.NoError(t, err) + // Deploy the SSV contract + boundContract, err := env.deploySimContract() + require.NoError(t, err) - // Create a client and connect to the simulator - err = env.createClient(WithLogger(logger)) - require.NoError(t, err) + // Create a client and connect to the simulator with finality fork enabled + err = env.createClient(WithLogger(logger), WithFinalityForkEpoch(1)) + require.NoError(t, err) - logs := env.client.StreamLogs(env.ctx, 0) + logs := env.client.StreamLogs(env.ctx, 0) - // helper to read next finalized block - nextBlk := func() BlockLogs { - for { - blk := <-logs - if len(blk.Logs) > 0 { - return blk + // helper to read next finalized block + nextBlk := func() BlockLogs { + for { + blk := <-logs + if len(blk.Logs) > 0 { + return blk + } } } - } - - // Emit event OperatorAdded - tx, err := boundContract.RegisterOperator( - env.auth, - ethcommon.Hex2Bytes("0xb24454393691331ee6eba4ffa2dbb2600b9859f908c3e648b6c6de9e1dea3e9329866015d08355c8d451427762b913d1"), - big.NewInt(100_000_000), - ) - require.NoError(t, err) - env.finalize() // mine && finalize - - receipt, err := env.sim.Client().TransactionReceipt(env.ctx, tx.Hash()) - require.NoError(t, err) - require.Equal(t, uint64(0x1), receipt.Status) - - blk := nextBlk() - require.NotEmpty(t, blk.Logs) - require.Equal( - t, - ethcommon.HexToHash("0xd839f31c14bd632f424e307b36abff63ca33684f77f28e35dc13718ef338f7f4"), - blk.Logs[0].Topics[0], - ) + // Emit event OperatorAdded + tx, err := boundContract.RegisterOperator( + env.auth, + ethcommon.Hex2Bytes("0xb24454393691331ee6eba4ffa2dbb2600b9859f908c3e648b6c6de9e1dea3e9329866015d08355c8d451427762b913d1"), + big.NewInt(100_000_000), + ) + require.NoError(t, err) - // Emit event OperatorRemoved - tx, err = boundContract.RemoveOperator(env.auth, 1) - require.NoError(t, err) + env.finalize() // mine && finalize - env.finalize() + receipt, err := env.sim.Client().TransactionReceipt(env.ctx, tx.Hash()) + require.NoError(t, err) + require.Equal(t, uint64(0x1), receipt.Status) + + blk := nextBlk() + require.NotEmpty(t, blk.Logs) + require.Equal( + t, + ethcommon.HexToHash("0xd839f31c14bd632f424e307b36abff63ca33684f77f28e35dc13718ef338f7f4"), + blk.Logs[0].Topics[0], + ) - receipt, err = env.sim.Client().TransactionReceipt(env.ctx, tx.Hash()) - require.NoError(t, err) - require.Equal(t, uint64(0x1), receipt.Status) - - blk = nextBlk() - require.NotEmpty(t, blk.Logs) - require.Equal( - t, - ethcommon.HexToHash("0x0e0ba6c2b04de36d6d509ec5bd155c43a9fe862f8052096dd54f3902a74cca3e"), - blk.Logs[0].Topics[0], - ) + // Emit event OperatorRemoved + tx, err = boundContract.RemoveOperator(env.auth, 1) + require.NoError(t, err) - // Emit event ValidatorAdded - tx, err = boundContract.RegisterValidator( - env.auth, - ethcommon.Hex2Bytes("0x1"), - []uint64{1, 2, 3}, - ethcommon.Hex2Bytes("0x2"), - big.NewInt(100_000_000), - simcontract.CallableCluster{ - ValidatorCount: 3, - NetworkFeeIndex: 1, - Index: 1, - Active: true, - Balance: big.NewInt(100_000_000), - }, - ) - require.NoError(t, err) + env.finalize() - env.finalize() + receipt, err = env.sim.Client().TransactionReceipt(env.ctx, tx.Hash()) + require.NoError(t, err) + require.Equal(t, uint64(0x1), receipt.Status) + + blk = nextBlk() + require.NotEmpty(t, blk.Logs) + require.Equal( + t, + ethcommon.HexToHash("0x0e0ba6c2b04de36d6d509ec5bd155c43a9fe862f8052096dd54f3902a74cca3e"), + blk.Logs[0].Topics[0], + ) + }) - receipt, err = env.sim.Client().TransactionReceipt(env.ctx, tx.Hash()) - require.NoError(t, err) - require.Equal(t, uint64(0x1), receipt.Status) - - blk = nextBlk() - require.NotEmpty(t, blk.Logs) - require.Equal( - t, - ethcommon.HexToHash("0x48a3ea0796746043948f6341d17ff8200937b99262a0b48c2663b951ed7114e5"), - blk.Logs[0].Topics[0], - ) + t.Run("pre-fork: receives contract events after follow distance", func(t *testing.T) { + logger, err := zap.NewDevelopment() + require.NoError(t, err) - // Emit event ValidatorRemoved - tx, err = boundContract.RemoveValidator( - env.auth, - ethcommon.Hex2Bytes("0x1"), - []uint64{1, 2, 3}, - simcontract.CallableCluster{ - ValidatorCount: 3, - NetworkFeeIndex: 1, - Index: 1, - Active: true, - Balance: big.NewInt(100_000_000), - }, - ) - require.NoError(t, err) + env := setupTestEnv(t, 3*time.Second) - env.finalize() + // Deploy the SSV contract + boundContract, err := env.deploySimContract() + require.NoError(t, err) - receipt, err = env.sim.Client().TransactionReceipt(env.ctx, tx.Hash()) - require.NoError(t, err) - require.Equal(t, uint64(0x1), receipt.Status) - - blk = nextBlk() - require.NotEmpty(t, blk.Logs) - require.Equal( - t, - ethcommon.HexToHash("0xccf4370403e5fbbde0cd3f13426479dcd8a5916b05db424b7a2c04978cf8ce6e"), - blk.Logs[0].Topics[0], - ) + // Create a client and connect to the simulator with follow distance + const followDistance = 2 + err = env.createClient( + WithLogger(logger), + WithFollowDistance(followDistance), + WithFinalityForkEpoch(0), // Explicitly disable finality fork + ) + require.NoError(t, err) - // Emit event ClusterLiquidated - tx, err = boundContract.Liquidate( - env.auth, - ethcommon.HexToAddress("0x1"), - []uint64{1, 2, 3}, - simcontract.CallableCluster{ - ValidatorCount: 3, - NetworkFeeIndex: 1, - Index: 1, - Active: true, - Balance: big.NewInt(100_000_000), - }, - ) - require.NoError(t, err) + logs := env.client.StreamLogs(env.ctx, 0) - env.finalize() + // Helper to advance blocks past follow distance + advanceBlocks := func(count int) { + for i := 0; i < count; i++ { + env.sim.Commit() + } + } - receipt, err = env.sim.Client().TransactionReceipt(env.ctx, tx.Hash()) - require.NoError(t, err) - require.Equal(t, uint64(0x1), receipt.Status) - - blk = nextBlk() - require.NotEmpty(t, blk.Logs) - require.Equal( - t, - ethcommon.HexToHash("0x1fce24c373e07f89214e9187598635036111dbb363e99f4ce498488cdc66e688"), - blk.Logs[0].Topics[0], - ) + // Helper to read next block with logs + nextBlk := func() BlockLogs { + for { + blk := <-logs + if len(blk.Logs) > 0 { + return blk + } + } + } - // Emit event ClusterReactivated - tx, err = boundContract.Reactivate( - env.auth, - []uint64{1, 2, 3}, - big.NewInt(100_000_000), - simcontract.CallableCluster{ - ValidatorCount: 3, - NetworkFeeIndex: 1, - Index: 1, - Active: true, - Balance: big.NewInt(100_000_000), - }, - ) - require.NoError(t, err) + // Emit event OperatorAdded + tx, err := boundContract.RegisterOperator( + env.auth, + ethcommon.Hex2Bytes("0xb24454393691331ee6eba4ffa2dbb2600b9859f908c3e648b6c6de9e1dea3e9329866015d08355c8d451427762b913d1"), + big.NewInt(100_000_000), + ) + require.NoError(t, err) + env.sim.Commit() - env.finalize() + // Mine enough blocks to pass the follow distance + advanceBlocks(followDistance + 1) - receipt, err = env.sim.Client().TransactionReceipt(env.ctx, tx.Hash()) - require.NoError(t, err) - require.Equal(t, uint64(0x1), receipt.Status) - - blk = nextBlk() - require.NotEmpty(t, blk.Logs) - require.Equal( - t, - ethcommon.HexToHash("0xc803f8c01343fcdaf32068f4c283951623ef2b3fa0c547551931356f456b6859"), - blk.Logs[0].Topics[0], - ) + receipt, err := env.sim.Client().TransactionReceipt(env.ctx, tx.Hash()) + require.NoError(t, err) + require.Equal(t, uint64(0x1), receipt.Status) + + blk := nextBlk() + require.NotEmpty(t, blk.Logs) + require.Equal( + t, + ethcommon.HexToHash("0xd839f31c14bd632f424e307b36abff63ca33684f77f28e35dc13718ef338f7f4"), + blk.Logs[0].Topics[0], + ) - // Emit event FeeRecipientAddressUpdated - tx, err = boundContract.SetFeeRecipientAddress( - env.auth, - ethcommon.HexToAddress("0x1"), - ) - require.NoError(t, err) + // Emit event OperatorRemoved + tx, err = boundContract.RemoveOperator(env.auth, 1) + require.NoError(t, err) + env.sim.Commit() - env.finalize() + // Mine enough blocks to pass the follow distance + advanceBlocks(followDistance + 1) - receipt, err = env.sim.Client().TransactionReceipt(env.ctx, tx.Hash()) - require.NoError(t, err) - require.Equal(t, uint64(0x1), receipt.Status) - - blk = nextBlk() - require.NotEmpty(t, blk.Logs) - require.Equal( - t, - ethcommon.HexToHash("0x259235c230d57def1521657e7c7951d3b385e76193378bc87ef6b56bc2ec3548"), - blk.Logs[0].Topics[0], - ) + receipt, err = env.sim.Client().TransactionReceipt(env.ctx, tx.Hash()) + require.NoError(t, err) + require.Equal(t, uint64(0x1), receipt.Status) + + blk = nextBlk() + require.NotEmpty(t, blk.Logs) + require.Equal( + t, + ethcommon.HexToHash("0x0e0ba6c2b04de36d6d509ec5bd155c43a9fe862f8052096dd54f3902a74cca3e"), + blk.Logs[0].Topics[0], + ) + }) } // TestFilterLogs tests the FilterLogs method of the client. From e26a0f214931ddf1feaa2b22c95ea40e629a8c3c Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Wed, 7 May 2025 13:35:24 +0700 Subject: [PATCH 21/53] small cleanup [skip ci] --- eth/executionclient/execution_client.go | 61 ++++++++++++------------- 1 file changed, 29 insertions(+), 32 deletions(-) diff --git a/eth/executionclient/execution_client.go b/eth/executionclient/execution_client.go index 2ecaf1b7e4..a71ba08d48 100644 --- a/eth/executionclient/execution_client.go +++ b/eth/executionclient/execution_client.go @@ -124,7 +124,6 @@ func (ec *ExecutionClient) Close() error { // FetchHistoricalLogs retrieves historical logs emitted by the contract starting from fromBlock. func (ec *ExecutionClient) FetchHistoricalLogs(ctx context.Context, fromBlock uint64) (logs <-chan BlockLogs, errors <-chan error, err error) { - // Get current block to determine which finality method to use currentBlock, err := ec.client.BlockNumber(ctx) if err != nil { ec.logger.Error(elResponseErrMsg, @@ -133,14 +132,19 @@ func (ec *ExecutionClient) FetchHistoricalLogs(ctx context.Context, fromBlock ui return nil, nil, fmt.Errorf("failed to get current block: %w", err) } - // Calculate current epoch currentEpoch := currentBlock / SlotsPerEpoch var toBlock uint64 - // Choose between finality and follow distance based on fork status - if IsFinalityActive(currentEpoch, ec.finalityForkEpoch) { - // Post-fork: Use finalized block from execution client + if !IsFinalityActive(currentEpoch, ec.finalityForkEpoch) { + // Pre-fork: follow distance approach + if currentBlock < ec.followDistance { + return nil, nil, ErrNothingToSync + } + + toBlock = currentBlock - ec.followDistance + } else { + // Post-fork: finalized block approach finalizedBlock, err := ec.client.HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) if err != nil { ec.logger.Error(elResponseErrMsg, @@ -150,23 +154,14 @@ func (ec *ExecutionClient) FetchHistoricalLogs(ctx context.Context, fromBlock ui return nil, nil, fmt.Errorf("get finalized block: %w", err) } toBlock = finalizedBlock.Number.Uint64() - } else { - // Pre-fork: Use follow distance like the original implementation - if currentBlock < ec.followDistance { - return nil, nil, ErrNothingToSync - } - toBlock = currentBlock - ec.followDistance } - // Wait until the finalized block number (toBlock) catches up to the block we want to start syncing from (fromBlock). - // For example, if we last processed block 123456, fromBlock = 123457. - // If Ethereum finality is only at 123454, we must wait until it reaches 123457 to continue. - // This prevents fetching logs from unfinalized (and potentially reorged) blocks. if toBlock < fromBlock { return nil, nil, ErrNothingToSync } logs, errors = ec.fetchLogsInBatches(ctx, fromBlock, toBlock) + return } @@ -380,7 +375,8 @@ func (ec *ExecutionClient) SubscribeFilterLogs(ctx context.Context, q ethereum.F logs, err := ec.client.SubscribeFilterLogs(ctx, q, ch) if err != nil { ec.logger.Error(elResponseErrMsg, - zap.String("method", "eth_subscribe(logs)"), + zap.String("method", "eth_subscribe"), + zap.String("tag", "logs"), zap.Error(err)) return nil, err } @@ -424,7 +420,8 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo sub, err := ec.client.SubscribeNewHead(ctx, headersCh) if err != nil { ec.logger.Error(elResponseErrMsg, - zap.String("method", "eth_subscribe(newHeads)"), + zap.String("method", "eth_subscribe"), + zap.String("tag", "newHeads"), zap.Error(err)) return fromBlock, fmt.Errorf("subscribe heads: %w", err) } @@ -456,7 +453,20 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo currentEpoch := header.Number.Uint64() / SlotsPerEpoch var toBlock uint64 - if IsFinalityActive(currentEpoch, ec.finalityForkEpoch) { + if !IsFinalityActive(currentEpoch, ec.finalityForkEpoch) { + // Pre-fork: follow distance approach + if header.Number.Uint64() < ec.followDistance { + continue + } + toBlock = header.Number.Uint64() - ec.followDistance + + ec.logger.Debug("processing blocks using safety distance", + zap.Uint64("epoch", currentEpoch), + zap.Uint64("finality_fork_epoch", ec.finalityForkEpoch), + zap.Uint64("head", header.Number.Uint64()), + zap.Uint64("follow_distance", ec.followDistance), + zap.Uint64("target_block", toBlock)) + } else { // Post-fork: finalized block approach finalizedBlock, err := ec.client.HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) if err != nil { @@ -467,7 +477,7 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo } toBlock = finalizedBlock.Number.Uint64() - ec.logger.Debug("using finalized block approach", + ec.logger.Debug("processing blocks using finality", zap.Uint64("epoch", currentEpoch), zap.Uint64("finality_fork_epoch", ec.finalityForkEpoch), zap.Uint64("finalized_block", toBlock)) @@ -480,19 +490,6 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo zap.Uint64("previous_finalized", lastFinalized)) lastFinalized = toBlock } - } else { - // Pre-fork: follow distance approach - if header.Number.Uint64() < ec.followDistance { - continue - } - toBlock = header.Number.Uint64() - ec.followDistance - - ec.logger.Debug("using follow distance approach", - zap.Uint64("epoch", currentEpoch), - zap.Uint64("finality_fork_epoch", ec.finalityForkEpoch), - zap.Uint64("head", header.Number.Uint64()), - zap.Uint64("follow_distance", ec.followDistance), - zap.Uint64("target_block", toBlock)) } // Wait until the target block number catches up to where we need to start processing From 41846bc53ea6b6572ea872060b31cf3735c950da Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Wed, 7 May 2025 16:24:34 +0700 Subject: [PATCH 22/53] fork support --- eth/ethtest/common_test.go | 66 +++++++++++++++++++++++++--------- eth/ethtest/eth_e2e_test.go | 51 ++++++++++++++++++++------ eth/executionclient/config.go | 1 + eth/executionclient/options.go | 6 ++-- 4 files changed, 95 insertions(+), 29 deletions(-) diff --git a/eth/ethtest/common_test.go b/eth/ethtest/common_test.go index 27c20098bb..0ac16ddfcf 100644 --- a/eth/ethtest/common_test.go +++ b/eth/ethtest/common_test.go @@ -51,20 +51,22 @@ func NewCommonTestInput( } type TestEnv struct { - eventSyncer *eventsyncer.EventSyncer - validators []*testValidatorData - ops []*testOperator - nodeStorage storage.Storage - sim *simulator.Backend - boundContract *simcontract.Simcontract - auth *bind.TransactOpts - shares [][]byte - execClient *executionclient.ExecutionClient - rpcServer *rpc.Server - httpSrv *httptest.Server - validatorCtrl *mocks.MockController - mockCtrl *gomock.Controller - finalityBlocks uint64 + eventSyncer *eventsyncer.EventSyncer + validators []*testValidatorData + ops []*testOperator + nodeStorage storage.Storage + sim *simulator.Backend + boundContract *simcontract.Simcontract + auth *bind.TransactOpts + shares [][]byte + execClient *executionclient.ExecutionClient + rpcServer *rpc.Server + httpSrv *httptest.Server + validatorCtrl *mocks.MockController + mockCtrl *gomock.Controller + finalityBlocks uint64 + followDistance uint64 + finalityForkEpoch uint64 } func (e *TestEnv) shutdown() { @@ -89,9 +91,13 @@ func (e *TestEnv) setup( validatorsCount uint64, operatorsCount uint64, ) error { + // Initialize defaults if not set if e.finalityBlocks == 0 { e.SetDefaultFinalityBlocks() } + if e.followDistance == 0 { + e.SetDefaultFollowDistance() + } logger := zaptest.NewLogger(t) // Create operators RSA keys @@ -168,11 +174,21 @@ func (e *TestEnv) setup( } // Create a client and connect to the simulator + execClientOpts := []executionclient.Option{ + executionclient.WithLogger(logger), + executionclient.WithFollowDistance(e.followDistance), + } + + // Apply finality fork settings if configured + if e.finalityForkEpoch > 0 { + execClientOpts = append(execClientOpts, executionclient.WithFinalityForkEpoch(e.finalityForkEpoch)) + } + e.execClient, err = executionclient.New( ctx, addr, contractAddr, - executionclient.WithLogger(logger), + execClientOpts..., ) if err != nil { return err @@ -205,18 +221,34 @@ func (e *TestEnv) setup( return nil } +// SetDefaultFinalityBlocks sets the default finality blocks. func (e *TestEnv) SetDefaultFinalityBlocks() { e.finalityBlocks = executionclient.DefaultFinalityDistance } -// MineAndFinalize mines enough blocks to ensure finality +// SetDefaultFollowDistance sets the default follow distance. +func (e *TestEnv) SetDefaultFollowDistance() { + e.followDistance = executionclient.DefaultFollowDistance +} + +// EnableFinalityFork enables the finality fork at the specified epoch. +func (e *TestEnv) EnableFinalityFork(epoch uint64) { + e.finalityForkEpoch = epoch +} + +// DisableFinalityFork disables the finality fork. +func (e *TestEnv) DisableFinalityFork() { + e.finalityForkEpoch = 0 +} + +// MineAndFinalize mines enough blocks to ensure finality. func (e *TestEnv) MineAndFinalize(blockNum *uint64) { for i := uint64(0); i < e.finalityBlocks; i++ { commitBlock(e.sim, blockNum) } } -// commitBlock creates a new block and increments block counter +// commitBlock creates a new block and increments block counter. func commitBlock(sim *simulator.Backend, blockNum *uint64) { sim.Commit() *blockNum++ diff --git a/eth/ethtest/eth_e2e_test.go b/eth/ethtest/eth_e2e_test.go index 36c0c2539a..2cd2a531c2 100644 --- a/eth/ethtest/eth_e2e_test.go +++ b/eth/ethtest/eth_e2e_test.go @@ -25,8 +25,20 @@ var ( testAddrBob = crypto.PubkeyToAddress(testKeyBob.PublicKey) ) -// E2E tests for ETH package -func TestEthExecLayer(t *testing.T) { +// TestEthExecLayer_PreFork tests ETH package with follow distance approach (pre-fork). +// TODO: use the correct name when we know the name of the fork. +func TestEthExecLayer_PreFork(t *testing.T) { + runTestEthExecLayer(t, false) +} + +// TestEthExecLayer_PostFork tests ETH package with finality approach (post-fork) +// TODO: use the correct name when we know the name of the fork. +func TestEthExecLayer_PostFork(t *testing.T) { + runTestEthExecLayer(t, true) +} + +// E2E tests for ETH package with configurable finality approach +func runTestEthExecLayer(t *testing.T, useFinalityFork bool) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -46,6 +58,17 @@ func TestEthExecLayer(t *testing.T) { testEnv := TestEnv{} testEnv.SetDefaultFinalityBlocks() + testEnv.SetDefaultFollowDistance() + + if useFinalityFork { + // Enable finality fork at epoch 1 + testEnv.EnableFinalityFork(1) + t.Log("Running test with finality (post-fork)") // TODO: use the correct name when we know the name of the fork. + } else { + // Disable finality fork to use follow distance approach + testEnv.DisableFinalityFork() + t.Log("Running test with follow distance (pre-fork)") // TODO: use the correct name when we know the name of the fork. + } defer testEnv.shutdown() err := testEnv.setup(t, ctx, testAddresses, 7, 4) @@ -98,19 +121,27 @@ func TestEthExecLayer(t *testing.T) { valAddInput.produce() testEnv.MineAndFinalize(&blockNum) - // Check the finalized block number (EventSyncer uses it to determine the range of blocks to process) - finalizedBlock, err := testEnv.sim.Client().HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) - require.NoError(t, err) - - finalizedBlockNum := finalizedBlock.Number.Uint64() + // Check how the EventSyncer determines which blocks to process + var expectedLastHandledBlock uint64 + + if useFinalityFork { + // When using finality fork, check the finalized block number + finalizedBlock, err := testEnv.sim.Client().HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) + require.NoError(t, err) + expectedLastHandledBlock = finalizedBlock.Number.Uint64() + } else { + // When using follow distance, the last handled block is the current block minus follow distance + currentBlock, err := testEnv.sim.Client().BlockNumber(ctx) + require.NoError(t, err) + expectedLastHandledBlock = currentBlock - testEnv.followDistance + } // Run SyncHistory lastHandledBlockNum, err = eventSyncer.SyncHistory(ctx, lastHandledBlockNum) require.NoError(t, err) - // EventSyncer.SyncHistory processes blocks only up to the finalized block number - // Check that the last handled block number is equal to the finalized block number - require.Equal(t, finalizedBlockNum, lastHandledBlockNum) + // Check that the last handled block number matches our expectation + require.Equal(t, expectedLastHandledBlock, lastHandledBlockNum) // Check that operators were successfully registered operators, err := nodeStorage.ListOperators(nil, 0, 10) diff --git a/eth/executionclient/config.go b/eth/executionclient/config.go index 0dbad59e0d..832bb051d8 100644 --- a/eth/executionclient/config.go +++ b/eth/executionclient/config.go @@ -14,6 +14,7 @@ type Options struct { } // IsFinalityActive returns true if the finality fork is active at the given epoch. +// TODO: use the correct name when we know the name of the fork. func IsFinalityActive(epoch uint64, finalityForkEpoch uint64) bool { return finalityForkEpoch > 0 && epoch >= finalityForkEpoch } diff --git a/eth/executionclient/options.go b/eth/executionclient/options.go index d714239a54..b756a82386 100644 --- a/eth/executionclient/options.go +++ b/eth/executionclient/options.go @@ -98,14 +98,16 @@ func WithFollowDistanceMulti(offset uint64) OptionMulti { } } -// WithFinalityForkEpoch sets the epoch at which to switch from follow distance to finality signals. +// WithFinalityForkEpoch sets the epoch at which to switch from follow distance to finality. +// TODO: use the correct name when we know the name of the fork. func WithFinalityForkEpoch(epoch uint64) Option { return func(s *ExecutionClient) { s.finalityForkEpoch = epoch } } -// WithFinalityForkEpochMulti sets the epoch at which to switch from follow distance to finality signals. +// WithFinalityForkEpochMulti sets the epoch at which to switch from follow distance to finality. +// TODO: use the correct name when we know the name of the fork. func WithFinalityForkEpochMulti(epoch uint64) OptionMulti { return func(s *MultiClient) { s.finalityForkEpoch = epoch From 4440124506068dcd71107c0248814f263d4fbd00 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Wed, 7 May 2025 16:40:26 +0700 Subject: [PATCH 23/53] feat(multi_client_test.go): add support for follow distance and finality fork epoch in NewMulti function feat(options.go): add WithFollowDistance and WithFinalityForkEpoch options to set follow distance and finality fork epoch respectively --- eth/executionclient/multi_client_test.go | 65 +++++++++++++++++------- eth/executionclient/options.go | 56 ++++++++++---------- 2 files changed, 76 insertions(+), 45 deletions(-) diff --git a/eth/executionclient/multi_client_test.go b/eth/executionclient/multi_client_test.go index 789877b1ee..58514d36c5 100644 --- a/eth/executionclient/multi_client_test.go +++ b/eth/executionclient/multi_client_test.go @@ -59,28 +59,59 @@ func TestNewMulti_WithOptions(t *testing.T) { contractAddr := ethcommon.HexToAddress("0x1234") customLogger := zap.NewExample() + const customFollowDistance = uint64(10) const customTimeout = 100 * time.Millisecond const customHealthInvalidationInterval = 50 * time.Millisecond const customLogBatchSize = 11 const customSyncDistanceTolerance = 12 - mc, err := NewMulti( - ctx, - addresses, - contractAddr, - WithLoggerMulti(customLogger), - WithConnectionTimeoutMulti(customTimeout), - WithHealthInvalidationIntervalMulti(customHealthInvalidationInterval), - WithLogBatchSizeMulti(customLogBatchSize), - WithSyncDistanceToleranceMulti(customSyncDistanceTolerance), - ) - require.NoError(t, err) - require.NotNil(t, mc) - require.Equal(t, customLogger.Named("execution_client_multi"), mc.logger) - require.EqualValues(t, customTimeout, mc.connectionTimeout) - require.EqualValues(t, customHealthInvalidationInterval, mc.healthInvalidationInterval) - require.EqualValues(t, customLogBatchSize, mc.logBatchSize) - require.EqualValues(t, customSyncDistanceTolerance, mc.syncDistanceTolerance) + t.Run("pre-fork (follow distance)", func(t *testing.T) { + mc, err := NewMulti( + ctx, + addresses, + contractAddr, + WithLoggerMulti(customLogger), + WithFollowDistanceMulti(customFollowDistance), + WithConnectionTimeoutMulti(customTimeout), + WithHealthInvalidationIntervalMulti(customHealthInvalidationInterval), + WithLogBatchSizeMulti(customLogBatchSize), + WithSyncDistanceToleranceMulti(customSyncDistanceTolerance), + ) + require.NoError(t, err) + require.NotNil(t, mc) + require.Equal(t, customLogger.Named("execution_client_multi"), mc.logger) + require.EqualValues(t, customFollowDistance, mc.followDistance) + require.EqualValues(t, customTimeout, mc.connectionTimeout) + require.EqualValues(t, customHealthInvalidationInterval, mc.healthInvalidationInterval) + require.EqualValues(t, customLogBatchSize, mc.logBatchSize) + require.EqualValues(t, customSyncDistanceTolerance, mc.syncDistanceTolerance) + require.EqualValues(t, 0, mc.finalityForkEpoch) // Default - not using finality fork + }) + + t.Run("post-fork (finality(", func(t *testing.T) { + const customFinalityForkEpoch = uint64(5) + + mc, err := NewMulti( + ctx, + addresses, + contractAddr, + WithLoggerMulti(customLogger), + WithConnectionTimeoutMulti(customTimeout), + WithHealthInvalidationIntervalMulti(customHealthInvalidationInterval), + WithLogBatchSizeMulti(customLogBatchSize), + WithSyncDistanceToleranceMulti(customSyncDistanceTolerance), + WithFinalityForkEpochMulti(customFinalityForkEpoch), + ) + require.NoError(t, err) + require.NotNil(t, mc) + require.Equal(t, customLogger.Named("execution_client_multi"), mc.logger) + require.EqualValues(t, customTimeout, mc.connectionTimeout) + require.EqualValues(t, customHealthInvalidationInterval, mc.healthInvalidationInterval) + require.EqualValues(t, customLogBatchSize, mc.logBatchSize) + require.EqualValues(t, customSyncDistanceTolerance, mc.syncDistanceTolerance) + require.EqualValues(t, customFinalityForkEpoch, mc.finalityForkEpoch) + require.EqualValues(t, DefaultFollowDistance, mc.followDistance) + }) } func TestMultiClient_assertSameChainIDs(t *testing.T) { diff --git a/eth/executionclient/options.go b/eth/executionclient/options.go index b756a82386..33f16f6b95 100644 --- a/eth/executionclient/options.go +++ b/eth/executionclient/options.go @@ -14,102 +14,102 @@ type OptionMulti func(client *MultiClient) // WithLogger enables logging. func WithLogger(logger *zap.Logger) Option { - return func(s *ExecutionClient) { - s.logger = logger.Named("execution_client") + return func(c *ExecutionClient) { + c.logger = logger.Named("execution_client") } } // WithLoggerMulti enables logging. func WithLoggerMulti(logger *zap.Logger) OptionMulti { - return func(s *MultiClient) { - s.logger = logger.Named("execution_client_multi") + return func(c *MultiClient) { + c.logger = logger.Named("execution_client_multi") } } // WithConnectionTimeout sets timeout for network connection to eth1 node. func WithConnectionTimeout(timeout time.Duration) Option { - return func(s *ExecutionClient) { - s.connectionTimeout = timeout + return func(c *ExecutionClient) { + c.connectionTimeout = timeout } } // WithConnectionTimeoutMulti sets timeout for network connection to eth1 node. func WithConnectionTimeoutMulti(timeout time.Duration) OptionMulti { - return func(s *MultiClient) { - s.connectionTimeout = timeout + return func(c *MultiClient) { + c.connectionTimeout = timeout } } // WithHealthInvalidationInterval sets health invalidation interval. 0 disables caching. func WithHealthInvalidationInterval(interval time.Duration) Option { - return func(s *ExecutionClient) { - s.healthInvalidationInterval = interval + return func(c *ExecutionClient) { + c.healthInvalidationInterval = interval } } // WithHealthInvalidationIntervalMulti sets health invalidation interval. func WithHealthInvalidationIntervalMulti(interval time.Duration) OptionMulti { - return func(s *MultiClient) { - s.healthInvalidationInterval = interval + return func(c *MultiClient) { + c.healthInvalidationInterval = interval } } // WithLogBatchSize sets log batch size. func WithLogBatchSize(size uint64) Option { - return func(s *ExecutionClient) { - s.logBatchSize = size + return func(c *ExecutionClient) { + c.logBatchSize = size } } // WithLogBatchSizeMulti sets log batch size. func WithLogBatchSizeMulti(size uint64) OptionMulti { - return func(s *MultiClient) { - s.logBatchSize = size + return func(c *MultiClient) { + c.logBatchSize = size } } // WithSyncDistanceTolerance sets the number of blocks that is acceptable to lag behind. func WithSyncDistanceTolerance(count uint64) Option { - return func(s *ExecutionClient) { - s.syncDistanceTolerance = count + return func(c *ExecutionClient) { + c.syncDistanceTolerance = count } } // WithSyncDistanceToleranceMulti sets the number of blocks that is acceptable to lag behind. func WithSyncDistanceToleranceMulti(count uint64) OptionMulti { - return func(s *MultiClient) { - s.syncDistanceTolerance = count + return func(c *MultiClient) { + c.syncDistanceTolerance = count } } // WithFollowDistance sets finalization offset (a block at this offset into the past // from the head block will be considered as very likely finalized). func WithFollowDistance(offset uint64) Option { - return func(s *ExecutionClient) { - s.followDistance = offset + return func(c *ExecutionClient) { + c.followDistance = offset } } // WithFollowDistanceMulti sets finalization offset (a block at this offset into the past // from the head block will be considered as very likely finalized). func WithFollowDistanceMulti(offset uint64) OptionMulti { - return func(s *MultiClient) { - s.followDistance = offset + return func(c *MultiClient) { + c.followDistance = offset } } // WithFinalityForkEpoch sets the epoch at which to switch from follow distance to finality. // TODO: use the correct name when we know the name of the fork. func WithFinalityForkEpoch(epoch uint64) Option { - return func(s *ExecutionClient) { - s.finalityForkEpoch = epoch + return func(c *ExecutionClient) { + c.finalityForkEpoch = epoch } } // WithFinalityForkEpochMulti sets the epoch at which to switch from follow distance to finality. // TODO: use the correct name when we know the name of the fork. func WithFinalityForkEpochMulti(epoch uint64) OptionMulti { - return func(s *MultiClient) { - s.finalityForkEpoch = epoch + return func(c *MultiClient) { + c.finalityForkEpoch = epoch } } From dc3a03cbd1d45815a666110ad06b7d464307b65e Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Wed, 7 May 2025 17:27:32 +0700 Subject: [PATCH 24/53] refactor(event_handler_test.go): add executionclient.WithFinalityForkEpoch(1) option to client creation for improved testing accuracy --- eth/eventhandler/event_handler_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eth/eventhandler/event_handler_test.go b/eth/eventhandler/event_handler_test.go index 4e196f6213..98bd2af7d9 100644 --- a/eth/eventhandler/event_handler_test.go +++ b/eth/eventhandler/event_handler_test.go @@ -115,7 +115,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NotEmpty(t, contractCode) // Create a client and connect to the simulator - client, err := executionclient.New(ctx, addr, contractAddr, executionclient.WithLogger(logger)) + client, err := executionclient.New(ctx, addr, contractAddr, executionclient.WithLogger(logger), executionclient.WithFinalityForkEpoch(1)) require.NoError(t, err) contractFilterer, err := client.Filterer() From d2faa93bf3ef1752d67032f3d3edef659cc47ee9 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Wed, 7 May 2025 19:12:00 +0700 Subject: [PATCH 25/53] refactor(execution_client.go): improve comments and add clarity to the healthy method for better understanding and maintainability --- eth/executionclient/execution_client.go | 49 ++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/eth/executionclient/execution_client.go b/eth/executionclient/execution_client.go index a71ba08d48..e1acf054d1 100644 --- a/eth/executionclient/execution_client.go +++ b/eth/executionclient/execution_client.go @@ -313,11 +313,30 @@ func (ec *ExecutionClient) Healthy(ctx context.Context) error { return ec.healthy(ctx) } +// healthy checks if the execution client is currently in a healthy state. +// It performs different checks based on whether the finality fork is active: +// +// Pre-fork (follow distance approach): +// - Verifies the client responds to requests +// - Checks if the sync distance is within the acceptable tolerance +// +// Post-fork (finality approach): +// - Verifies the client responds to requests +// - Checks if the sync distance is within the acceptable tolerance +// - Checks if finalized blocks are available +// +// The method returns nil if the client is healthy, or an error explaining why it's not. +// Error types include: +// - errSyncing: when the client is still synchronizing blocks +// - network errors: when the client doesn't respond +// TODO: update for related stuff (names, etc) func (ec *ExecutionClient) healthy(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, ec.connectionTimeout) defer cancel() start := time.Now() + + // 1. Check if client is reachable sp, err := ec.SyncProgress(ctx) if err != nil { recordExecutionClientStatus(ctx, statusFailure, ec.nodeAddr) @@ -328,11 +347,12 @@ func (ec *ExecutionClient) healthy(ctx context.Context) error { } recordRequestDuration(ctx, ec.nodeAddr, time.Since(start)) + // 2. Check sync distance if sp != nil { syncDistance := max(sp.HighestBlock, sp.CurrentBlock) - sp.CurrentBlock - observability.RecordUint64Value(ctx, syncDistance, syncDistanceGauge.Record, metric.WithAttributes(semconv.ServerAddress(ec.nodeAddr))) + observability.RecordUint64Value(ctx, syncDistance, syncDistanceGauge.Record, + metric.WithAttributes(semconv.ServerAddress(ec.nodeAddr))) - // block out of sync distance tolerance if syncDistance > ec.syncDistanceTolerance { recordExecutionClientStatus(ctx, statusSyncing, ec.nodeAddr) return fmt.Errorf("sync distance exceeds tolerance (%d): %w", syncDistance, errSyncing) @@ -341,6 +361,31 @@ func (ec *ExecutionClient) healthy(ctx context.Context) error { syncDistanceGauge.Record(ctx, 0, metric.WithAttributes(semconv.ServerAddress(ec.nodeAddr))) } + // Get current block to determine epoch for fork status + currentBlock, err := ec.client.BlockNumber(ctx) + if err != nil { + recordExecutionClientStatus(ctx, statusFailure, ec.nodeAddr) + ec.logger.Error(elResponseErrMsg, + zap.String("method", "eth_blockNumber"), + zap.Error(err)) + return err + } + + currentEpoch := currentBlock / SlotsPerEpoch + + // 3. Check if finalized block is available (post-fork only) + if IsFinalityActive(currentEpoch, ec.finalityForkEpoch) { + _, err := ec.client.HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) + if err != nil { + recordExecutionClientStatus(ctx, statusFailure, ec.nodeAddr) + ec.logger.Error(elResponseErrMsg, + zap.String("method", "eth_getBlockByNumber"), + zap.String("tag", "finalized"), + zap.Error(err)) + return fmt.Errorf("get finalized block: %w", err) + } + } + recordExecutionClientStatus(ctx, statusReady, ec.nodeAddr) ec.lastSyncedTime.Store(time.Now().Unix()) From c7a9a50ed800d2d712f0ce022665872fd0a2bbc3 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Wed, 7 May 2025 14:24:13 +0200 Subject: [PATCH 26/53] Update eth/executionclient/multi_client_test.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- eth/executionclient/multi_client_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eth/executionclient/multi_client_test.go b/eth/executionclient/multi_client_test.go index 58514d36c5..a8d361a847 100644 --- a/eth/executionclient/multi_client_test.go +++ b/eth/executionclient/multi_client_test.go @@ -88,7 +88,7 @@ func TestNewMulti_WithOptions(t *testing.T) { require.EqualValues(t, 0, mc.finalityForkEpoch) // Default - not using finality fork }) - t.Run("post-fork (finality(", func(t *testing.T) { + t.Run("post-fork (finality)", func(t *testing.T) { const customFinalityForkEpoch = uint64(5) mc, err := NewMulti( From 804ef769426c264aec36fbce25e979055be10d1e Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Wed, 7 May 2025 21:43:24 +0700 Subject: [PATCH 27/53] refactor --- eth/ethtest/common_test.go | 7 +-- eth/eventhandler/event_handler_test.go | 50 +++++++++---------- eth/executionclient/config.go | 6 --- eth/executionclient/config_test.go | 52 -------------------- eth/executionclient/constants.go | 10 ++++ eth/executionclient/defaults.go | 7 ++- eth/executionclient/execution_client.go | 8 +-- eth/executionclient/execution_client_test.go | 20 ++++---- eth/executionclient/multi_client.go | 6 +-- eth/executionclient/multi_client_test.go | 2 +- 10 files changed, 60 insertions(+), 108 deletions(-) delete mode 100644 eth/executionclient/config_test.go create mode 100644 eth/executionclient/constants.go diff --git a/eth/ethtest/common_test.go b/eth/ethtest/common_test.go index 0ac16ddfcf..2452f6e190 100644 --- a/eth/ethtest/common_test.go +++ b/eth/ethtest/common_test.go @@ -180,7 +180,7 @@ func (e *TestEnv) setup( } // Apply finality fork settings if configured - if e.finalityForkEpoch > 0 { + if e.finalityForkEpoch < executionclient.FinalityForkEpoch { execClientOpts = append(execClientOpts, executionclient.WithFinalityForkEpoch(e.finalityForkEpoch)) } @@ -223,7 +223,7 @@ func (e *TestEnv) setup( // SetDefaultFinalityBlocks sets the default finality blocks. func (e *TestEnv) SetDefaultFinalityBlocks() { - e.finalityBlocks = executionclient.DefaultFinalityDistance + e.finalityBlocks = executionclient.FinalityDistance } // SetDefaultFollowDistance sets the default follow distance. @@ -232,13 +232,14 @@ func (e *TestEnv) SetDefaultFollowDistance() { } // EnableFinalityFork enables the finality fork at the specified epoch. +// Using a small epoch value enables finality, while the default high value effectively disables it. func (e *TestEnv) EnableFinalityFork(epoch uint64) { e.finalityForkEpoch = epoch } // DisableFinalityFork disables the finality fork. func (e *TestEnv) DisableFinalityFork() { - e.finalityForkEpoch = 0 + e.finalityForkEpoch = executionclient.FinalityForkEpoch } // MineAndFinalize mines enough blocks to ensure finality. diff --git a/eth/eventhandler/event_handler_test.go b/eth/eventhandler/event_handler_test.go index 98bd2af7d9..2c35257c16 100644 --- a/eth/eventhandler/event_handler_test.go +++ b/eth/eventhandler/event_handler_test.go @@ -159,7 +159,7 @@ func TestHandleBlockEventsStream(t *testing.T) { } sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } @@ -286,7 +286,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } @@ -341,7 +341,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } @@ -395,7 +395,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } @@ -454,7 +454,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } @@ -507,7 +507,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } @@ -561,7 +561,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } @@ -609,7 +609,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } @@ -639,7 +639,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } @@ -683,7 +683,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } @@ -722,7 +722,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } @@ -763,7 +763,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } @@ -805,7 +805,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } @@ -855,7 +855,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } @@ -902,7 +902,7 @@ func TestHandleBlockEventsStream(t *testing.T) { sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } @@ -967,7 +967,7 @@ func TestHandleBlockEventsStream(t *testing.T) { sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } @@ -1021,7 +1021,7 @@ func TestHandleBlockEventsStream(t *testing.T) { sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } @@ -1059,7 +1059,7 @@ func TestHandleBlockEventsStream(t *testing.T) { sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } @@ -1116,7 +1116,7 @@ func TestHandleBlockEventsStream(t *testing.T) { sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } @@ -1168,7 +1168,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } @@ -1248,7 +1248,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } @@ -1314,7 +1314,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } @@ -1349,7 +1349,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } @@ -1395,7 +1395,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } @@ -1430,7 +1430,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.DefaultFinalityDistance; i++ { + for i := 0; i < executionclient.FinalityDistance; i++ { sim.Commit() } diff --git a/eth/executionclient/config.go b/eth/executionclient/config.go index 832bb051d8..63221935c3 100644 --- a/eth/executionclient/config.go +++ b/eth/executionclient/config.go @@ -12,9 +12,3 @@ type Options struct { ConnectionTimeout time.Duration `yaml:"ETH1ConnectionTimeout" env:"ETH_1_CONNECTION_TIMEOUT" env-default:"10s" env-description:"Timeout for execution client connections"` SyncDistanceTolerance uint64 `yaml:"ETH1SyncDistanceTolerance" env:"ETH_1_SYNC_DISTANCE_TOLERANCE" env-default:"5" env-description:"Maximum number of blocks behind head considered in-sync"` } - -// IsFinalityActive returns true if the finality fork is active at the given epoch. -// TODO: use the correct name when we know the name of the fork. -func IsFinalityActive(epoch uint64, finalityForkEpoch uint64) bool { - return finalityForkEpoch > 0 && epoch >= finalityForkEpoch -} diff --git a/eth/executionclient/config_test.go b/eth/executionclient/config_test.go deleted file mode 100644 index 2b12d24305..0000000000 --- a/eth/executionclient/config_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package executionclient - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestIsFinalityActive(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - currentEpoch uint64 - finalityForkEpoch uint64 - expected bool - }{ - { - name: "finality disabled when finalityForkEpoch is 0", - currentEpoch: 100, - finalityForkEpoch: 0, - expected: false, - }, - { - name: "finality inactive when current epoch is less than fork epoch", - currentEpoch: 99, - finalityForkEpoch: 100, - expected: false, - }, - { - name: "finality active when current epoch equals fork epoch", - currentEpoch: 100, - finalityForkEpoch: 100, - expected: true, - }, - { - name: "finality active when current epoch greater than fork epoch", - currentEpoch: 101, - finalityForkEpoch: 100, - expected: true, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - result := IsFinalityActive(tc.currentEpoch, tc.finalityForkEpoch) - require.Equal(t, tc.expected, result) - }) - } -} diff --git a/eth/executionclient/constants.go b/eth/executionclient/constants.go new file mode 100644 index 0000000000..e2c93a407e --- /dev/null +++ b/eth/executionclient/constants.go @@ -0,0 +1,10 @@ +package executionclient + +const ( + SlotsPerEpoch = 32 + FinalityDistance = SlotsPerEpoch * 2 + + // FinalityForkEpoch is the epoch at which the finality fork is active. + // TODO: This is a placeholder value and should be updated when the actual epoch is known. + FinalityForkEpoch = 120 +) diff --git a/eth/executionclient/defaults.go b/eth/executionclient/defaults.go index 1a705907e7..8f7c45f869 100644 --- a/eth/executionclient/defaults.go +++ b/eth/executionclient/defaults.go @@ -5,10 +5,9 @@ import ( ) const ( - SlotsPerEpoch = 32 - DefaultFinalityDistance = SlotsPerEpoch * 2 - DefaultFollowDistance = 8 // Default follow distance for pre-finality fork - DefaultFinalityForkEpoch = 0 // Epoch at which to enable finalized blocks from execution client (0 means disabled) + // DefaultFollowDistance is the default follow distance for the pre-finality fork. + // This is the number of blocks that the execution client will follow behind the head of the chain. + DefaultFollowDistance = 8 DefaultConnectionTimeout = 10 * time.Second DefaultHealthInvalidationInterval = 24 * time.Second // TODO: decide on this value, for now choosing the node prober interval but it should probably be a bit less than block interval diff --git a/eth/executionclient/execution_client.go b/eth/executionclient/execution_client.go index e1acf054d1..e87f798164 100644 --- a/eth/executionclient/execution_client.go +++ b/eth/executionclient/execution_client.go @@ -87,7 +87,7 @@ func New(ctx context.Context, nodeAddr string, contractAddr ethcommon.Address, o healthInvalidationInterval: DefaultHealthInvalidationInterval, logBatchSize: DefaultHistoricalLogsBatchSize, // TODO Make batch of logs adaptive depending on "websocket: read limit" followDistance: DefaultFollowDistance, - finalityForkEpoch: DefaultFinalityForkEpoch, + finalityForkEpoch: FinalityForkEpoch, closed: make(chan struct{}), } for _, opt := range opts { @@ -136,7 +136,7 @@ func (ec *ExecutionClient) FetchHistoricalLogs(ctx context.Context, fromBlock ui var toBlock uint64 - if !IsFinalityActive(currentEpoch, ec.finalityForkEpoch) { + if currentEpoch <= ec.finalityForkEpoch { // Pre-fork: follow distance approach if currentBlock < ec.followDistance { return nil, nil, ErrNothingToSync @@ -374,7 +374,7 @@ func (ec *ExecutionClient) healthy(ctx context.Context) error { currentEpoch := currentBlock / SlotsPerEpoch // 3. Check if finalized block is available (post-fork only) - if IsFinalityActive(currentEpoch, ec.finalityForkEpoch) { + if currentEpoch <= ec.finalityForkEpoch { _, err := ec.client.HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) if err != nil { recordExecutionClientStatus(ctx, statusFailure, ec.nodeAddr) @@ -498,7 +498,7 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo currentEpoch := header.Number.Uint64() / SlotsPerEpoch var toBlock uint64 - if !IsFinalityActive(currentEpoch, ec.finalityForkEpoch) { + if currentEpoch <= ec.finalityForkEpoch { // Pre-fork: follow distance approach if header.Number.Uint64() < ec.followDistance { continue diff --git a/eth/executionclient/execution_client_test.go b/eth/executionclient/execution_client_test.go index 20e38c82f9..ae919bd82a 100644 --- a/eth/executionclient/execution_client_test.go +++ b/eth/executionclient/execution_client_test.go @@ -157,9 +157,9 @@ func (env *testEnv) createBlocksWithLogs(contract *bind.BoundContract, count int return nil } -// finalize mines 64 blocks (DefaultFinalityDistance) to simulate proper finalization (2 epochs). +// finalize mines 64 blocks (FinalityDistance) to simulate proper finalization (2 epochs). func (env *testEnv) finalize() { - for i := 0; i < DefaultFinalityDistance; i++ { + for i := 0; i < FinalityDistance; i++ { env.sim.Commit() } } @@ -217,7 +217,7 @@ func TestFetchHistoricalLogs(t *testing.T) { WithLogger(logger), WithConnectionTimeout(2*time.Second), WithFollowDistance(followDistance), - WithFinalityForkEpoch(0), // Explicitly disable finality fork + WithFinalityForkEpoch(FinalityForkEpoch), ) require.NoError(t, err) @@ -257,7 +257,7 @@ func TestFetchHistoricalLogs(t *testing.T) { WithLogger(logger), WithConnectionTimeout(2*time.Second), WithFollowDistance(followDistance), - WithFinalityForkEpoch(0), // Explicitly disable finality fork + WithFinalityForkEpoch(FinalityForkEpoch), ) require.NoError(t, err) @@ -279,7 +279,7 @@ func TestFetchHistoricalLogs(t *testing.T) { WithLogger(logger), WithConnectionTimeout(2*time.Second), WithFollowDistance(followDistance), - WithFinalityForkEpoch(0), // Explicitly disable finality fork + WithFinalityForkEpoch(FinalityForkEpoch), ) require.NoError(t, err) @@ -387,7 +387,7 @@ func TestStreamLogs(t *testing.T) { // Create a client with explicit follow distance and disabled finality fork const followDistance = 2 - err = env.createClient(WithLogger(logger), WithFollowDistance(followDistance), WithFinalityForkEpoch(0)) + err = env.createClient(WithLogger(logger), WithFollowDistance(followDistance), WithFinalityForkEpoch(FinalityForkEpoch)) require.NoError(t, err) logsCh := env.client.StreamLogs(env.ctx, 0) @@ -707,7 +707,7 @@ func TestChainReorganizationLogs(t *testing.T) { err = env.createClient( WithLogger(logger), WithFollowDistance(followDistance), - WithFinalityForkEpoch(0), // Explicitly disable finality fork + WithFinalityForkEpoch(FinalityForkEpoch), ) require.NoError(t, err) @@ -908,7 +908,7 @@ func TestSimSSV(t *testing.T) { err = env.createClient( WithLogger(logger), WithFollowDistance(followDistance), - WithFinalityForkEpoch(0), // Explicitly disable finality fork + WithFinalityForkEpoch(FinalityForkEpoch), ) require.NoError(t, err) @@ -1165,7 +1165,7 @@ func TestBlockByNumber(t *testing.T) { // Calculate the expected latest block number based on: // - Genesis block = 0 // - Contract deployment = +1 block - // - finalize() adds DefaultFinalityDistance blocks = +64 blocks + // - finalize() adds FinalityDistance blocks = +64 blocks expectedLatestBlock := uint64(65) // 0 + 1 + 64 latestBlock, err := env.client.BlockByNumber(env.ctx, nil) require.NoError(t, err) @@ -1223,7 +1223,7 @@ func TestHeaderByNumber(t *testing.T) { // Calculate the expected latest header number based on: // - Genesis block = 0 // - Contract deployment = +1 block - // - finalize() adds DefaultFinalityDistance blocks = +64 blocks + // - finalize() adds FinalityDistance blocks = +64 blocks expectedLatestHeader := uint64(65) // 0 + 1 + 64 latestHeader, err := env.client.HeaderByNumber(env.ctx, nil) require.NoError(t, err) diff --git a/eth/executionclient/multi_client.go b/eth/executionclient/multi_client.go index 7973b4fd85..39d3bbef26 100644 --- a/eth/executionclient/multi_client.go +++ b/eth/executionclient/multi_client.go @@ -92,7 +92,7 @@ func NewMulti( connectionTimeout: DefaultConnectionTimeout, logBatchSize: DefaultHistoricalLogsBatchSize, followDistance: DefaultFollowDistance, - finalityForkEpoch: DefaultFinalityForkEpoch, + finalityForkEpoch: FinalityForkEpoch, } for _, opt := range opts { @@ -516,10 +516,10 @@ func (mc *MultiClient) DescribeForkConfig() string { banner += "--------------------------------\n" banner += "Finality determination:\n" banner += fmt.Sprintf(" - Follow distance: %d blocks\n", mc.followDistance) - if mc.finalityForkEpoch > 0 { + if mc.finalityForkEpoch < 10000 { // Consider it enabled if below some reasonable value banner += fmt.Sprintf(" - Finality fork active at epoch: %d\n", mc.finalityForkEpoch) } else { - banner += " - Finality fork: disabled\n" + banner += " - Finality fork: disabled (very high activation epoch)\n" } banner += "--------------------------------\n" return banner diff --git a/eth/executionclient/multi_client_test.go b/eth/executionclient/multi_client_test.go index a8d361a847..07b7bb97a6 100644 --- a/eth/executionclient/multi_client_test.go +++ b/eth/executionclient/multi_client_test.go @@ -85,7 +85,7 @@ func TestNewMulti_WithOptions(t *testing.T) { require.EqualValues(t, customHealthInvalidationInterval, mc.healthInvalidationInterval) require.EqualValues(t, customLogBatchSize, mc.logBatchSize) require.EqualValues(t, customSyncDistanceTolerance, mc.syncDistanceTolerance) - require.EqualValues(t, 0, mc.finalityForkEpoch) // Default - not using finality fork + require.EqualValues(t, FinalityForkEpoch, mc.finalityForkEpoch) // Default - high epoch effectively disables finality }) t.Run("post-fork (finality)", func(t *testing.T) { From 54a08d9d73a70adc8ff734df24cf1b904dfff82a Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Wed, 7 May 2025 21:46:45 +0700 Subject: [PATCH 28/53] refactor(execution_client.go): refactor IsPreFinalityFork function to improve readability and maintainability --- eth/executionclient/execution_client.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/eth/executionclient/execution_client.go b/eth/executionclient/execution_client.go index e87f798164..9e54155980 100644 --- a/eth/executionclient/execution_client.go +++ b/eth/executionclient/execution_client.go @@ -136,7 +136,7 @@ func (ec *ExecutionClient) FetchHistoricalLogs(ctx context.Context, fromBlock ui var toBlock uint64 - if currentEpoch <= ec.finalityForkEpoch { + if ec.IsPreFinalityFork(currentEpoch) { // Pre-fork: follow distance approach if currentBlock < ec.followDistance { return nil, nil, ErrNothingToSync @@ -374,7 +374,7 @@ func (ec *ExecutionClient) healthy(ctx context.Context) error { currentEpoch := currentBlock / SlotsPerEpoch // 3. Check if finalized block is available (post-fork only) - if currentEpoch <= ec.finalityForkEpoch { + if ec.IsPreFinalityFork(currentEpoch) { _, err := ec.client.HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) if err != nil { recordExecutionClientStatus(ctx, statusFailure, ec.nodeAddr) @@ -498,7 +498,7 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo currentEpoch := header.Number.Uint64() / SlotsPerEpoch var toBlock uint64 - if currentEpoch <= ec.finalityForkEpoch { + if ec.IsPreFinalityFork(currentEpoch) { // Pre-fork: follow distance approach if header.Number.Uint64() < ec.followDistance { continue @@ -569,6 +569,14 @@ func (ec *ExecutionClient) ChainID(ctx context.Context) (*big.Int, error) { return ec.client.ChainID(ctx) } +// IsPreFinalityFork returns whether the given epoch is before or equal to the finality fork epoch. +// This determines if the client should use the follow distance approach (pre-fork) +// or the finalized block approach (post-fork). +// TODO: use a correct name for this function +func (ec *ExecutionClient) IsPreFinalityFork(epoch uint64) bool { + return epoch <= ec.finalityForkEpoch +} + // connect connects to Ethereum execution client. // It must not be called twice in parallel. func (ec *ExecutionClient) connect(ctx context.Context) error { From 826dd7afba989bcbe059d4a84b6026c5da5a5934 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Thu, 8 May 2025 14:41:47 +0700 Subject: [PATCH 29/53] refactor(execution_client.go): improve variable naming and add atomic.Bool flag feat(execution_client.go): add support for determining finality fork and using finalized blocks --- eth/executionclient/execution_client.go | 44 +++++++++++++++---------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/eth/executionclient/execution_client.go b/eth/executionclient/execution_client.go index 9e54155980..e99c391766 100644 --- a/eth/executionclient/execution_client.go +++ b/eth/executionclient/execution_client.go @@ -72,9 +72,10 @@ type ExecutionClient struct { syncProgressFn func(context.Context) (*ethereum.SyncProgress, error) // variables - client *ethclient.Client - closed chan struct{} - lastSyncedTime atomic.Int64 + client *ethclient.Client + closed chan struct{} + lastSyncedTime atomic.Int64 + isPostForkState atomic.Bool // TODO: use a fork name } // New creates a new instance of ExecutionClient. @@ -136,12 +137,11 @@ func (ec *ExecutionClient) FetchHistoricalLogs(ctx context.Context, fromBlock ui var toBlock uint64 - if ec.IsPreFinalityFork(currentEpoch) { + if !ec.IsFinalityFork(currentEpoch) { // Pre-fork: follow distance approach if currentBlock < ec.followDistance { return nil, nil, ErrNothingToSync } - toBlock = currentBlock - ec.followDistance } else { // Post-fork: finalized block approach @@ -374,7 +374,8 @@ func (ec *ExecutionClient) healthy(ctx context.Context) error { currentEpoch := currentBlock / SlotsPerEpoch // 3. Check if finalized block is available (post-fork only) - if ec.IsPreFinalityFork(currentEpoch) { + if ec.IsFinalityFork(currentEpoch) { + // We're post-fork, so check for finalized blocks _, err := ec.client.HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) if err != nil { recordExecutionClientStatus(ctx, statusFailure, ec.nodeAddr) @@ -443,7 +444,6 @@ func (ec *ExecutionClient) FilterLogs(ctx context.Context, q ethereum.FilterQuer // streamLogsToChan streams ongoing logs from the given block to the given channel. // *Always* returns the last block it fetched, even if it errored. -// TODO: consider handling "websocket: read limit exceeded" error and reducing batch size (syncSmartContractsEvents has code for this) func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- BlockLogs, fromBlock uint64) (lastBlock uint64, err error) { headersCh := make(chan *ethtypes.Header) @@ -498,7 +498,7 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo currentEpoch := header.Number.Uint64() / SlotsPerEpoch var toBlock uint64 - if ec.IsPreFinalityFork(currentEpoch) { + if !ec.IsFinalityFork(currentEpoch) { // Pre-fork: follow distance approach if header.Number.Uint64() < ec.followDistance { continue @@ -506,7 +506,7 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo toBlock = header.Number.Uint64() - ec.followDistance ec.logger.Debug("processing blocks using safety distance", - zap.Uint64("epoch", currentEpoch), + zap.Uint64("estimated_epoch", currentEpoch), zap.Uint64("finality_fork_epoch", ec.finalityForkEpoch), zap.Uint64("head", header.Number.Uint64()), zap.Uint64("follow_distance", ec.followDistance), @@ -523,7 +523,7 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo toBlock = finalizedBlock.Number.Uint64() ec.logger.Debug("processing blocks using finality", - zap.Uint64("epoch", currentEpoch), + zap.Uint64("estimated_epoch", currentEpoch), zap.Uint64("finality_fork_epoch", ec.finalityForkEpoch), zap.Uint64("finalized_block", toBlock)) @@ -531,7 +531,7 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo finalizedEpoch := toBlock / SlotsPerEpoch ec.logger.Info("⏱ finalized block changed", zap.Uint64("new_finalized", toBlock), - zap.Uint64("epoch", finalizedEpoch), + zap.Uint64("estimated_epoch", finalizedEpoch), zap.Uint64("previous_finalized", lastFinalized)) lastFinalized = toBlock } @@ -569,12 +569,22 @@ func (ec *ExecutionClient) ChainID(ctx context.Context) (*big.Int, error) { return ec.client.ChainID(ctx) } -// IsPreFinalityFork returns whether the given epoch is before or equal to the finality fork epoch. -// This determines if the client should use the follow distance approach (pre-fork) -// or the finalized block approach (post-fork). -// TODO: use a correct name for this function -func (ec *ExecutionClient) IsPreFinalityFork(epoch uint64) bool { - return epoch <= ec.finalityForkEpoch +// IsFinalityFork determines if we should use finalized blocks or follow distance +// It also sets the permanent flag once we've confirmed passing the fork threshold. +func (ec *ExecutionClient) IsFinalityFork(epoch uint64) bool { + if ec.isPostForkState.Load() { + return true + } + + if epoch > ec.finalityForkEpoch { + ec.isPostForkState.Store(true) + ec.logger.Info("finality fork threshold passed, using finalized blocks", + zap.Uint64("current_estimated_epoch", epoch), + zap.Uint64("finality_fork_epoch", ec.finalityForkEpoch)) + return true + } + + return false } // connect connects to Ethereum execution client. From 488546b981251406ed4fe80a812e9a34e0a331d6 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Thu, 8 May 2025 14:53:43 +0700 Subject: [PATCH 30/53] remove old stuff --- eth/executionclient/multi_client.go | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/eth/executionclient/multi_client.go b/eth/executionclient/multi_client.go index 39d3bbef26..616e860e48 100644 --- a/eth/executionclient/multi_client.go +++ b/eth/executionclient/multi_client.go @@ -508,19 +508,3 @@ func methodFromContext(ctx context.Context) string { } return v } - -// DescribeForkConfig returns a human-readable description of the fork configuration. -func (mc *MultiClient) DescribeForkConfig() string { - var banner string - banner += "SSV Multi-Client Configuration:\n" - banner += "--------------------------------\n" - banner += "Finality determination:\n" - banner += fmt.Sprintf(" - Follow distance: %d blocks\n", mc.followDistance) - if mc.finalityForkEpoch < 10000 { // Consider it enabled if below some reasonable value - banner += fmt.Sprintf(" - Finality fork active at epoch: %d\n", mc.finalityForkEpoch) - } else { - banner += " - Finality fork: disabled (very high activation epoch)\n" - } - banner += "--------------------------------\n" - return banner -} From 211ed9c802821777a967bc43ef39ec41e322bd2f Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Thu, 8 May 2025 16:00:46 +0700 Subject: [PATCH 31/53] checkpoint [skip ci] --- eth/eventsyncer/event_syncer.go | 5 ----- eth/executionclient/execution_client.go | 2 ++ 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/eth/eventsyncer/event_syncer.go b/eth/eventsyncer/event_syncer.go index a500ac5c43..b86374e8fd 100644 --- a/eth/eventsyncer/event_syncer.go +++ b/eth/eventsyncer/event_syncer.go @@ -26,11 +26,6 @@ const ( defaultStalenessThreshold = 300 * time.Second ) -var ( - // ErrNodeNotReady is returned when node is not ready. - ErrNodeNotReady = fmt.Errorf("node not ready") -) - type ExecutionClient interface { FetchHistoricalLogs(ctx context.Context, fromBlock uint64) (logs <-chan executionclient.BlockLogs, errors <-chan error, err error) StreamLogs(ctx context.Context, fromBlock uint64) <-chan executionclient.BlockLogs diff --git a/eth/executionclient/execution_client.go b/eth/executionclient/execution_client.go index e99c391766..ce856246e8 100644 --- a/eth/executionclient/execution_client.go +++ b/eth/executionclient/execution_client.go @@ -551,11 +551,13 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo logs <- block lastBlock = block.BlockNumber } + if err := <-fetchErrors; err != nil { // If we get an error while fetching, we return the last block we fetched. return lastBlock, fmt.Errorf("fetch logs: %w", err) } fromBlock = toBlock + 1 + observability.RecordUint64Value(ctx, fromBlock, lastProcessedBlockGauge.Record, metric.WithAttributes(semconv.ServerAddress(ec.nodeAddr))) } } From c7feb03cbfd27f561d5d65eeb9878194b4bb2f14 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Thu, 8 May 2025 18:55:11 +0700 Subject: [PATCH 32/53] fix too old block, TODO: refactor, optimize rpc calls --- eth/eventsyncer/event_syncer.go | 55 +++++++++++++++++++------ eth/eventsyncer/event_syncer_mock.go | 16 +++++++ eth/executionclient/execution_client.go | 29 +++++++++++-- eth/executionclient/mocks.go | 28 +++++++++++++ eth/executionclient/multi_client.go | 16 +++++++ 5 files changed, 128 insertions(+), 16 deletions(-) diff --git a/eth/eventsyncer/event_syncer.go b/eth/eventsyncer/event_syncer.go index b86374e8fd..fa0117cbc0 100644 --- a/eth/eventsyncer/event_syncer.go +++ b/eth/eventsyncer/event_syncer.go @@ -10,6 +10,7 @@ import ( "time" "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" "go.uber.org/zap" "github.com/ssvlabs/ssv/eth/executionclient" @@ -23,13 +24,15 @@ import ( // https://github.com/ssvlabs/ssv/pull/1053 const ( - defaultStalenessThreshold = 300 * time.Second + defaultStalenessThreshold = 300 * time.Second + defaultFinalizedStalenessThreshold = 3 * 32 * 12 * time.Second // 3 epochs // TODO: set a proper value? ) type ExecutionClient interface { FetchHistoricalLogs(ctx context.Context, fromBlock uint64) (logs <-chan executionclient.BlockLogs, errors <-chan error, err error) StreamLogs(ctx context.Context, fromBlock uint64) <-chan executionclient.BlockLogs HeaderByNumber(ctx context.Context, blockNumber *big.Int) (*types.Header, error) + IsFinalizedFork(ctx context.Context) bool } type EventHandler interface { @@ -43,8 +46,10 @@ type EventSyncer struct { executionClient ExecutionClient eventHandler EventHandler - logger *zap.Logger - stalenessThreshold time.Duration + logger *zap.Logger + + stalenessThreshold time.Duration + finalizedStalenessThreshold time.Duration lastProcessedBlock uint64 lastProcessedBlockChange time.Time @@ -56,8 +61,9 @@ func New(nodeStorage nodestorage.Storage, executionClient ExecutionClient, event executionClient: executionClient, eventHandler: eventHandler, - logger: zap.NewNop(), - stalenessThreshold: defaultStalenessThreshold, + logger: zap.NewNop(), + stalenessThreshold: defaultStalenessThreshold, + finalizedStalenessThreshold: defaultFinalizedStalenessThreshold, } for _, opt := range opts { @@ -88,15 +94,40 @@ func (es *EventSyncer) Healthy(ctx context.Context) error { return es.blockBelowThreshold(ctx, lastProcessedBlock) } +// blockBelowThreshold checks if the specified block is recent enough. func (es *EventSyncer) blockBelowThreshold(ctx context.Context, block *big.Int) error { - header, err := es.executionClient.HeaderByNumber(ctx, block) - if err != nil { - return fmt.Errorf("failed to get header for block %d: %w", block, err) - } + usingFinalized := es.executionClient.IsFinalizedFork(ctx) + + if usingFinalized { + // When using finalized blocks, only check if the finalized block is fresh + finalizedHeader, err := es.executionClient.HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) + if err != nil { + return fmt.Errorf("failed to get finalized block header: %w", err) + } + + // #nosec G115 + blockTime := time.Unix(int64(finalizedHeader.Time), 0) + staleness := time.Since(blockTime) - // #nosec G115 - if header.Time < uint64(time.Now().Add(-es.stalenessThreshold).Unix()) { - return fmt.Errorf("block %d is too old", block) + if staleness > es.finalizedStalenessThreshold { + return fmt.Errorf("finalized block %d is too old (age: %s)", + finalizedHeader.Number.Uint64(), staleness.Round(time.Second)) + } + } else { + // When using safety distance, check the specific block + header, err := es.executionClient.HeaderByNumber(ctx, block) + if err != nil { + return fmt.Errorf("failed to get header for block %d: %w", block, err) + } + + // #nosec G115 + blockTime := time.Unix(int64(header.Time), 0) + staleness := time.Since(blockTime) + + if staleness > es.stalenessThreshold { + return fmt.Errorf("block %d is too old (age: %s)", + block.Uint64(), staleness.Round(time.Second)) + } } return nil diff --git a/eth/eventsyncer/event_syncer_mock.go b/eth/eventsyncer/event_syncer_mock.go index ba992727d3..12b358a42a 100644 --- a/eth/eventsyncer/event_syncer_mock.go +++ b/eth/eventsyncer/event_syncer_mock.go @@ -23,6 +23,7 @@ import ( type MockExecutionClient struct { ctrl *gomock.Controller recorder *MockExecutionClientMockRecorder + isgomock struct{} } // MockExecutionClientMockRecorder is the mock recorder for MockExecutionClient. @@ -73,6 +74,20 @@ func (mr *MockExecutionClientMockRecorder) HeaderByNumber(ctx, blockNumber any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HeaderByNumber", reflect.TypeOf((*MockExecutionClient)(nil).HeaderByNumber), ctx, blockNumber) } +// IsFinalizedFork mocks base method. +func (m *MockExecutionClient) IsFinalizedFork(ctx context.Context) bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsFinalizedFork", ctx) + ret0, _ := ret[0].(bool) + return ret0 +} + +// IsFinalizedFork indicates an expected call of IsFinalizedFork. +func (mr *MockExecutionClientMockRecorder) IsFinalizedFork(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsFinalizedFork", reflect.TypeOf((*MockExecutionClient)(nil).IsFinalizedFork), ctx) +} + // StreamLogs mocks base method. func (m *MockExecutionClient) StreamLogs(ctx context.Context, fromBlock uint64) <-chan executionclient.BlockLogs { m.ctrl.T.Helper() @@ -91,6 +106,7 @@ func (mr *MockExecutionClientMockRecorder) StreamLogs(ctx, fromBlock any) *gomoc type MockEventHandler struct { ctrl *gomock.Controller recorder *MockEventHandlerMockRecorder + isgomock struct{} } // MockEventHandlerMockRecorder is the mock recorder for MockEventHandler. diff --git a/eth/executionclient/execution_client.go b/eth/executionclient/execution_client.go index ce856246e8..9c52295ac7 100644 --- a/eth/executionclient/execution_client.go +++ b/eth/executionclient/execution_client.go @@ -35,6 +35,7 @@ type Provider interface { Healthy(ctx context.Context) error SubscribeFilterLogs(ctx context.Context, q ethereum.FilterQuery, ch chan<- ethtypes.Log) (ethereum.Subscription, error) FilterLogs(ctx context.Context, q ethereum.FilterQuery) ([]ethtypes.Log, error) + IsFinalizedFork(ctx context.Context) bool Close() error } @@ -137,7 +138,7 @@ func (ec *ExecutionClient) FetchHistoricalLogs(ctx context.Context, fromBlock ui var toBlock uint64 - if !ec.IsFinalityFork(currentEpoch) { + if !ec.isFinalityFork(currentEpoch) { // Pre-fork: follow distance approach if currentBlock < ec.followDistance { return nil, nil, ErrNothingToSync @@ -374,7 +375,7 @@ func (ec *ExecutionClient) healthy(ctx context.Context) error { currentEpoch := currentBlock / SlotsPerEpoch // 3. Check if finalized block is available (post-fork only) - if ec.IsFinalityFork(currentEpoch) { + if ec.isFinalityFork(currentEpoch) { // We're post-fork, so check for finalized blocks _, err := ec.client.HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) if err != nil { @@ -498,7 +499,7 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo currentEpoch := header.Number.Uint64() / SlotsPerEpoch var toBlock uint64 - if !ec.IsFinalityFork(currentEpoch) { + if !ec.isFinalityFork(currentEpoch) { // Pre-fork: follow distance approach if header.Number.Uint64() < ec.followDistance { continue @@ -571,9 +572,29 @@ func (ec *ExecutionClient) ChainID(ctx context.Context) (*big.Int, error) { return ec.client.ChainID(ctx) } +// IsFinalizedFork returns whether finalized blocks should be used instead of follow distance. +// Returns true if we've passed the finality fork epoch threshold. +func (ec *ExecutionClient) IsFinalizedFork(ctx context.Context) bool { + if ec.isPostForkState.Load() { + return true + } + + currentBlock, err := ec.client.BlockNumber(ctx) + if err != nil { + ec.logger.Error(elResponseErrMsg, + zap.String("method", "eth_blockNumber"), + zap.Error(err)) + return false + } + + currentEpoch := currentBlock / SlotsPerEpoch + + return ec.isFinalityFork(currentEpoch) +} + // IsFinalityFork determines if we should use finalized blocks or follow distance // It also sets the permanent flag once we've confirmed passing the fork threshold. -func (ec *ExecutionClient) IsFinalityFork(epoch uint64) bool { +func (ec *ExecutionClient) isFinalityFork(epoch uint64) bool { if ec.isPostForkState.Load() { return true } diff --git a/eth/executionclient/mocks.go b/eth/executionclient/mocks.go index f9171642d4..809c0d2392 100644 --- a/eth/executionclient/mocks.go +++ b/eth/executionclient/mocks.go @@ -163,6 +163,20 @@ func (mr *MockProviderMockRecorder) Healthy(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Healthy", reflect.TypeOf((*MockProvider)(nil).Healthy), ctx) } +// IsFinalizedFork mocks base method. +func (m *MockProvider) IsFinalizedFork(ctx context.Context) bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsFinalizedFork", ctx) + ret0, _ := ret[0].(bool) + return ret0 +} + +// IsFinalizedFork indicates an expected call of IsFinalizedFork. +func (mr *MockProviderMockRecorder) IsFinalizedFork(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsFinalizedFork", reflect.TypeOf((*MockProvider)(nil).IsFinalizedFork), ctx) +} + // StreamLogs mocks base method. func (m *MockProvider) StreamLogs(ctx context.Context, fromBlock uint64) <-chan BlockLogs { m.ctrl.T.Helper() @@ -335,6 +349,20 @@ func (mr *MockSingleClientProviderMockRecorder) Healthy(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Healthy", reflect.TypeOf((*MockSingleClientProvider)(nil).Healthy), ctx) } +// IsFinalizedFork mocks base method. +func (m *MockSingleClientProvider) IsFinalizedFork(ctx context.Context) bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsFinalizedFork", ctx) + ret0, _ := ret[0].(bool) + return ret0 +} + +// IsFinalizedFork indicates an expected call of IsFinalizedFork. +func (mr *MockSingleClientProviderMockRecorder) IsFinalizedFork(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsFinalizedFork", reflect.TypeOf((*MockSingleClientProvider)(nil).IsFinalizedFork), ctx) +} + // StreamLogs mocks base method. func (m *MockSingleClientProvider) StreamLogs(ctx context.Context, fromBlock uint64) <-chan BlockLogs { m.ctrl.T.Helper() diff --git a/eth/executionclient/multi_client.go b/eth/executionclient/multi_client.go index 616e860e48..4be167c66f 100644 --- a/eth/executionclient/multi_client.go +++ b/eth/executionclient/multi_client.go @@ -508,3 +508,19 @@ func methodFromContext(ctx context.Context) string { } return v } + +// IsFinalizedFork returns whether the client is currently using finalized blocks. +func (mc *MultiClient) IsFinalizedFork(ctx context.Context) bool { + f := func(client SingleClientProvider) (any, error) { + return client.IsFinalizedFork(ctx), nil + } + + res, err := mc.call(contextWithMethod(ctx, "IsFinalizedFork"), f, len(mc.clients)) + if err != nil { + mc.logger.Warn("failed to check if using finalized fork, assuming not using it", + zap.Error(err)) + return false + } + + return res.(bool) +} From d21c7066533385b3b5f2eddea356820c7af1015d Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Thu, 8 May 2025 19:09:12 +0700 Subject: [PATCH 33/53] feat(event_syncer_test.go): add tests for finalized fork scenarios in blockBelowThreshold function --- eth/eventsyncer/event_syncer_test.go | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/eth/eventsyncer/event_syncer_test.go b/eth/eventsyncer/event_syncer_test.go index 53b1811fe1..52f20ffe19 100644 --- a/eth/eventsyncer/event_syncer_test.go +++ b/eth/eventsyncer/event_syncer_test.go @@ -16,6 +16,7 @@ import ( ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient/simulated" + "github.com/ethereum/go-ethereum/rpc" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "go.uber.org/zap" @@ -243,6 +244,7 @@ func TestBlockBelowThreshold(t *testing.T) { t.Run("fails on EC error", func(t *testing.T) { err1 := errors.New("ec err") + m.EXPECT().IsFinalizedFork(ctx).Return(false) m.EXPECT().HeaderByNumber(ctx, big.NewInt(1)).Return(nil, err1) err := s.blockBelowThreshold(ctx, big.NewInt(1)) require.ErrorIs(t, err, err1) @@ -250,6 +252,7 @@ func TestBlockBelowThreshold(t *testing.T) { t.Run("fails if outside threshold", func(t *testing.T) { header := ðtypes.Header{Time: uint64(time.Now().Add(-(defaultStalenessThreshold + time.Second)).Unix())} + m.EXPECT().IsFinalizedFork(ctx).Return(false) m.EXPECT().HeaderByNumber(ctx, big.NewInt(1)).Return(header, nil) err := s.blockBelowThreshold(ctx, big.NewInt(1)) require.Error(t, err) @@ -257,8 +260,39 @@ func TestBlockBelowThreshold(t *testing.T) { t.Run("success", func(t *testing.T) { header := ðtypes.Header{Time: uint64(time.Now().Add(-(defaultStalenessThreshold - time.Second)).Unix())} + m.EXPECT().IsFinalizedFork(ctx).Return(false) m.EXPECT().HeaderByNumber(ctx, big.NewInt(1)).Return(header, nil) err := s.blockBelowThreshold(ctx, big.NewInt(1)) require.NoError(t, err) }) + + t.Run("finalized fork success", func(t *testing.T) { + finalizedHeader := ðtypes.Header{ + Time: uint64(time.Now().Add(-(defaultFinalizedStalenessThreshold - time.Second)).Unix()), + Number: big.NewInt(100), + } + m.EXPECT().IsFinalizedFork(ctx).Return(true) + m.EXPECT().HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())).Return(finalizedHeader, nil) + err := s.blockBelowThreshold(ctx, big.NewInt(1)) // The block parameter is not used when IsFinalizedFork is true + require.NoError(t, err) + }) + + t.Run("finalized fork too old", func(t *testing.T) { + finalizedHeader := ðtypes.Header{ + Time: uint64(time.Now().Add(-(defaultFinalizedStalenessThreshold + time.Second)).Unix()), + Number: big.NewInt(100), + } + m.EXPECT().IsFinalizedFork(ctx).Return(true) + m.EXPECT().HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())).Return(finalizedHeader, nil) + err := s.blockBelowThreshold(ctx, big.NewInt(1)) + require.Error(t, err) + }) + + t.Run("finalized fork error", func(t *testing.T) { + err1 := errors.New("finalized block error") + m.EXPECT().IsFinalizedFork(ctx).Return(true) + m.EXPECT().HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())).Return(nil, err1) + err := s.blockBelowThreshold(ctx, big.NewInt(1)) + require.ErrorIs(t, err, err1) + }) } From f165470f12f746b5ee85414d9b4fd8351b046cb7 Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Fri, 9 May 2025 13:08:23 +0700 Subject: [PATCH 34/53] refactor(execution_client.go): improve logic for determining target block based on fork state and finalize block availability feat(execution_client.go): add support for syncing state and transition to post-fork state feat(execution_client.go): implement getFinalizedBlock method to retrieve finalized block number --- eth/executionclient/execution_client.go | 213 +++++++++++++----------- 1 file changed, 113 insertions(+), 100 deletions(-) diff --git a/eth/executionclient/execution_client.go b/eth/executionclient/execution_client.go index 9c52295ac7..bde91d6975 100644 --- a/eth/executionclient/execution_client.go +++ b/eth/executionclient/execution_client.go @@ -51,6 +51,7 @@ var ( ErrClosed = fmt.Errorf("closed") ErrBadInput = fmt.Errorf("bad input") ErrNothingToSync = errors.New("nothing to sync") + errSyncing = fmt.Errorf("syncing") ) const elResponseErrMsg = "Execution client returned an error" @@ -126,35 +127,47 @@ func (ec *ExecutionClient) Close() error { // FetchHistoricalLogs retrieves historical logs emitted by the contract starting from fromBlock. func (ec *ExecutionClient) FetchHistoricalLogs(ctx context.Context, fromBlock uint64) (logs <-chan BlockLogs, errors <-chan error, err error) { - currentBlock, err := ec.client.BlockNumber(ctx) - if err != nil { - ec.logger.Error(elResponseErrMsg, - zap.String("method", "eth_blockNumber"), - zap.Error(err)) - return nil, nil, fmt.Errorf("failed to get current block: %w", err) - } - - currentEpoch := currentBlock / SlotsPerEpoch - var toBlock uint64 - if !ec.isFinalityFork(currentEpoch) { - // Pre-fork: follow distance approach - if currentBlock < ec.followDistance { - return nil, nil, ErrNothingToSync - } - toBlock = currentBlock - ec.followDistance - } else { - // Post-fork: finalized block approach - finalizedBlock, err := ec.client.HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) + if ec.isPostForkState.Load() { + toBlock, err = ec.getFinalizedBlock(ctx) if err != nil { ec.logger.Error(elResponseErrMsg, zap.String("method", "eth_getBlockByNumber"), zap.String("tag", "finalized"), zap.Error(err)) - return nil, nil, fmt.Errorf("get finalized block: %w", err) + return nil, nil, err + } + } else { + currentBlock, err := ec.client.BlockNumber(ctx) + if err != nil { + ec.logger.Error(elResponseErrMsg, + zap.String("method", "eth_blockNumber"), + zap.Error(err)) + return nil, nil, fmt.Errorf("failed to get current block: %w", err) + } + + // Check if we're past the fork + currentEpoch := currentBlock / SlotsPerEpoch + + if currentEpoch > ec.finalityForkEpoch { + // Just passed the fork threshold + ec.isPostForkState.Store(true) + toBlock, err = ec.getFinalizedBlock(ctx) + if err != nil { + ec.logger.Error(elResponseErrMsg, + zap.String("method", "eth_getBlockByNumber"), + zap.String("tag", "finalized"), + zap.Error(err)) + return nil, nil, err + } + } else { + // Pre-fork: use follow distance + if currentBlock < ec.followDistance { + return nil, nil, ErrNothingToSync + } + toBlock = currentBlock - ec.followDistance } - toBlock = finalizedBlock.Number.Uint64() } if toBlock < fromBlock { @@ -297,8 +310,6 @@ func (ec *ExecutionClient) StreamLogs(ctx context.Context, fromBlock uint64) <-c return logsCh } -var errSyncing = fmt.Errorf("syncing") - // Healthy returns if execution client is currently healthy: responds to requests and not in the syncing state. func (ec *ExecutionClient) Healthy(ctx context.Context) error { if ec.isClosed() { @@ -332,18 +343,24 @@ func (ec *ExecutionClient) Healthy(ctx context.Context) error { // - network errors: when the client doesn't respond // TODO: update for related stuff (names, etc) func (ec *ExecutionClient) healthy(ctx context.Context) error { + if ec.isClosed() { + return ErrClosed + } + + // Check if we recently validated health + lastHealthyTime := time.Unix(ec.lastSyncedTime.Load(), 0) + if ec.healthInvalidationInterval != 0 && time.Since(lastHealthyTime) <= ec.healthInvalidationInterval { + return nil + } + ctx, cancel := context.WithTimeout(ctx, ec.connectionTimeout) defer cancel() - start := time.Now() - // 1. Check if client is reachable + start := time.Now() sp, err := ec.SyncProgress(ctx) if err != nil { recordExecutionClientStatus(ctx, statusFailure, ec.nodeAddr) - ec.logger.Error(elResponseErrMsg, - zap.String("method", "eth_syncing"), - zap.Error(err)) return err } recordRequestDuration(ctx, ec.nodeAddr, time.Since(start)) @@ -362,29 +379,28 @@ func (ec *ExecutionClient) healthy(ctx context.Context) error { syncDistanceGauge.Record(ctx, 0, metric.WithAttributes(semconv.ServerAddress(ec.nodeAddr))) } - // Get current block to determine epoch for fork status - currentBlock, err := ec.client.BlockNumber(ctx) - if err != nil { - recordExecutionClientStatus(ctx, statusFailure, ec.nodeAddr) - ec.logger.Error(elResponseErrMsg, - zap.String("method", "eth_blockNumber"), - zap.Error(err)) - return err - } - - currentEpoch := currentBlock / SlotsPerEpoch - - // 3. Check if finalized block is available (post-fork only) - if ec.isFinalityFork(currentEpoch) { - // We're post-fork, so check for finalized blocks - _, err := ec.client.HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) + // 3. Check finalized block availability (post-fork only) + if ec.isPostForkState.Load() { + _, err := ec.getFinalizedBlock(ctx) if err != nil { recordExecutionClientStatus(ctx, statusFailure, ec.nodeAddr) - ec.logger.Error(elResponseErrMsg, - zap.String("method", "eth_getBlockByNumber"), - zap.String("tag", "finalized"), - zap.Error(err)) - return fmt.Errorf("get finalized block: %w", err) + return err + } + } else { + // Check if we've just passed the fork point + currentBlock, err := ec.client.BlockNumber(ctx) + if err != nil { + recordExecutionClientStatus(ctx, statusFailure, ec.nodeAddr) + return err + } + + if currentBlock/SlotsPerEpoch > ec.finalityForkEpoch { + ec.isPostForkState.Store(true) + _, err := ec.getFinalizedBlock(ctx) + if err != nil { + recordExecutionClientStatus(ctx, statusFailure, ec.nodeAddr) + return err + } } } @@ -479,54 +495,30 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo select { case <-ctx.Done(): return fromBlock, context.Canceled - case <-ec.closed: return fromBlock, ErrClosed - case subErr := <-sub.Err(): if subErr == nil { return fromBlock, ErrClosed } return fromBlock, fmt.Errorf("subscription: %w", subErr) - case header := <-headersCh: + headerNum := header.Number.Uint64() ec.logger.Debug("new head received", - zap.Uint64("head_number", header.Number.Uint64()), + zap.Uint64("head_number", headerNum), zap.String("head_hash", header.Hash().Hex()), zap.String("head_parent_hash", header.ParentHash.Hex())) - // Calculate current epoch to determine which finality approach to use - currentEpoch := header.Number.Uint64() / SlotsPerEpoch var toBlock uint64 - if !ec.isFinalityFork(currentEpoch) { - // Pre-fork: follow distance approach - if header.Number.Uint64() < ec.followDistance { - continue - } - toBlock = header.Number.Uint64() - ec.followDistance - - ec.logger.Debug("processing blocks using safety distance", - zap.Uint64("estimated_epoch", currentEpoch), - zap.Uint64("finality_fork_epoch", ec.finalityForkEpoch), - zap.Uint64("head", header.Number.Uint64()), - zap.Uint64("follow_distance", ec.followDistance), - zap.Uint64("target_block", toBlock)) - } else { - // Post-fork: finalized block approach - finalizedBlock, err := ec.client.HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) + // Determine target block based on fork state + if ec.isPostForkState.Load() { + // Post-fork: use finalized block + finalizedBlock, err := ec.getFinalizedBlock(ctx) if err != nil { - ec.logger.Error(elResponseErrMsg, - zap.String("method", "eth_getBlockByNumber"), - zap.Error(err)) - return fromBlock, fmt.Errorf("get finalized block: %w", err) + return fromBlock, err } - toBlock = finalizedBlock.Number.Uint64() - - ec.logger.Debug("processing blocks using finality", - zap.Uint64("estimated_epoch", currentEpoch), - zap.Uint64("finality_fork_epoch", ec.finalityForkEpoch), - zap.Uint64("finalized_block", toBlock)) + toBlock = finalizedBlock if toBlock != lastFinalized { finalizedEpoch := toBlock / SlotsPerEpoch @@ -536,17 +528,37 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo zap.Uint64("previous_finalized", lastFinalized)) lastFinalized = toBlock } + } else { + // Check if we need to transition to post-fork + currentEpoch := headerNum / SlotsPerEpoch + + if currentEpoch > ec.finalityForkEpoch { + ec.isPostForkState.Store(true) + finalizedBlock, err := ec.getFinalizedBlock(ctx) + if err != nil { + return fromBlock, err + } + + toBlock = finalizedBlock + } else { + // Pre-fork: follow distance approach + if headerNum < ec.followDistance { + continue + } + toBlock = headerNum - ec.followDistance + } } - // Wait until the target block number catches up to where we need to start processing - // This prevents fetching logs from unfinalized (and potentially reorged) blocks + // Skip if toBlock is less than fromBlock if toBlock < fromBlock { - ec.logger.Info("waiting for finalized block to reach fromBlock", - zap.Uint64("from_block", fromBlock), - zap.Uint64("finalized_block", toBlock)) + ec.logger.Info("waiting for target block to reach fromBlock", + fields.FromBlock(fromBlock), + fields.ToBlock(toBlock), + zap.Bool("finalized_fork", ec.isPostForkState.Load())) continue } + // Process logs for this block range logStream, fetchErrors := ec.fetchLogsInBatches(ctx, fromBlock, toBlock) for block := range logStream { logs <- block @@ -554,12 +566,13 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo } if err := <-fetchErrors; err != nil { - // If we get an error while fetching, we return the last block we fetched. return lastBlock, fmt.Errorf("fetch logs: %w", err) } + fromBlock = toBlock + 1 - observability.RecordUint64Value(ctx, fromBlock, lastProcessedBlockGauge.Record, metric.WithAttributes(semconv.ServerAddress(ec.nodeAddr))) + observability.RecordUint64Value(ctx, fromBlock, lastProcessedBlockGauge.Record, + metric.WithAttributes(semconv.ServerAddress(ec.nodeAddr))) } } } @@ -579,6 +592,7 @@ func (ec *ExecutionClient) IsFinalizedFork(ctx context.Context) bool { return true } + // Only make this RPC call if we don't know our fork state yet currentBlock, err := ec.client.BlockNumber(ctx) if err != nil { ec.logger.Error(elResponseErrMsg, @@ -589,20 +603,11 @@ func (ec *ExecutionClient) IsFinalizedFork(ctx context.Context) bool { currentEpoch := currentBlock / SlotsPerEpoch - return ec.isFinalityFork(currentEpoch) -} - -// IsFinalityFork determines if we should use finalized blocks or follow distance -// It also sets the permanent flag once we've confirmed passing the fork threshold. -func (ec *ExecutionClient) isFinalityFork(epoch uint64) bool { - if ec.isPostForkState.Load() { - return true - } - - if epoch > ec.finalityForkEpoch { + // Check if we've passed the fork point + if currentEpoch > ec.finalityForkEpoch { ec.isPostForkState.Store(true) ec.logger.Info("finality fork threshold passed, using finalized blocks", - zap.Uint64("current_estimated_epoch", epoch), + zap.Uint64("current_epoch", currentEpoch), zap.Uint64("finality_fork_epoch", ec.finalityForkEpoch)) return true } @@ -610,6 +615,14 @@ func (ec *ExecutionClient) isFinalityFork(epoch uint64) bool { return false } +func (ec *ExecutionClient) getFinalizedBlock(ctx context.Context) (uint64, error) { + finalizedBlock, err := ec.client.HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) + if err != nil { + return 0, fmt.Errorf("get finalized block: %w", err) + } + return finalizedBlock.Number.Uint64(), nil +} + // connect connects to Ethereum execution client. // It must not be called twice in parallel. func (ec *ExecutionClient) connect(ctx context.Context) error { From 9ee2a15a54df29a3cc75624ccacd4bba31f6669e Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Fri, 9 May 2025 13:13:22 +0700 Subject: [PATCH 35/53] refactor(execution_client.go): refactor logging in streamLogsToChan method to use fields.BlockNumber for better readability refactor(execution_client.go): refactor logging in connect method to use fields.Took for consistency and clarity --- eth/executionclient/execution_client.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eth/executionclient/execution_client.go b/eth/executionclient/execution_client.go index bde91d6975..1d967dc4b5 100644 --- a/eth/executionclient/execution_client.go +++ b/eth/executionclient/execution_client.go @@ -505,7 +505,7 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo case header := <-headersCh: headerNum := header.Number.Uint64() ec.logger.Debug("new head received", - zap.Uint64("head_number", headerNum), + fields.BlockNumber(headerNum), zap.String("head_hash", header.Hash().Hex()), zap.String("head_parent_hash", header.ParentHash.Hex())) @@ -641,7 +641,7 @@ func (ec *ExecutionClient) connect(ctx context.Context) error { } ec.client = client - logger.Info("connected to execution client", zap.Duration("took", time.Since(start))) + logger.Info("connected to execution client", fields.Took(time.Since(start))) return nil } From 61e1ba29c27f2d626ee0b16062f3c0ed2761397c Mon Sep 17 00:00:00 2001 From: karol-ssvlabs Date: Fri, 9 May 2025 14:11:15 +0700 Subject: [PATCH 36/53] refactor(event_syncer.go): improve readability by extracting lastBlockNum variable refactor(event_syncer.go): optimize staleness check by considering finalizedStalenessThreshold if execution client is finalized --- eth/eventsyncer/event_syncer.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/eth/eventsyncer/event_syncer.go b/eth/eventsyncer/event_syncer.go index fa0117cbc0..417d0d7bbb 100644 --- a/eth/eventsyncer/event_syncer.go +++ b/eth/eventsyncer/event_syncer.go @@ -82,13 +82,23 @@ func (es *EventSyncer) Healthy(ctx context.Context) error { if !found || lastProcessedBlock == nil || lastProcessedBlock.Uint64() == 0 { return fmt.Errorf("last processed block is not set") } - if es.lastProcessedBlock != lastProcessedBlock.Uint64() { - es.lastProcessedBlock = lastProcessedBlock.Uint64() + + lastBlockNum := lastProcessedBlock.Uint64() + if es.lastProcessedBlock != lastBlockNum { + es.lastProcessedBlock = lastBlockNum es.lastProcessedBlockChange = time.Now() return nil } - if time.Since(es.lastProcessedBlockChange) > es.stalenessThreshold { - return fmt.Errorf("syncing is stuck at block %d", lastProcessedBlock.Uint64()) + + staleness := time.Since(es.lastProcessedBlockChange) + threshold := es.stalenessThreshold + + if es.executionClient.IsFinalizedFork(ctx) { + threshold = es.finalizedStalenessThreshold + } + + if staleness > threshold { + return fmt.Errorf("syncing is stuck at block %d", lastBlockNum) } return es.blockBelowThreshold(ctx, lastProcessedBlock) From f4cca05ed75f7586be5f179bd367c1bc4f952f9f Mon Sep 17 00:00:00 2001 From: kchojn Date: Mon, 19 May 2025 21:42:55 +0700 Subject: [PATCH 37/53] move fork stuff to networkconfig --- cli/operator/node.go | 2 + eth/ethtest/common_test.go | 88 ++++++---------- eth/ethtest/eth_e2e_test.go | 19 +--- eth/eventhandler/event_handler_test.go | 56 +++++----- eth/eventsyncer/event_syncer_test.go | 6 +- eth/executionclient/config.go | 33 ++++++ eth/executionclient/constants.go | 10 -- eth/executionclient/execution_client.go | 39 +++---- eth/executionclient/execution_client_test.go | 104 ++++++++++++------- eth/executionclient/multi_client.go | 11 +- eth/executionclient/multi_client_test.go | 16 +-- eth/executionclient/options.go | 32 ------ networkconfig/config.go | 5 +- networkconfig/forks.go | 30 ++++++ networkconfig/hoodi.go | 3 + networkconfig/mainnet.go | 3 + networkconfig/sepolia.go | 3 + networkconfig/test-network.go | 3 + 18 files changed, 251 insertions(+), 212 deletions(-) delete mode 100644 eth/executionclient/constants.go create mode 100644 networkconfig/forks.go diff --git a/cli/operator/node.go b/cli/operator/node.go index 42bb7e8081..4eaed4a6b0 100644 --- a/cli/operator/node.go +++ b/cli/operator/node.go @@ -283,6 +283,7 @@ var StartNodeCmd = &cobra.Command{ if len(executionAddrList) == 1 { ec, err := executionclient.New( cmd.Context(), + executionclient.NewConfigFromNetwork(networkConfig), executionAddrList[0], ethcommon.HexToAddress(networkConfig.RegistryContractAddr), executionclient.WithLogger(logger), @@ -298,6 +299,7 @@ var StartNodeCmd = &cobra.Command{ } else { ec, err := executionclient.NewMulti( cmd.Context(), + executionclient.NewConfigFromNetwork(networkConfig), executionAddrList, ethcommon.HexToAddress(networkConfig.RegistryContractAddr), executionclient.WithLoggerMulti(logger), diff --git a/eth/ethtest/common_test.go b/eth/ethtest/common_test.go index 2452f6e190..a435c6ff49 100644 --- a/eth/ethtest/common_test.go +++ b/eth/ethtest/common_test.go @@ -15,6 +15,8 @@ import ( "go.uber.org/mock/gomock" "go.uber.org/zap/zaptest" + "github.com/ssvlabs/ssv/networkconfig" + "github.com/ssvlabs/ssv/eth/eventsyncer" "github.com/ssvlabs/ssv/eth/executionclient" "github.com/ssvlabs/ssv/eth/simulator" @@ -51,22 +53,20 @@ func NewCommonTestInput( } type TestEnv struct { - eventSyncer *eventsyncer.EventSyncer - validators []*testValidatorData - ops []*testOperator - nodeStorage storage.Storage - sim *simulator.Backend - boundContract *simcontract.Simcontract - auth *bind.TransactOpts - shares [][]byte - execClient *executionclient.ExecutionClient - rpcServer *rpc.Server - httpSrv *httptest.Server - validatorCtrl *mocks.MockController - mockCtrl *gomock.Controller - finalityBlocks uint64 - followDistance uint64 - finalityForkEpoch uint64 + eventSyncer *eventsyncer.EventSyncer + validators []*testValidatorData + ops []*testOperator + nodeStorage storage.Storage + sim *simulator.Backend + boundContract *simcontract.Simcontract + auth *bind.TransactOpts + shares [][]byte + execClient *executionclient.ExecutionClient + rpcServer *rpc.Server + httpSrv *httptest.Server + validatorCtrl *mocks.MockController + mockCtrl *gomock.Controller + execClientConfig executionclient.Config } func (e *TestEnv) shutdown() { @@ -90,16 +90,21 @@ func (e *TestEnv) setup( testAddresses []*ethcommon.Address, validatorsCount uint64, operatorsCount uint64, + useFinalityFork bool, ) error { - // Initialize defaults if not set - if e.finalityBlocks == 0 { - e.SetDefaultFinalityBlocks() - } - if e.followDistance == 0 { - e.SetDefaultFollowDistance() - } logger := zaptest.NewLogger(t) + // set up basic network/fork stuff + e.execClientConfig = executionclient.NewConfigFromNetwork(networkconfig.TestNetwork) + + if useFinalityFork { + // Post-fork config (use finality consensus) + e.execClientConfig = e.execClientConfig.WithFinalityConsensusEpoch(1) + } else { + // Pre-fork config (use follow distance) + e.execClientConfig = e.execClientConfig.WithFinalityConsensusEpoch(1000) + } + // Create operators RSA keys ops, err := createOperators(operatorsCount, 0) if err != nil { @@ -173,22 +178,12 @@ func (e *TestEnv) setup( return fmt.Errorf("contractCode is empty") } - // Create a client and connect to the simulator - execClientOpts := []executionclient.Option{ - executionclient.WithLogger(logger), - executionclient.WithFollowDistance(e.followDistance), - } - - // Apply finality fork settings if configured - if e.finalityForkEpoch < executionclient.FinalityForkEpoch { - execClientOpts = append(execClientOpts, executionclient.WithFinalityForkEpoch(e.finalityForkEpoch)) - } - e.execClient, err = executionclient.New( ctx, + e.execClientConfig, addr, contractAddr, - execClientOpts..., + executionclient.WithLogger(logger), ) if err != nil { return err @@ -221,30 +216,9 @@ func (e *TestEnv) setup( return nil } -// SetDefaultFinalityBlocks sets the default finality blocks. -func (e *TestEnv) SetDefaultFinalityBlocks() { - e.finalityBlocks = executionclient.FinalityDistance -} - -// SetDefaultFollowDistance sets the default follow distance. -func (e *TestEnv) SetDefaultFollowDistance() { - e.followDistance = executionclient.DefaultFollowDistance -} - -// EnableFinalityFork enables the finality fork at the specified epoch. -// Using a small epoch value enables finality, while the default high value effectively disables it. -func (e *TestEnv) EnableFinalityFork(epoch uint64) { - e.finalityForkEpoch = epoch -} - -// DisableFinalityFork disables the finality fork. -func (e *TestEnv) DisableFinalityFork() { - e.finalityForkEpoch = executionclient.FinalityForkEpoch -} - // MineAndFinalize mines enough blocks to ensure finality. func (e *TestEnv) MineAndFinalize(blockNum *uint64) { - for i := uint64(0); i < e.finalityBlocks; i++ { + for i := uint64(0); i < e.execClientConfig.SlotsPerEpoch*2; i++ { commitBlock(e.sim, blockNum) } } diff --git a/eth/ethtest/eth_e2e_test.go b/eth/ethtest/eth_e2e_test.go index 2cd2a531c2..a2d64defa7 100644 --- a/eth/ethtest/eth_e2e_test.go +++ b/eth/ethtest/eth_e2e_test.go @@ -39,7 +39,7 @@ func TestEthExecLayer_PostFork(t *testing.T) { // E2E tests for ETH package with configurable finality approach func runTestEthExecLayer(t *testing.T, useFinalityFork bool) { - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(t.Context()) defer cancel() testAddresses := make([]*ethcommon.Address, 2) @@ -57,21 +57,10 @@ func runTestEthExecLayer(t *testing.T, useFinalityFork bool) { expectedNonce := registrystorage.Nonce(0) testEnv := TestEnv{} - testEnv.SetDefaultFinalityBlocks() - testEnv.SetDefaultFollowDistance() - - if useFinalityFork { - // Enable finality fork at epoch 1 - testEnv.EnableFinalityFork(1) - t.Log("Running test with finality (post-fork)") // TODO: use the correct name when we know the name of the fork. - } else { - // Disable finality fork to use follow distance approach - testEnv.DisableFinalityFork() - t.Log("Running test with follow distance (pre-fork)") // TODO: use the correct name when we know the name of the fork. - } defer testEnv.shutdown() - err := testEnv.setup(t, ctx, testAddresses, 7, 4) + + err := testEnv.setup(t, ctx, testAddresses, 7, 4, useFinalityFork) require.NoError(t, err) var ( @@ -133,7 +122,7 @@ func runTestEthExecLayer(t *testing.T, useFinalityFork bool) { // When using follow distance, the last handled block is the current block minus follow distance currentBlock, err := testEnv.sim.Client().BlockNumber(ctx) require.NoError(t, err) - expectedLastHandledBlock = currentBlock - testEnv.followDistance + expectedLastHandledBlock = currentBlock - testEnv.execClientConfig.FollowDistance } // Run SyncHistory diff --git a/eth/eventhandler/event_handler_test.go b/eth/eventhandler/event_handler_test.go index 2c35257c16..0a2c63ef05 100644 --- a/eth/eventhandler/event_handler_test.go +++ b/eth/eventhandler/event_handler_test.go @@ -115,7 +115,11 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NotEmpty(t, contractCode) // Create a client and connect to the simulator - client, err := executionclient.New(ctx, addr, contractAddr, executionclient.WithLogger(logger), executionclient.WithFinalityForkEpoch(1)) + client, err := executionclient.New(ctx, + executionclient.NewConfigFromNetwork(networkconfig.TestNetwork), + addr, + contractAddr, + executionclient.WithLogger(logger)) require.NoError(t, err) contractFilterer, err := client.Filterer() @@ -159,7 +163,7 @@ func TestHandleBlockEventsStream(t *testing.T) { } sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } @@ -286,7 +290,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } @@ -341,7 +345,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } @@ -395,7 +399,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } @@ -454,7 +458,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } @@ -507,7 +511,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } @@ -561,7 +565,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } @@ -609,7 +613,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } @@ -639,7 +643,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } @@ -683,7 +687,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } @@ -722,7 +726,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } @@ -763,7 +767,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } @@ -805,7 +809,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } @@ -855,7 +859,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } @@ -902,7 +906,7 @@ func TestHandleBlockEventsStream(t *testing.T) { sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } @@ -967,7 +971,7 @@ func TestHandleBlockEventsStream(t *testing.T) { sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } @@ -1021,7 +1025,7 @@ func TestHandleBlockEventsStream(t *testing.T) { sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } @@ -1059,7 +1063,7 @@ func TestHandleBlockEventsStream(t *testing.T) { sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } @@ -1116,7 +1120,7 @@ func TestHandleBlockEventsStream(t *testing.T) { sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } @@ -1168,7 +1172,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } @@ -1248,7 +1252,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } @@ -1314,7 +1318,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } @@ -1349,7 +1353,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } @@ -1395,7 +1399,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } @@ -1430,7 +1434,7 @@ func TestHandleBlockEventsStream(t *testing.T) { require.NoError(t, err) sim.Commit() - for i := 0; i < executionclient.FinalityDistance; i++ { + for i := 0; i < 64; i++ { sim.Commit() } diff --git a/eth/eventsyncer/event_syncer_test.go b/eth/eventsyncer/event_syncer_test.go index 52f20ffe19..2e8119673f 100644 --- a/eth/eventsyncer/event_syncer_test.go +++ b/eth/eventsyncer/event_syncer_test.go @@ -87,7 +87,11 @@ func TestEventSyncer(t *testing.T) { require.NoError(t, err) addr := "ws:" + strings.TrimPrefix(httpSrv.URL, "http:") - client, err := executionclient.New(ctx, addr, contractAddr, executionclient.WithLogger(logger)) + client, err := executionclient.New(ctx, + executionclient.NewConfigFromNetwork(networkconfig.TestNetwork), + addr, + contractAddr, + executionclient.WithLogger(logger)) require.NoError(t, err) err = client.Healthy(ctx) diff --git a/eth/executionclient/config.go b/eth/executionclient/config.go index 63221935c3..40c402e7a6 100644 --- a/eth/executionclient/config.go +++ b/eth/executionclient/config.go @@ -2,6 +2,8 @@ package executionclient import ( "time" + + "github.com/ssvlabs/ssv/networkconfig" ) // TODO: rename eth1, consider combining with consensus client options @@ -12,3 +14,34 @@ type Options struct { ConnectionTimeout time.Duration `yaml:"ETH1ConnectionTimeout" env:"ETH_1_CONNECTION_TIMEOUT" env-default:"10s" env-description:"Timeout for execution client connections"` SyncDistanceTolerance uint64 `yaml:"ETH1SyncDistanceTolerance" env:"ETH_1_SYNC_DISTANCE_TOLERANCE" env-default:"5" env-description:"Maximum number of blocks behind head considered in-sync"` } + +type Config struct { + SlotsPerEpoch uint64 // Slots per epoch + FinalityConsensusEpoch uint64 // Epoch at which finality fork activates + FollowDistance uint64 // Number of blocks to follow behind head +} + +// NewConfigFromNetwork creates a new Config with network-specific values +// and default values for other parameters. +func NewConfigFromNetwork(networkCfg networkconfig.NetworkConfig) Config { + return Config{ + SlotsPerEpoch: networkCfg.SlotsPerEpoch(), + FinalityConsensusEpoch: networkCfg.FinalityConsensusEpoch, + FollowDistance: DefaultFollowDistance, + } +} + +func (c Config) WithSlotsPerEpoch(slots uint64) Config { + c.SlotsPerEpoch = slots + return c +} + +func (c Config) WithFinalityConsensusEpoch(epoch uint64) Config { + c.FinalityConsensusEpoch = epoch + return c +} + +func (c Config) WithFollowDistance(distance uint64) Config { + c.FollowDistance = distance + return c +} diff --git a/eth/executionclient/constants.go b/eth/executionclient/constants.go deleted file mode 100644 index e2c93a407e..0000000000 --- a/eth/executionclient/constants.go +++ /dev/null @@ -1,10 +0,0 @@ -package executionclient - -const ( - SlotsPerEpoch = 32 - FinalityDistance = SlotsPerEpoch * 2 - - // FinalityForkEpoch is the epoch at which the finality fork is active. - // TODO: This is a placeholder value and should be updated when the actual epoch is known. - FinalityForkEpoch = 120 -) diff --git a/eth/executionclient/execution_client.go b/eth/executionclient/execution_client.go index 1d967dc4b5..cc2c54a09a 100644 --- a/eth/executionclient/execution_client.go +++ b/eth/executionclient/execution_client.go @@ -59,6 +59,7 @@ const elResponseErrMsg = "Execution client returned an error" // ExecutionClient represents a client for interacting with Ethereum execution client. type ExecutionClient struct { // mandatory + config Config nodeAddr string contractAddress ethcommon.Address @@ -67,8 +68,6 @@ type ExecutionClient struct { connectionTimeout time.Duration healthInvalidationInterval time.Duration logBatchSize uint64 - followDistance uint64 // Follow distance for pre-finality fork - finalityForkEpoch uint64 // Epoch at which finality fork occurs syncDistanceTolerance uint64 syncProgressFn func(context.Context) (*ethereum.SyncProgress, error) @@ -81,16 +80,20 @@ type ExecutionClient struct { } // New creates a new instance of ExecutionClient. -func New(ctx context.Context, nodeAddr string, contractAddr ethcommon.Address, opts ...Option) (*ExecutionClient, error) { +func New(ctx context.Context, + config Config, + nodeAddr string, + contractAddr ethcommon.Address, + opts ...Option, +) (*ExecutionClient, error) { client := &ExecutionClient{ + config: config, nodeAddr: nodeAddr, contractAddress: contractAddr, logger: zap.NewNop(), connectionTimeout: DefaultConnectionTimeout, healthInvalidationInterval: DefaultHealthInvalidationInterval, logBatchSize: DefaultHistoricalLogsBatchSize, // TODO Make batch of logs adaptive depending on "websocket: read limit" - followDistance: DefaultFollowDistance, - finalityForkEpoch: FinalityForkEpoch, closed: make(chan struct{}), } for _, opt := range opts { @@ -148,9 +151,9 @@ func (ec *ExecutionClient) FetchHistoricalLogs(ctx context.Context, fromBlock ui } // Check if we're past the fork - currentEpoch := currentBlock / SlotsPerEpoch + currentEpoch := currentBlock / ec.config.SlotsPerEpoch - if currentEpoch > ec.finalityForkEpoch { + if currentEpoch > ec.config.FinalityConsensusEpoch { // Just passed the fork threshold ec.isPostForkState.Store(true) toBlock, err = ec.getFinalizedBlock(ctx) @@ -163,10 +166,10 @@ func (ec *ExecutionClient) FetchHistoricalLogs(ctx context.Context, fromBlock ui } } else { // Pre-fork: use follow distance - if currentBlock < ec.followDistance { + if currentBlock < ec.config.FollowDistance { return nil, nil, ErrNothingToSync } - toBlock = currentBlock - ec.followDistance + toBlock = currentBlock - ec.config.FollowDistance } } @@ -394,7 +397,7 @@ func (ec *ExecutionClient) healthy(ctx context.Context) error { return err } - if currentBlock/SlotsPerEpoch > ec.finalityForkEpoch { + if currentBlock/ec.config.SlotsPerEpoch > ec.config.FinalityConsensusEpoch { ec.isPostForkState.Store(true) _, err := ec.getFinalizedBlock(ctx) if err != nil { @@ -521,7 +524,7 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo toBlock = finalizedBlock if toBlock != lastFinalized { - finalizedEpoch := toBlock / SlotsPerEpoch + finalizedEpoch := toBlock / ec.config.SlotsPerEpoch ec.logger.Info("⏱ finalized block changed", zap.Uint64("new_finalized", toBlock), zap.Uint64("estimated_epoch", finalizedEpoch), @@ -530,9 +533,9 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo } } else { // Check if we need to transition to post-fork - currentEpoch := headerNum / SlotsPerEpoch + currentEpoch := headerNum / ec.config.SlotsPerEpoch - if currentEpoch > ec.finalityForkEpoch { + if currentEpoch > ec.config.FinalityConsensusEpoch { ec.isPostForkState.Store(true) finalizedBlock, err := ec.getFinalizedBlock(ctx) if err != nil { @@ -542,10 +545,10 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo toBlock = finalizedBlock } else { // Pre-fork: follow distance approach - if headerNum < ec.followDistance { + if headerNum < ec.config.FollowDistance { continue } - toBlock = headerNum - ec.followDistance + toBlock = headerNum - ec.config.FollowDistance } } @@ -601,14 +604,14 @@ func (ec *ExecutionClient) IsFinalizedFork(ctx context.Context) bool { return false } - currentEpoch := currentBlock / SlotsPerEpoch + currentEpoch := currentBlock / ec.config.SlotsPerEpoch // Check if we've passed the fork point - if currentEpoch > ec.finalityForkEpoch { + if currentEpoch > ec.config.FinalityConsensusEpoch { ec.isPostForkState.Store(true) ec.logger.Info("finality fork threshold passed, using finalized blocks", zap.Uint64("current_epoch", currentEpoch), - zap.Uint64("finality_fork_epoch", ec.finalityForkEpoch)) + zap.Uint64("finality_fork_epoch", ec.config.FinalityConsensusEpoch)) return true } diff --git a/eth/executionclient/execution_client_test.go b/eth/executionclient/execution_client_test.go index ae919bd82a..8c02c4ebd6 100644 --- a/eth/executionclient/execution_client_test.go +++ b/eth/executionclient/execution_client_test.go @@ -23,6 +23,8 @@ import ( "go.uber.org/zap" "go.uber.org/zap/zaptest" + "github.com/ssvlabs/ssv/networkconfig" + "github.com/ssvlabs/ssv/eth/simulator" "github.com/ssvlabs/ssv/eth/simulator/simcontract" ) @@ -61,8 +63,8 @@ func simTestBackend(testAddr ethcommon.Address) *simulator.Backend { // testEnv is a helper struct to set up and manage test environment. type testEnv struct { - ctx context.Context t *testing.T + ctx context.Context sim *simulator.Backend rpcServer *httptest.Server wsURL string @@ -73,7 +75,7 @@ type testEnv struct { // setupTestEnv creates a new test environment with simulators, contracts, and clients' setup. func setupTestEnv(t *testing.T, testTimeout time.Duration) *testEnv { - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) t.Cleanup(cancel) // Create simulator instance @@ -93,8 +95,8 @@ func setupTestEnv(t *testing.T, testTimeout time.Duration) *testEnv { auth, _ := bind.NewKeyedTransactorWithChainID(testKey, big.NewInt(1337)) return &testEnv{ - ctx: ctx, t: t, + ctx: ctx, sim: sim, rpcServer: httpsrv, wsURL: wsURL, @@ -122,16 +124,16 @@ func (env *testEnv) deployCallableContract() (*bind.BoundContract, error) { } // createClient creates and validates a new execution client with given options. -func (env *testEnv) createClient(options ...Option) error { - return env.createClientWithCleanup(true, options...) +func (env *testEnv) createClient(cfg Config, options ...Option) error { + return env.createClientWithCleanup(true, cfg, options...) } // createClientWithCleanup creates and initializes an execution client, optionally registering it for cleanup. // If registerCleanup is false, the caller is responsible for closing the client. -func (env *testEnv) createClientWithCleanup(registerCleanup bool, options ...Option) error { +func (env *testEnv) createClientWithCleanup(registerCleanup bool, cfg Config, options ...Option) error { allOptions := append([]Option{}, options...) var err error - env.client, err = New(env.ctx, env.wsURL, env.contractAddr, allOptions...) + env.client, err = New(env.ctx, cfg, env.wsURL, env.contractAddr, allOptions...) if err != nil { return err } @@ -157,9 +159,9 @@ func (env *testEnv) createBlocksWithLogs(contract *bind.BoundContract, count int return nil } -// finalize mines 64 blocks (FinalityDistance) to simulate proper finalization (2 epochs). +// finalize mines 64 blocks to simulate proper finalization (2 epochs). func (env *testEnv) finalize() { - for i := 0; i < FinalityDistance; i++ { + for i := 0; i < 64; i++ { env.sim.Commit() } } @@ -175,9 +177,9 @@ func TestFetchHistoricalLogs(t *testing.T) { // Create a client and connect to the simulator with finality fork enabled err = env.createClient( + NewConfigFromNetwork(networkconfig.TestNetwork), WithLogger(logger), WithConnectionTimeout(2*time.Second), - WithFinalityForkEpoch(1), // Enable finality fork ) require.NoError(t, err) @@ -214,10 +216,9 @@ func TestFetchHistoricalLogs(t *testing.T) { // Create a client with finality fork disabled (using follow distance) const followDistance = 8 err = env.createClient( + NewConfigFromNetwork(networkconfig.TestNetwork).WithFollowDistance(followDistance), WithLogger(logger), WithConnectionTimeout(2*time.Second), - WithFollowDistance(followDistance), - WithFinalityForkEpoch(FinalityForkEpoch), ) require.NoError(t, err) @@ -254,10 +255,9 @@ func TestFetchHistoricalLogs(t *testing.T) { // Create a client with a large followDistance and finality fork disabled const followDistance = 100 // Much larger than the current block number err = env.createClient( + NewConfigFromNetwork(networkconfig.TestNetwork).WithFollowDistance(followDistance), WithLogger(logger), WithConnectionTimeout(2*time.Second), - WithFollowDistance(followDistance), - WithFinalityForkEpoch(FinalityForkEpoch), ) require.NoError(t, err) @@ -276,10 +276,9 @@ func TestFetchHistoricalLogs(t *testing.T) { // Create a client with finality fork disabled const followDistance = 8 err = env.createClient( + NewConfigFromNetwork(networkconfig.TestNetwork).WithFollowDistance(followDistance), WithLogger(logger), WithConnectionTimeout(2*time.Second), - WithFollowDistance(followDistance), - WithFinalityForkEpoch(FinalityForkEpoch), ) require.NoError(t, err) @@ -307,8 +306,8 @@ func TestFetchHistoricalLogs(t *testing.T) { // Create a client - connection should succeed initially err = env.createClient( + NewConfigFromNetwork(networkconfig.TestNetwork), WithLogger(logger), - WithFollowDistance(8), WithConnectionTimeout(100*time.Millisecond), ) require.NoError(t, err) // Connection is established initially @@ -338,7 +337,9 @@ func TestStreamLogs(t *testing.T) { require.NoError(t, err) // Create a client and connect to the simulator with finality fork enabled - err = env.createClient(WithLogger(logger), WithFinalityForkEpoch(1)) + err = env.createClient( + NewConfigFromNetwork(networkconfig.TestNetwork), + WithLogger(logger)) require.NoError(t, err) logsCh := env.client.StreamLogs(env.ctx, 0) @@ -387,7 +388,9 @@ func TestStreamLogs(t *testing.T) { // Create a client with explicit follow distance and disabled finality fork const followDistance = 2 - err = env.createClient(WithLogger(logger), WithFollowDistance(followDistance), WithFinalityForkEpoch(FinalityForkEpoch)) + err = env.createClient( + NewConfigFromNetwork(networkconfig.TestNetwork).WithFollowDistance(followDistance), + WithLogger(logger)) require.NoError(t, err) logsCh := env.client.StreamLogs(env.ctx, 0) @@ -446,7 +449,9 @@ func TestStreamLogs(t *testing.T) { require.NoError(t, err) // Create a client and connect to the simulator - err = env.createClient(WithLogger(logger)) + err = env.createClient( + NewConfigFromNetwork(networkconfig.TestNetwork), + WithLogger(logger)) require.NoError(t, err) // Use a cancelable context @@ -481,7 +486,9 @@ func TestStreamLogs(t *testing.T) { require.NoError(t, err) // Create a client without automatic cleanup - err = env.createClientWithCleanup(false, WithLogger(logger)) + err = env.createClientWithCleanup(false, + NewConfigFromNetwork(networkconfig.TestNetwork), + WithLogger(logger)) require.NoError(t, err) logsCh := env.client.StreamLogs(env.ctx, 0) @@ -512,7 +519,9 @@ func TestFetchLogsInBatches(t *testing.T) { contract, err := env.deployCallableContract() require.NoError(t, err) - err = env.createClient(WithLogger(logger), WithLogBatchSize(2)) + err = env.createClient( + NewConfigFromNetwork(networkconfig.TestNetwork), + WithLogger(logger), WithLogBatchSize(2)) require.NoError(t, err) // Create blocks with transactions @@ -604,7 +613,9 @@ func TestChainReorganizationLogs(t *testing.T) { require.NoError(t, err) // 2. Create a client and set up subscription with finality fork enabled - err = env.createClient(WithLogger(logger), WithFinalityForkEpoch(1)) + err = env.createClient( + NewConfigFromNetwork(networkconfig.TestNetwork), + WithLogger(logger)) require.NoError(t, err) currentBlock, err := env.sim.Client().BlockNumber(env.ctx) @@ -705,9 +716,8 @@ func TestChainReorganizationLogs(t *testing.T) { // 2. Create a client with follow distance mechanism (finality fork disabled) const followDistance = 5 err = env.createClient( + NewConfigFromNetwork(networkconfig.TestNetwork).WithFollowDistance(followDistance), WithLogger(logger), - WithFollowDistance(followDistance), - WithFinalityForkEpoch(FinalityForkEpoch), ) require.NoError(t, err) @@ -837,7 +847,9 @@ func TestSimSSV(t *testing.T) { require.NoError(t, err) // Create a client and connect to the simulator with finality fork enabled - err = env.createClient(WithLogger(logger), WithFinalityForkEpoch(1)) + err = env.createClient( + NewConfigFromNetwork(networkconfig.TestNetwork), + WithLogger(logger)) require.NoError(t, err) logs := env.client.StreamLogs(env.ctx, 0) @@ -906,9 +918,8 @@ func TestSimSSV(t *testing.T) { // Create a client and connect to the simulator with follow distance const followDistance = 2 err = env.createClient( + NewConfigFromNetwork(networkconfig.TestNetwork).WithFollowDistance(followDistance), WithLogger(logger), - WithFollowDistance(followDistance), - WithFinalityForkEpoch(FinalityForkEpoch), ) require.NoError(t, err) @@ -989,7 +1000,9 @@ func TestFilterLogs(t *testing.T) { require.NoError(t, err) // Create a client and connect to the simulator - err = env.createClient(WithLogger(logger)) + err = env.createClient( + NewConfigFromNetwork(networkconfig.TestNetwork), + WithLogger(logger)) require.NoError(t, err) // Create blocks with transactions @@ -1023,6 +1036,7 @@ func TestFilterLogs(t *testing.T) { // Create a client - connection should succeed initially err = env.createClient( + NewConfigFromNetwork(networkconfig.TestNetwork), WithLogger(logger), WithConnectionTimeout(100*time.Millisecond), ) @@ -1055,7 +1069,9 @@ func TestSubscribeFilterLogs(t *testing.T) { require.NoError(t, err) // Create a client and connect to the simulator - err = env.createClient(WithLogger(logger)) + err = env.createClient( + NewConfigFromNetwork(networkconfig.TestNetwork), + WithLogger(logger)) require.NoError(t, err) // Set up a channel to receive logs @@ -1116,6 +1132,7 @@ func TestSubscribeFilterLogs(t *testing.T) { // Create a client - connection should succeed initially err = env.createClient( + NewConfigFromNetwork(networkconfig.TestNetwork), WithLogger(logger), WithConnectionTimeout(100*time.Millisecond), ) @@ -1150,7 +1167,9 @@ func TestBlockByNumber(t *testing.T) { require.NoError(t, err) // Create a client and connect to the simulator - err = env.createClient(WithLogger(logger)) + err = env.createClient( + NewConfigFromNetwork(networkconfig.TestNetwork), + WithLogger(logger)) require.NoError(t, err) // Finalize the blocks @@ -1180,6 +1199,7 @@ func TestBlockByNumber(t *testing.T) { // Create a client - connection should succeed initially err = env.createClient( + NewConfigFromNetwork(networkconfig.TestNetwork), WithLogger(logger), WithConnectionTimeout(100*time.Millisecond), ) @@ -1208,7 +1228,9 @@ func TestHeaderByNumber(t *testing.T) { require.NoError(t, err) // Create a client and connect to the simulator - err = env.createClient(WithLogger(logger)) + err = env.createClient( + NewConfigFromNetwork(networkconfig.TestNetwork), + WithLogger(logger)) require.NoError(t, err) // Finalize the blocks @@ -1238,6 +1260,7 @@ func TestHeaderByNumber(t *testing.T) { // Create a client - connection should succeed initially err = env.createClient( + NewConfigFromNetwork(networkconfig.TestNetwork), WithLogger(logger), WithConnectionTimeout(100*time.Millisecond), ) @@ -1264,7 +1287,9 @@ func TestFilterer(t *testing.T) { require.NoError(t, err) // Create a client and connect to the simulator - err = env.createClient(WithLogger(logger)) + err = env.createClient( + NewConfigFromNetwork(networkconfig.TestNetwork), + WithLogger(logger)) require.NoError(t, err) // Test the Filterer method @@ -1282,7 +1307,9 @@ func TestSyncProgress(t *testing.T) { require.NoError(t, err) // Create a client and connect to the simulator - err = env.createClient(WithHealthInvalidationInterval(0)) + err = env.createClient( + NewConfigFromNetwork(networkconfig.TestNetwork), + WithHealthInvalidationInterval(0)) require.NoError(t, err) err = env.client.Healthy(env.ctx) @@ -1302,6 +1329,7 @@ func TestSyncProgress(t *testing.T) { t.Run("within tolerable limits", func(t *testing.T) { client, err := New( env.ctx, + NewConfigFromNetwork(networkconfig.TestNetwork), env.wsURL, env.contractAddr, WithSyncDistanceTolerance(2), @@ -1328,7 +1356,9 @@ func TestHealthy(t *testing.T) { require.NoError(t, err) // Create a client and connect to the simulator - err = env.createClientWithCleanup(false) + err = env.createClientWithCleanup(false, + NewConfigFromNetwork(networkconfig.TestNetwork), + ) require.NoError(t, err) // Close the client using our safe method @@ -1345,7 +1375,9 @@ func TestHealthy(t *testing.T) { require.NoError(t, err) // Create a client with a health invalidation interval - err = env.createClient(WithHealthInvalidationInterval(10 * time.Second)) + err = env.createClient( + NewConfigFromNetwork(networkconfig.TestNetwork), + WithHealthInvalidationInterval(10*time.Second)) require.NoError(t, err) // First call to Healthy should perform the actual health check diff --git a/eth/executionclient/multi_client.go b/eth/executionclient/multi_client.go index 4be167c66f..200d53e633 100644 --- a/eth/executionclient/multi_client.go +++ b/eth/executionclient/multi_client.go @@ -52,14 +52,14 @@ var _ Provider = &MultiClient{} // The execution MultiClient switches to EL2, the consensus multi client switches to CL2, // This shouldn't cause significant duty misses. type MultiClient struct { + config Config + // optional logger *zap.Logger connectionTimeout time.Duration healthInvalidationInterval time.Duration logBatchSize uint64 syncDistanceTolerance uint64 - followDistance uint64 // Follow distance for pre-finality fork - finalityForkEpoch uint64 // Epoch at which finality fork occurred TODO: use a proper name contractAddress ethcommon.Address chainID atomic.Pointer[big.Int] @@ -75,6 +75,7 @@ type MultiClient struct { // NewMulti creates a new instance of MultiClient. func NewMulti( ctx context.Context, + config Config, nodeAddrs []string, contractAddr ethcommon.Address, opts ...OptionMulti, @@ -84,6 +85,7 @@ func NewMulti( } multiClient := &MultiClient{ + config: config, nodeAddrs: nodeAddrs, clients: make([]SingleClientProvider, len(nodeAddrs)), // initialized with nil values (not connected) clientsMu: make([]sync.Mutex, len(nodeAddrs)), @@ -91,8 +93,6 @@ func NewMulti( logger: zap.NewNop(), connectionTimeout: DefaultConnectionTimeout, logBatchSize: DefaultHistoricalLogsBatchSize, - followDistance: DefaultFollowDistance, - finalityForkEpoch: FinalityForkEpoch, } for _, opt := range opts { @@ -148,14 +148,13 @@ func (mc *MultiClient) connect(ctx context.Context, clientIndex int) error { singleClient, err := New( ctx, + mc.config, mc.nodeAddrs[clientIndex], mc.contractAddress, WithLogger(logger), WithConnectionTimeout(mc.connectionTimeout), WithHealthInvalidationInterval(mc.healthInvalidationInterval), WithSyncDistanceTolerance(mc.syncDistanceTolerance), - WithFollowDistance(mc.followDistance), - WithFinalityForkEpoch(mc.finalityForkEpoch), ) if err != nil { recordClientInitStatus(ctx, mc.nodeAddrs[clientIndex], false) diff --git a/eth/executionclient/multi_client_test.go b/eth/executionclient/multi_client_test.go index 07b7bb97a6..e8ba4ae927 100644 --- a/eth/executionclient/multi_client_test.go +++ b/eth/executionclient/multi_client_test.go @@ -18,13 +18,15 @@ import ( "go.uber.org/mock/gomock" "go.uber.org/zap" "go.uber.org/zap/zapcore" + + "github.com/ssvlabs/ssv/networkconfig" ) func TestNewMulti(t *testing.T) { t.Run("no node addresses", func(t *testing.T) { ctx := context.Background() - mc, err := NewMulti(ctx, []string{}, ethcommon.Address{}) + mc, err := NewMulti(ctx, NewConfigFromNetwork(networkconfig.TestNetwork), []string{}, ethcommon.Address{}) require.Nil(t, mc, "MultiClient should be nil on error") require.Error(t, err, "expected an error due to no node addresses") @@ -36,7 +38,7 @@ func TestNewMulti(t *testing.T) { addr := "invalid-addr" addresses := []string{addr} - mc, err := NewMulti(ctx, addresses, ethcommon.Address{}) + mc, err := NewMulti(ctx, NewConfigFromNetwork(networkconfig.TestNetwork), addresses, ethcommon.Address{}) require.Nil(t, mc, "MultiClient should be nil on error") require.Error(t, err) @@ -68,10 +70,10 @@ func TestNewMulti_WithOptions(t *testing.T) { t.Run("pre-fork (follow distance)", func(t *testing.T) { mc, err := NewMulti( ctx, + NewConfigFromNetwork(networkconfig.TestNetwork).WithFollowDistance(customFollowDistance), addresses, contractAddr, WithLoggerMulti(customLogger), - WithFollowDistanceMulti(customFollowDistance), WithConnectionTimeoutMulti(customTimeout), WithHealthInvalidationIntervalMulti(customHealthInvalidationInterval), WithLogBatchSizeMulti(customLogBatchSize), @@ -80,12 +82,11 @@ func TestNewMulti_WithOptions(t *testing.T) { require.NoError(t, err) require.NotNil(t, mc) require.Equal(t, customLogger.Named("execution_client_multi"), mc.logger) - require.EqualValues(t, customFollowDistance, mc.followDistance) + require.EqualValues(t, customFollowDistance, mc.config.FollowDistance) require.EqualValues(t, customTimeout, mc.connectionTimeout) require.EqualValues(t, customHealthInvalidationInterval, mc.healthInvalidationInterval) require.EqualValues(t, customLogBatchSize, mc.logBatchSize) require.EqualValues(t, customSyncDistanceTolerance, mc.syncDistanceTolerance) - require.EqualValues(t, FinalityForkEpoch, mc.finalityForkEpoch) // Default - high epoch effectively disables finality }) t.Run("post-fork (finality)", func(t *testing.T) { @@ -93,6 +94,7 @@ func TestNewMulti_WithOptions(t *testing.T) { mc, err := NewMulti( ctx, + NewConfigFromNetwork(networkconfig.TestNetwork).WithFinalityConsensusEpoch(customFinalityForkEpoch), addresses, contractAddr, WithLoggerMulti(customLogger), @@ -100,7 +102,6 @@ func TestNewMulti_WithOptions(t *testing.T) { WithHealthInvalidationIntervalMulti(customHealthInvalidationInterval), WithLogBatchSizeMulti(customLogBatchSize), WithSyncDistanceToleranceMulti(customSyncDistanceTolerance), - WithFinalityForkEpochMulti(customFinalityForkEpoch), ) require.NoError(t, err) require.NotNil(t, mc) @@ -109,8 +110,7 @@ func TestNewMulti_WithOptions(t *testing.T) { require.EqualValues(t, customHealthInvalidationInterval, mc.healthInvalidationInterval) require.EqualValues(t, customLogBatchSize, mc.logBatchSize) require.EqualValues(t, customSyncDistanceTolerance, mc.syncDistanceTolerance) - require.EqualValues(t, customFinalityForkEpoch, mc.finalityForkEpoch) - require.EqualValues(t, DefaultFollowDistance, mc.followDistance) + require.EqualValues(t, customFinalityForkEpoch, mc.config.FinalityConsensusEpoch) }) } diff --git a/eth/executionclient/options.go b/eth/executionclient/options.go index 33f16f6b95..d58d031d01 100644 --- a/eth/executionclient/options.go +++ b/eth/executionclient/options.go @@ -81,35 +81,3 @@ func WithSyncDistanceToleranceMulti(count uint64) OptionMulti { c.syncDistanceTolerance = count } } - -// WithFollowDistance sets finalization offset (a block at this offset into the past -// from the head block will be considered as very likely finalized). -func WithFollowDistance(offset uint64) Option { - return func(c *ExecutionClient) { - c.followDistance = offset - } -} - -// WithFollowDistanceMulti sets finalization offset (a block at this offset into the past -// from the head block will be considered as very likely finalized). -func WithFollowDistanceMulti(offset uint64) OptionMulti { - return func(c *MultiClient) { - c.followDistance = offset - } -} - -// WithFinalityForkEpoch sets the epoch at which to switch from follow distance to finality. -// TODO: use the correct name when we know the name of the fork. -func WithFinalityForkEpoch(epoch uint64) Option { - return func(c *ExecutionClient) { - c.finalityForkEpoch = epoch - } -} - -// WithFinalityForkEpochMulti sets the epoch at which to switch from follow distance to finality. -// TODO: use the correct name when we know the name of the fork. -func WithFinalityForkEpochMulti(epoch uint64) OptionMulti { - return func(c *MultiClient) { - c.finalityForkEpoch = epoch - } -} diff --git a/networkconfig/config.go b/networkconfig/config.go index 6d290ed244..1f31763c3f 100644 --- a/networkconfig/config.go +++ b/networkconfig/config.go @@ -17,8 +17,6 @@ var SupportedConfigs = map[string]NetworkConfig{ Sepolia.Name: Sepolia, } -const forkName = "alan" - func GetNetworkConfigByName(name string) (NetworkConfig, error) { if network, ok := SupportedConfigs[name]; ok { return network, nil @@ -31,6 +29,7 @@ type NetworkConfig struct { Name string BeaconConfig SSVConfig + ForksConfig } func (n NetworkConfig) String() string { @@ -43,7 +42,7 @@ func (n NetworkConfig) String() string { } func (n NetworkConfig) NetworkName() string { - return fmt.Sprintf("%s:%s", n.Name, forkName) + return fmt.Sprintf("%s:%s", n.Name, "alan") } // ForkVersion returns the fork version of the network. diff --git a/networkconfig/forks.go b/networkconfig/forks.go new file mode 100644 index 0000000000..7b185a1fe8 --- /dev/null +++ b/networkconfig/forks.go @@ -0,0 +1,30 @@ +package networkconfig + +// Fork is a numerical identifier of specific network upgrades (forks). +type Fork int + +const ( + Alan Fork = iota + FinalityConsensus // TODO: use a different name when we have a better one +) + +// String implements fmt.Stringer. +func (f Fork) String() string { + s, ok := forkToString[f] + if !ok { + return "Unknown fork" + } + return s +} + +var forkToString = map[Fork]string{ + Alan: "Alan", + FinalityConsensus: "Finality Consensus", // TODO: use a different name when we have a better one +} + +// ForksConfig contains the epoch numbers for different protocol forks. +type ForksConfig struct { + // FinalityConsensusEpoch is the epoch at which the network switches + // to using finalized blocks for determining log fetching ranges and health checks. + FinalityConsensusEpoch uint64 // TODO: use a different name when we have a better one +} diff --git a/networkconfig/hoodi.go b/networkconfig/hoodi.go index 24292d2251..c25c830d1a 100644 --- a/networkconfig/hoodi.go +++ b/networkconfig/hoodi.go @@ -23,4 +23,7 @@ var Hoodi = NetworkConfig{ "enr:-Ja4QIKlyNFuFtTOnVoavqwmpgSJXfhSmhpdSDOUhf5-FBr7bBxQRvG6VrpUvlkr8MtpNNuMAkM33AseduSaOhd9IeWGAZWjRbnvgmlkgnY0gmlwhCNVVTCJc2VjcDI1NmsxoQNTTyiJPoZh502xOZpHSHAfR-94NaXLvi5J4CNHMh2tjoNzc3YBg3RjcIITioN1ZHCCD6I", }, }, + ForksConfig: ForksConfig{ + FinalityConsensusEpoch: 120, // TODO: use a different name when we have a better one, value as well + }, } diff --git a/networkconfig/mainnet.go b/networkconfig/mainnet.go index e8ce291a42..b20e639e47 100644 --- a/networkconfig/mainnet.go +++ b/networkconfig/mainnet.go @@ -32,4 +32,7 @@ var Mainnet = NetworkConfig{ "enr:-Li4QH7FwJcL8gJj0zHAITXqghMkG-A5bfWh2-3Q7vosy9D1BS8HZk-1ITuhK_rfzG3v_UtBDI6uNJZWpdcWfrQFCxKGAYnQ1DRCh2F0dG5ldHOIAAAAAAAAAACEZXRoMpD1pf1CAAAAAP__________gmlkgnY0gmlwhBLb3g2Jc2VjcDI1NmsxoQKeSDcZWSaY9FC723E9yYX1Li18bswhLNlxBZdLfgOKp4N0Y3CCE4mDdWRwgg-h", }, }, + ForksConfig: ForksConfig{ + FinalityConsensusEpoch: 120, // TODO: use a different name when we have a better one, value as well + }, } diff --git a/networkconfig/sepolia.go b/networkconfig/sepolia.go index cc05bfb9d4..9b824aa661 100644 --- a/networkconfig/sepolia.go +++ b/networkconfig/sepolia.go @@ -23,4 +23,7 @@ var Sepolia = NetworkConfig{ "enr:-Ja4QIE0Ml0a8Pq9zD-0g9KYGN3jAMPJ0CAP0i16fK-PSHfLeORl-Z5p8odoP1oS5S2E8IsF5jNG7gqTKhjVsHR-Z_CGAZXrnTJrgmlkgnY0gmlwhCOjXGWJc2VjcDI1NmsxoQKCRDQsIdFsJDmu_ZU2H6b2_HRJbuUneDXHLfFkSQH9O4Nzc3YBg3RjcIITioN1ZHCCD6I", }, }, + ForksConfig: ForksConfig{ + FinalityConsensusEpoch: 120, // TODO: use a different name when we have a better one, value as well + }, } diff --git a/networkconfig/test-network.go b/networkconfig/test-network.go index bed1b4e5f5..9dafb6d1bc 100644 --- a/networkconfig/test-network.go +++ b/networkconfig/test-network.go @@ -21,4 +21,7 @@ var TestNetwork = NetworkConfig{ "enr:-Li4QFIQzamdvTxGJhvcXG_DFmCeyggSffDnllY5DiU47pd_K_1MRnSaJimWtfKJ-MD46jUX9TwgW5Jqe0t4pH41RYWGAYuFnlyth2F0dG5ldHOIAAAAAAAAAACEZXRoMpD1pf1CAAAAAP__________gmlkgnY0gmlwhCLdu_SJc2VjcDI1NmsxoQN4v-N9zFYwEqzGPBBX37q24QPFvAVUtokIo1fblIsmTIN0Y3CCE4uDdWRwgg-j", }, }, + ForksConfig: ForksConfig{ + FinalityConsensusEpoch: 1, // TODO: use a different name when we have a better one, value as well + }, } From cf357f7237daa9e9bcd82030708890e1f7a1ee39 Mon Sep 17 00:00:00 2001 From: kchojn Date: Wed, 21 May 2025 16:29:19 +0700 Subject: [PATCH 38/53] feat(executionclient): add support for phase0.Epoch type in Config struct to improve type safety and clarity feat(executionclient): update WithFinalityConsensusEpoch method in Config struct to accept phase0.Epoch type for consistency feat(executionclient): update currentEpoch calculations in execution_client.go to use phase0.Epoch type for better type handling feat(executionclient): update finalizedEpoch calculations in execution_client.go to use uint64 conversion for better consistency feat(executionclient): update currentEpoch calculations in IsFinalizedFork method to use phase0.Epoch type for better type handling feat(multi_client_test): update customFinalityForkEpoch variable to use phase0.Epoch type for consistency feat(networkconfig): remove ForksConfig struct and related code as it is no longer needed feat(networkconfig): update Forks field in NetworkConfig struct to use SSVForkConfig for better organization and clarity feat(networkconfig): update Forks field in Hoodi, Mainnet, Sepolia, and TestNetwork configurations to use SSVForkConfig for better organization feat(networkconfig): add SSVForkConfig struct to handle SSV protocol forks feat(networkconfig): add SSVForks struct to handle a list of SSV protocol forks feat(networkconfig): add SSVFork struct to describe a single SSV protocol fork feat(networkconfig): add ActiveFork, FindByName, IsForkActive methods to SSVForks struct for better fork management feat(networkconfig): add ActiveFork, FindForkByName, IsForkActive methods to SSVForkConfig struct for better fork management --- eth/executionclient/config.go | 19 +++-- eth/executionclient/execution_client.go | 19 +++-- eth/executionclient/multi_client_test.go | 3 +- networkconfig/config.go | 1 - networkconfig/forks.go | 30 -------- networkconfig/hoodi.go | 15 +++- networkconfig/mainnet.go | 15 +++- networkconfig/sepolia.go | 15 +++- networkconfig/ssv.go | 2 + networkconfig/ssv_forks.go | 96 ++++++++++++++++++++++++ networkconfig/test-network.go | 15 +++- 11 files changed, 173 insertions(+), 57 deletions(-) delete mode 100644 networkconfig/forks.go create mode 100644 networkconfig/ssv_forks.go diff --git a/eth/executionclient/config.go b/eth/executionclient/config.go index 40c402e7a6..7638d45cb6 100644 --- a/eth/executionclient/config.go +++ b/eth/executionclient/config.go @@ -3,6 +3,8 @@ package executionclient import ( "time" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/ssvlabs/ssv/networkconfig" ) @@ -16,17 +18,24 @@ type Options struct { } type Config struct { - SlotsPerEpoch uint64 // Slots per epoch - FinalityConsensusEpoch uint64 // Epoch at which finality fork activates - FollowDistance uint64 // Number of blocks to follow behind head + SlotsPerEpoch uint64 // Slots per epoch + FinalityConsensusEpoch phase0.Epoch // Epoch at which finality fork activates + FollowDistance uint64 // Number of blocks to follow behind head } // NewConfigFromNetwork creates a new Config with network-specific values // and default values for other parameters. func NewConfigFromNetwork(networkCfg networkconfig.NetworkConfig) Config { + var finalityConsensusEpoch phase0.Epoch + + finalityConsensusFork := networkCfg.Forks.FindForkByName("Finality Consensus") + if finalityConsensusFork != nil { + finalityConsensusEpoch = finalityConsensusFork.Epoch + } + return Config{ SlotsPerEpoch: networkCfg.SlotsPerEpoch(), - FinalityConsensusEpoch: networkCfg.FinalityConsensusEpoch, + FinalityConsensusEpoch: finalityConsensusEpoch, FollowDistance: DefaultFollowDistance, } } @@ -36,7 +45,7 @@ func (c Config) WithSlotsPerEpoch(slots uint64) Config { return c } -func (c Config) WithFinalityConsensusEpoch(epoch uint64) Config { +func (c Config) WithFinalityConsensusEpoch(epoch phase0.Epoch) Config { c.FinalityConsensusEpoch = epoch return c } diff --git a/eth/executionclient/execution_client.go b/eth/executionclient/execution_client.go index cc2c54a09a..ac70b99d78 100644 --- a/eth/executionclient/execution_client.go +++ b/eth/executionclient/execution_client.go @@ -18,6 +18,8 @@ import ( semconv "go.opentelemetry.io/otel/semconv/v1.26.0" "go.uber.org/zap" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/ssvlabs/ssv/eth/contract" "github.com/ssvlabs/ssv/logging/fields" "github.com/ssvlabs/ssv/observability" @@ -151,7 +153,7 @@ func (ec *ExecutionClient) FetchHistoricalLogs(ctx context.Context, fromBlock ui } // Check if we're past the fork - currentEpoch := currentBlock / ec.config.SlotsPerEpoch + currentEpoch := phase0.Epoch(currentBlock / ec.config.SlotsPerEpoch) if currentEpoch > ec.config.FinalityConsensusEpoch { // Just passed the fork threshold @@ -397,7 +399,8 @@ func (ec *ExecutionClient) healthy(ctx context.Context) error { return err } - if currentBlock/ec.config.SlotsPerEpoch > ec.config.FinalityConsensusEpoch { + currentEpoch := phase0.Epoch(currentBlock / ec.config.SlotsPerEpoch) + if currentEpoch > ec.config.FinalityConsensusEpoch { ec.isPostForkState.Store(true) _, err := ec.getFinalizedBlock(ctx) if err != nil { @@ -524,16 +527,16 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logs chan<- Blo toBlock = finalizedBlock if toBlock != lastFinalized { - finalizedEpoch := toBlock / ec.config.SlotsPerEpoch + finalizedEpoch := phase0.Epoch(toBlock / ec.config.SlotsPerEpoch) ec.logger.Info("⏱ finalized block changed", zap.Uint64("new_finalized", toBlock), - zap.Uint64("estimated_epoch", finalizedEpoch), + zap.Uint64("estimated_epoch", uint64(finalizedEpoch)), zap.Uint64("previous_finalized", lastFinalized)) lastFinalized = toBlock } } else { // Check if we need to transition to post-fork - currentEpoch := headerNum / ec.config.SlotsPerEpoch + currentEpoch := phase0.Epoch(headerNum / ec.config.SlotsPerEpoch) if currentEpoch > ec.config.FinalityConsensusEpoch { ec.isPostForkState.Store(true) @@ -604,14 +607,14 @@ func (ec *ExecutionClient) IsFinalizedFork(ctx context.Context) bool { return false } - currentEpoch := currentBlock / ec.config.SlotsPerEpoch + currentEpoch := phase0.Epoch(currentBlock / ec.config.SlotsPerEpoch) // Check if we've passed the fork point if currentEpoch > ec.config.FinalityConsensusEpoch { ec.isPostForkState.Store(true) ec.logger.Info("finality fork threshold passed, using finalized blocks", - zap.Uint64("current_epoch", currentEpoch), - zap.Uint64("finality_fork_epoch", ec.config.FinalityConsensusEpoch)) + zap.Uint64("current_epoch", uint64(currentEpoch)), + zap.Uint64("finality_fork_epoch", uint64(ec.config.FinalityConsensusEpoch))) return true } diff --git a/eth/executionclient/multi_client_test.go b/eth/executionclient/multi_client_test.go index e8ba4ae927..ecdd74003f 100644 --- a/eth/executionclient/multi_client_test.go +++ b/eth/executionclient/multi_client_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/ethereum/go-ethereum" ethcommon "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" @@ -90,7 +91,7 @@ func TestNewMulti_WithOptions(t *testing.T) { }) t.Run("post-fork (finality)", func(t *testing.T) { - const customFinalityForkEpoch = uint64(5) + const customFinalityForkEpoch = phase0.Epoch(5) mc, err := NewMulti( ctx, diff --git a/networkconfig/config.go b/networkconfig/config.go index 1f31763c3f..78c25d6c5c 100644 --- a/networkconfig/config.go +++ b/networkconfig/config.go @@ -29,7 +29,6 @@ type NetworkConfig struct { Name string BeaconConfig SSVConfig - ForksConfig } func (n NetworkConfig) String() string { diff --git a/networkconfig/forks.go b/networkconfig/forks.go deleted file mode 100644 index 7b185a1fe8..0000000000 --- a/networkconfig/forks.go +++ /dev/null @@ -1,30 +0,0 @@ -package networkconfig - -// Fork is a numerical identifier of specific network upgrades (forks). -type Fork int - -const ( - Alan Fork = iota - FinalityConsensus // TODO: use a different name when we have a better one -) - -// String implements fmt.Stringer. -func (f Fork) String() string { - s, ok := forkToString[f] - if !ok { - return "Unknown fork" - } - return s -} - -var forkToString = map[Fork]string{ - Alan: "Alan", - FinalityConsensus: "Finality Consensus", // TODO: use a different name when we have a better one -} - -// ForksConfig contains the epoch numbers for different protocol forks. -type ForksConfig struct { - // FinalityConsensusEpoch is the epoch at which the network switches - // to using finalized blocks for determining log fetching ranges and health checks. - FinalityConsensusEpoch uint64 // TODO: use a different name when we have a better one -} diff --git a/networkconfig/hoodi.go b/networkconfig/hoodi.go index c25c830d1a..599b195f62 100644 --- a/networkconfig/hoodi.go +++ b/networkconfig/hoodi.go @@ -22,8 +22,17 @@ var Hoodi = NetworkConfig{ // SSV Labs "enr:-Ja4QIKlyNFuFtTOnVoavqwmpgSJXfhSmhpdSDOUhf5-FBr7bBxQRvG6VrpUvlkr8MtpNNuMAkM33AseduSaOhd9IeWGAZWjRbnvgmlkgnY0gmlwhCNVVTCJc2VjcDI1NmsxoQNTTyiJPoZh502xOZpHSHAfR-94NaXLvi5J4CNHMh2tjoNzc3YBg3RjcIITioN1ZHCCD6I", }, - }, - ForksConfig: ForksConfig{ - FinalityConsensusEpoch: 120, // TODO: use a different name when we have a better one, value as well + Forks: SSVForkConfig{ + Forks: SSVForks{ + { + Name: "Alan", + Epoch: 0, + }, + { + Name: "Finality Consensus", + Epoch: 120, // TODO: use a different name when we have a better one, value as well + }, + }, + }, }, } diff --git a/networkconfig/mainnet.go b/networkconfig/mainnet.go index b20e639e47..8162959a58 100644 --- a/networkconfig/mainnet.go +++ b/networkconfig/mainnet.go @@ -31,8 +31,17 @@ var Mainnet = NetworkConfig{ // CryptoManufaktur "enr:-Li4QH7FwJcL8gJj0zHAITXqghMkG-A5bfWh2-3Q7vosy9D1BS8HZk-1ITuhK_rfzG3v_UtBDI6uNJZWpdcWfrQFCxKGAYnQ1DRCh2F0dG5ldHOIAAAAAAAAAACEZXRoMpD1pf1CAAAAAP__________gmlkgnY0gmlwhBLb3g2Jc2VjcDI1NmsxoQKeSDcZWSaY9FC723E9yYX1Li18bswhLNlxBZdLfgOKp4N0Y3CCE4mDdWRwgg-h", }, - }, - ForksConfig: ForksConfig{ - FinalityConsensusEpoch: 120, // TODO: use a different name when we have a better one, value as well + Forks: SSVForkConfig{ + Forks: SSVForks{ + { + Name: "Alan", + Epoch: 0, + }, + { + Name: "Finality Consensus", + Epoch: 120, // TODO: use a different name when we have a better one, value as well + }, + }, + }, }, } diff --git a/networkconfig/sepolia.go b/networkconfig/sepolia.go index 9b824aa661..cc7fcb5044 100644 --- a/networkconfig/sepolia.go +++ b/networkconfig/sepolia.go @@ -22,8 +22,17 @@ var Sepolia = NetworkConfig{ // SSV Labs "enr:-Ja4QIE0Ml0a8Pq9zD-0g9KYGN3jAMPJ0CAP0i16fK-PSHfLeORl-Z5p8odoP1oS5S2E8IsF5jNG7gqTKhjVsHR-Z_CGAZXrnTJrgmlkgnY0gmlwhCOjXGWJc2VjcDI1NmsxoQKCRDQsIdFsJDmu_ZU2H6b2_HRJbuUneDXHLfFkSQH9O4Nzc3YBg3RjcIITioN1ZHCCD6I", }, - }, - ForksConfig: ForksConfig{ - FinalityConsensusEpoch: 120, // TODO: use a different name when we have a better one, value as well + Forks: SSVForkConfig{ + Forks: SSVForks{ + { + Name: "Alan", + Epoch: 0, + }, + { + Name: "Finality Consensus", + Epoch: 120, // TODO: use a different name when we have a better one, value as well + }, + }, + }, }, } diff --git a/networkconfig/ssv.go b/networkconfig/ssv.go index 6ae33b78e9..8c5f91bb52 100644 --- a/networkconfig/ssv.go +++ b/networkconfig/ssv.go @@ -12,4 +12,6 @@ type SSVConfig struct { RegistryContractAddr string // TODO: ethcommon.Address Bootnodes []string DiscoveryProtocolID [6]byte + + Forks SSVForkConfig } diff --git a/networkconfig/ssv_forks.go b/networkconfig/ssv_forks.go new file mode 100644 index 0000000000..f2fce8c40d --- /dev/null +++ b/networkconfig/ssv_forks.go @@ -0,0 +1,96 @@ +package networkconfig + +import "github.com/attestantio/go-eth2-client/spec/phase0" + +// Fork is a numerical identifier of specific network upgrades (forks). +type Fork int + +const ( + Alan Fork = iota + FinalityConsensus // TODO: use a different name when we have a better one +) + +// String implements fmt.Stringer. +func (f Fork) String() string { + s, ok := forkToString[f] + if !ok { + return "Unknown fork" + } + return s +} + +var forkToString = map[Fork]string{ + Alan: "Alan", + FinalityConsensus: "Finality Consensus", // TODO: use a different name when we have a better one +} + +// SSVFork describes a single SSV protocol fork. +type SSVFork struct { + // Name of the fork + Name string + + // Epoch when the fork is activated + Epoch phase0.Epoch +} + +// SSVForks is a list of SSV protocol forks. +type SSVForks []*SSVFork + +// ActiveFork returns the active fork at the given epoch. +func (f SSVForks) ActiveFork(epoch phase0.Epoch) *SSVFork { + for i := len(f) - 1; i >= 0; i-- { + if f[i].Epoch <= epoch { + return f[i] + } + } + return nil +} + +// FindByName returns the fork with the given name, or nil if not found. +func (f SSVForks) FindByName(name string) *SSVFork { + for _, fork := range f { + if fork.Name == name { + return fork + } + } + return nil +} + +// IsForkActive returns whether the fork with the given name is active at the given epoch. +func (f SSVForks) IsForkActive(name string, epoch phase0.Epoch) bool { + fork := f.FindByName(name) + if fork == nil { + return false + } + + activeFork := f.ActiveFork(epoch) + + return activeFork != nil && activeFork.Name == name +} + +// SSVForkConfig contains fork configurations for an SSV network. +type SSVForkConfig struct { + // Forks is the list of all SSV protocol forks in order of activation epoch. + Forks SSVForks +} + +// ActiveFork returns the active fork at the given epoch. +func (c SSVForkConfig) ActiveFork(epoch phase0.Epoch) *SSVFork { + return c.Forks.ActiveFork(epoch) +} + +// FindForkByName returns the fork with the given name, or nil if not found. +func (c SSVForkConfig) FindForkByName(name string) *SSVFork { + return c.Forks.FindByName(name) +} + +// IsForkActive returns whether the fork with the given name is active at the given epoch. +func (c SSVForkConfig) IsForkActive(name string, epoch phase0.Epoch) bool { + return c.Forks.IsForkActive(name, epoch) +} + +// IsFinalityConsensusActive returns whether the FinalityConsensus fork is active at the given epoch. +// TODO: use a different name when we have a better one +func (c SSVForkConfig) IsFinalityConsensusActive(epoch phase0.Epoch) bool { + return c.IsForkActive("Finality Consensus", epoch) +} diff --git a/networkconfig/test-network.go b/networkconfig/test-network.go index 9dafb6d1bc..42284f8a2f 100644 --- a/networkconfig/test-network.go +++ b/networkconfig/test-network.go @@ -20,8 +20,17 @@ var TestNetwork = NetworkConfig{ Bootnodes: []string{ "enr:-Li4QFIQzamdvTxGJhvcXG_DFmCeyggSffDnllY5DiU47pd_K_1MRnSaJimWtfKJ-MD46jUX9TwgW5Jqe0t4pH41RYWGAYuFnlyth2F0dG5ldHOIAAAAAAAAAACEZXRoMpD1pf1CAAAAAP__________gmlkgnY0gmlwhCLdu_SJc2VjcDI1NmsxoQN4v-N9zFYwEqzGPBBX37q24QPFvAVUtokIo1fblIsmTIN0Y3CCE4uDdWRwgg-j", }, - }, - ForksConfig: ForksConfig{ - FinalityConsensusEpoch: 1, // TODO: use a different name when we have a better one, value as well + Forks: SSVForkConfig{ + Forks: SSVForks{ + { + Name: "Alan", + Epoch: 0, + }, + { + Name: "Finality Consensus", + Epoch: 1, // TODO: use a different name when we have a better one, value as well + }, + }, + }, }, } From d11f46bdb05a1de0b73600dd925e5cf3d93538ee Mon Sep 17 00:00:00 2001 From: kchojn Date: Wed, 21 May 2025 17:07:24 +0700 Subject: [PATCH 39/53] refactor(executionclient): update Config struct to use GetFinalityConsensusEpoch method from SSVForkConfig to retrieve Finality Consensus epoch value refactor(networkconfig): introduce MaxEpoch constant to represent undefined epoch for not-yet-scheduled forks refactor(networkconfig): add ComputeActiveFork method to find the active fork for a given epoch refactor(networkconfig): update GetFinalityConsensusEpoch method in SSVForkConfig to use FindForkByName to retrieve Finality Consensus epoch --- eth/executionclient/config.go | 9 +-------- networkconfig/hoodi.go | 2 +- networkconfig/mainnet.go | 2 +- networkconfig/sepolia.go | 2 +- networkconfig/ssv_forks.go | 34 ++++++++++++++++++++++++++++------ 5 files changed, 32 insertions(+), 17 deletions(-) diff --git a/eth/executionclient/config.go b/eth/executionclient/config.go index 7638d45cb6..954d11fba3 100644 --- a/eth/executionclient/config.go +++ b/eth/executionclient/config.go @@ -26,16 +26,9 @@ type Config struct { // NewConfigFromNetwork creates a new Config with network-specific values // and default values for other parameters. func NewConfigFromNetwork(networkCfg networkconfig.NetworkConfig) Config { - var finalityConsensusEpoch phase0.Epoch - - finalityConsensusFork := networkCfg.Forks.FindForkByName("Finality Consensus") - if finalityConsensusFork != nil { - finalityConsensusEpoch = finalityConsensusFork.Epoch - } - return Config{ SlotsPerEpoch: networkCfg.SlotsPerEpoch(), - FinalityConsensusEpoch: finalityConsensusEpoch, + FinalityConsensusEpoch: networkCfg.Forks.GetFinalityConsensusEpoch(), FollowDistance: DefaultFollowDistance, } } diff --git a/networkconfig/hoodi.go b/networkconfig/hoodi.go index 599b195f62..070230bcde 100644 --- a/networkconfig/hoodi.go +++ b/networkconfig/hoodi.go @@ -30,7 +30,7 @@ var Hoodi = NetworkConfig{ }, { Name: "Finality Consensus", - Epoch: 120, // TODO: use a different name when we have a better one, value as well + Epoch: MaxEpoch, }, }, }, diff --git a/networkconfig/mainnet.go b/networkconfig/mainnet.go index 8162959a58..315c8a0d98 100644 --- a/networkconfig/mainnet.go +++ b/networkconfig/mainnet.go @@ -39,7 +39,7 @@ var Mainnet = NetworkConfig{ }, { Name: "Finality Consensus", - Epoch: 120, // TODO: use a different name when we have a better one, value as well + Epoch: MaxEpoch, }, }, }, diff --git a/networkconfig/sepolia.go b/networkconfig/sepolia.go index cc7fcb5044..e1f1a02dd7 100644 --- a/networkconfig/sepolia.go +++ b/networkconfig/sepolia.go @@ -30,7 +30,7 @@ var Sepolia = NetworkConfig{ }, { Name: "Finality Consensus", - Epoch: 120, // TODO: use a different name when we have a better one, value as well + Epoch: MaxEpoch, }, }, }, diff --git a/networkconfig/ssv_forks.go b/networkconfig/ssv_forks.go index f2fce8c40d..0ff37ce48b 100644 --- a/networkconfig/ssv_forks.go +++ b/networkconfig/ssv_forks.go @@ -1,6 +1,10 @@ package networkconfig -import "github.com/attestantio/go-eth2-client/spec/phase0" +import ( + "math" + + "github.com/attestantio/go-eth2-client/spec/phase0" +) // Fork is a numerical identifier of specific network upgrades (forks). type Fork int @@ -24,6 +28,9 @@ var forkToString = map[Fork]string{ FinalityConsensus: "Finality Consensus", // TODO: use a different name when we have a better one } +// MaxEpoch represents undefined epoch for not-yet-scheduled forks +var MaxEpoch = phase0.Epoch(math.MaxUint64) + // SSVFork describes a single SSV protocol fork. type SSVFork struct { // Name of the fork @@ -46,6 +53,18 @@ func (f SSVForks) ActiveFork(epoch phase0.Epoch) *SSVFork { return nil } +// ComputeActiveFork returns the active fork for the given epoch +func (f SSVForks) ComputeActiveFork(epoch phase0.Epoch) *SSVFork { + // Search from the end to find the most recent active fork + for i := len(f) - 1; i >= 0; i-- { + if epoch >= f[i].Epoch { + return f[i] + } + } + // If no active fork is found, return nil + return nil +} + // FindByName returns the fork with the given name, or nil if not found. func (f SSVForks) FindByName(name string) *SSVFork { for _, fork := range f { @@ -64,7 +83,6 @@ func (f SSVForks) IsForkActive(name string, epoch phase0.Epoch) bool { } activeFork := f.ActiveFork(epoch) - return activeFork != nil && activeFork.Name == name } @@ -89,8 +107,12 @@ func (c SSVForkConfig) IsForkActive(name string, epoch phase0.Epoch) bool { return c.Forks.IsForkActive(name, epoch) } -// IsFinalityConsensusActive returns whether the FinalityConsensus fork is active at the given epoch. -// TODO: use a different name when we have a better one -func (c SSVForkConfig) IsFinalityConsensusActive(epoch phase0.Epoch) bool { - return c.IsForkActive("Finality Consensus", epoch) +// GetFinalityConsensusEpoch returns the epoch at which the Finality Consensus fork is activated. +// If the fork is not found, returns MaxEpoch (undefined). +func (c SSVForkConfig) GetFinalityConsensusEpoch() phase0.Epoch { + fork := c.FindForkByName("Finality Consensus") + if fork != nil { + return fork.Epoch + } + return MaxEpoch } From 20f68fbb8797bd5c9f7340ea0bac5e1f7207afa6 Mon Sep 17 00:00:00 2001 From: kchojn Date: Wed, 21 May 2025 17:09:47 +0700 Subject: [PATCH 40/53] refactor(config.go): refactor NetworkName method to use active fork name in lowercase for consistency --- networkconfig/config.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/networkconfig/config.go b/networkconfig/config.go index 78c25d6c5c..2d8bca85c1 100644 --- a/networkconfig/config.go +++ b/networkconfig/config.go @@ -3,6 +3,7 @@ package networkconfig import ( "encoding/json" "fmt" + "strings" "time" ) @@ -41,7 +42,9 @@ func (n NetworkConfig) String() string { } func (n NetworkConfig) NetworkName() string { - return fmt.Sprintf("%s:%s", n.Name, "alan") + activeFork := n.Forks.ActiveFork(0) // Alan + + return fmt.Sprintf("%s:%s", n.Name, strings.ToLower(activeFork.Name)) } // ForkVersion returns the fork version of the network. From 663f796db299d764a4335ff83dcae239dcac47a9 Mon Sep 17 00:00:00 2001 From: kchojn Date: Wed, 21 May 2025 17:17:08 +0700 Subject: [PATCH 41/53] refactor(ssv_forks.go): rename Fork type to SSVForkName for clarity and consistency refactor(ssv_forks.go): update comments and variable names to reflect the change from Fork to SSVForkName for SSV protocol forks --- networkconfig/ssv_forks.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/networkconfig/ssv_forks.go b/networkconfig/ssv_forks.go index 0ff37ce48b..e6205f7d85 100644 --- a/networkconfig/ssv_forks.go +++ b/networkconfig/ssv_forks.go @@ -6,16 +6,16 @@ import ( "github.com/attestantio/go-eth2-client/spec/phase0" ) -// Fork is a numerical identifier of specific network upgrades (forks). -type Fork int +// SSVForkName is a numerical identifier of specific SSV protocol forks. +type SSVForkName int const ( - Alan Fork = iota - FinalityConsensus // TODO: use a different name when we have a better one + Alan SSVForkName = iota + FinalityConsensus // TODO: use a different name when we have a better one ) // String implements fmt.Stringer. -func (f Fork) String() string { +func (f SSVForkName) String() string { s, ok := forkToString[f] if !ok { return "Unknown fork" @@ -23,7 +23,7 @@ func (f Fork) String() string { return s } -var forkToString = map[Fork]string{ +var forkToString = map[SSVForkName]string{ Alan: "Alan", FinalityConsensus: "Finality Consensus", // TODO: use a different name when we have a better one } From d73cb19315a3a5974e426684b1a5737baef49746 Mon Sep 17 00:00:00 2001 From: kchojn Date: Wed, 21 May 2025 19:00:29 +0700 Subject: [PATCH 42/53] some tests --- networkconfig/ssv_forks_test.go | 286 ++++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 networkconfig/ssv_forks_test.go diff --git a/networkconfig/ssv_forks_test.go b/networkconfig/ssv_forks_test.go new file mode 100644 index 0000000000..b695652b10 --- /dev/null +++ b/networkconfig/ssv_forks_test.go @@ -0,0 +1,286 @@ +package networkconfig + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +func TestSSVForkName_String(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + fork SSVForkName + expected string + }{ + { + name: "alan fork", + fork: Alan, + expected: "Alan", + }, + { + name: "finality consensus fork", + fork: FinalityConsensus, + expected: "Finality Consensus", + }, + { + name: "unknown fork", + fork: SSVForkName(999), + expected: "Unknown fork", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tt.expected, tt.fork.String()) + }) + } +} + +func TestSSVForks_ActiveFork(t *testing.T) { + t.Parallel() + + forks := SSVForks{ + {Name: "Fork1", Epoch: 100}, + {Name: "Fork2", Epoch: 200}, + } + + testCases := []struct { + name string + epoch phase0.Epoch + expectedFork string + expectNilFork bool + }{ + { + name: "before first fork", + epoch: 50, + expectNilFork: true, + }, + { + name: "at first fork", + epoch: 100, + expectedFork: "Fork1", + }, + { + name: "between first and second fork", + epoch: 150, + expectedFork: "Fork1", + }, + { + name: "at second fork", + epoch: 200, + expectedFork: "Fork2", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := forks.ActiveFork(tt.epoch) + if tt.expectNilFork { + require.Nil(t, result) + } else { + require.NotNil(t, result) + require.Equal(t, tt.expectedFork, result.Name) + } + }) + } +} + +func TestSSVForks_ComputeActiveFork(t *testing.T) { + t.Parallel() + + forks := SSVForks{ + {Name: "Fork1", Epoch: 100}, + {Name: "Fork2", Epoch: 200}, + } + + testCases := []struct { + name string + epoch phase0.Epoch + expectedFork string + expectNilFork bool + }{ + { + name: "before first fork", + epoch: 50, + expectNilFork: true, + }, + { + name: "at first fork", + epoch: 100, + expectedFork: "Fork1", + }, + { + name: "between first and second fork", + epoch: 150, + expectedFork: "Fork1", + }, + { + name: "at second fork", + epoch: 200, + expectedFork: "Fork2", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := forks.ComputeActiveFork(tt.epoch) + if tt.expectNilFork { + require.Nil(t, result) + } else { + require.NotNil(t, result) + require.Equal(t, tt.expectedFork, result.Name) + } + }) + } +} + +func TestSSVForks_FindByName(t *testing.T) { + t.Parallel() + + forks := SSVForks{ + {Name: "Fork1", Epoch: 100}, + {Name: "Fork2", Epoch: 200}, + } + + testCases := []struct { + name string + forkName string + expectedEpoch phase0.Epoch + expectNilFork bool + }{ + { + name: "existing fork", + forkName: "Fork2", + expectedEpoch: 200, + }, + { + name: "non-existent fork", + forkName: "ForkX", + expectNilFork: true, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := forks.FindByName(tt.forkName) + if tt.expectNilFork { + require.Nil(t, result) + } else { + require.NotNil(t, result) + require.Equal(t, tt.forkName, result.Name) + require.Equal(t, tt.expectedEpoch, result.Epoch) + } + }) + } +} + +func TestSSVForks_IsForkActive(t *testing.T) { + t.Parallel() + + forks := SSVForks{ + {Name: "Fork1", Epoch: 100}, + {Name: "Fork2", Epoch: 200}, + } + + testCases := []struct { + name string + forkName string + epoch phase0.Epoch + expectActive bool + }{ + { + name: "before first fork activation", + forkName: "Fork1", + epoch: 50, + expectActive: false, + }, + { + name: "at fork activation", + forkName: "Fork1", + epoch: 100, + expectActive: true, + }, + { + name: "after fork activation but before next fork", + forkName: "Fork1", + epoch: 150, + expectActive: true, + }, + { + name: "after next fork activation", + forkName: "Fork1", + epoch: 200, + expectActive: false, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := forks.IsForkActive(tt.forkName, tt.epoch) + require.Equal(t, tt.expectActive, result) + }) + } +} + +func TestSSVForkConfig_Delegation(t *testing.T) { + t.Parallel() + + forks := SSVForks{ + {Name: "Fork1", Epoch: 100}, + {Name: "Fork2", Epoch: 200}, + {Name: "Finality Consensus", Epoch: 300}, + } + + config := SSVForkConfig{Forks: forks} + + t.Run("ActiveFork", func(t *testing.T) { + t.Parallel() + + result := config.ActiveFork(250) + require.NotNil(t, result) + require.Equal(t, "Fork2", result.Name) + }) + + t.Run("FindForkByName", func(t *testing.T) { + t.Parallel() + + result := config.FindForkByName("Fork1") + require.NotNil(t, result) + require.Equal(t, phase0.Epoch(100), result.Epoch) + }) + + t.Run("IsForkActive", func(t *testing.T) { + t.Parallel() + + result := config.IsForkActive("Fork2", 250) + require.True(t, result) + }) + + t.Run("GetFinalityConsensusEpoch", func(t *testing.T) { + t.Parallel() + + result := config.GetFinalityConsensusEpoch() + require.Equal(t, phase0.Epoch(300), result) + }) + + t.Run("GetFinalityConsensusEpoch not found", func(t *testing.T) { + t.Parallel() + + emptyConfig := SSVForkConfig{Forks: SSVForks{}} + result := emptyConfig.GetFinalityConsensusEpoch() + require.Equal(t, MaxEpoch, result) + }) +} From d2fb9bec250a40149977ea895a9d2e440f6b3498 Mon Sep 17 00:00:00 2001 From: kchojn Date: Tue, 27 May 2025 13:21:49 +0700 Subject: [PATCH 43/53] NewConfigFromNetworkConfig --- cli/operator/node.go | 4 +- eth/ethtest/common_test.go | 2 +- eth/eventhandler/event_handler_test.go | 2 +- eth/eventsyncer/event_syncer_test.go | 2 +- eth/executionclient/config.go | 4 +- eth/executionclient/execution_client_test.go | 54 ++++++++++---------- eth/executionclient/multi_client_test.go | 8 +-- 7 files changed, 38 insertions(+), 38 deletions(-) diff --git a/cli/operator/node.go b/cli/operator/node.go index 4eaed4a6b0..08dc81594c 100644 --- a/cli/operator/node.go +++ b/cli/operator/node.go @@ -283,7 +283,7 @@ var StartNodeCmd = &cobra.Command{ if len(executionAddrList) == 1 { ec, err := executionclient.New( cmd.Context(), - executionclient.NewConfigFromNetwork(networkConfig), + executionclient.NewConfigFromNetworkConfig(networkConfig), executionAddrList[0], ethcommon.HexToAddress(networkConfig.RegistryContractAddr), executionclient.WithLogger(logger), @@ -299,7 +299,7 @@ var StartNodeCmd = &cobra.Command{ } else { ec, err := executionclient.NewMulti( cmd.Context(), - executionclient.NewConfigFromNetwork(networkConfig), + executionclient.NewConfigFromNetworkConfig(networkConfig), executionAddrList, ethcommon.HexToAddress(networkConfig.RegistryContractAddr), executionclient.WithLoggerMulti(logger), diff --git a/eth/ethtest/common_test.go b/eth/ethtest/common_test.go index a435c6ff49..18f45efcb5 100644 --- a/eth/ethtest/common_test.go +++ b/eth/ethtest/common_test.go @@ -95,7 +95,7 @@ func (e *TestEnv) setup( logger := zaptest.NewLogger(t) // set up basic network/fork stuff - e.execClientConfig = executionclient.NewConfigFromNetwork(networkconfig.TestNetwork) + e.execClientConfig = executionclient.NewConfigFromNetworkConfig(networkconfig.TestNetwork) if useFinalityFork { // Post-fork config (use finality consensus) diff --git a/eth/eventhandler/event_handler_test.go b/eth/eventhandler/event_handler_test.go index 0a2c63ef05..5e1453a14b 100644 --- a/eth/eventhandler/event_handler_test.go +++ b/eth/eventhandler/event_handler_test.go @@ -116,7 +116,7 @@ func TestHandleBlockEventsStream(t *testing.T) { // Create a client and connect to the simulator client, err := executionclient.New(ctx, - executionclient.NewConfigFromNetwork(networkconfig.TestNetwork), + executionclient.NewConfigFromNetworkConfig(networkconfig.TestNetwork), addr, contractAddr, executionclient.WithLogger(logger)) diff --git a/eth/eventsyncer/event_syncer_test.go b/eth/eventsyncer/event_syncer_test.go index 2e8119673f..d59604335c 100644 --- a/eth/eventsyncer/event_syncer_test.go +++ b/eth/eventsyncer/event_syncer_test.go @@ -88,7 +88,7 @@ func TestEventSyncer(t *testing.T) { addr := "ws:" + strings.TrimPrefix(httpSrv.URL, "http:") client, err := executionclient.New(ctx, - executionclient.NewConfigFromNetwork(networkconfig.TestNetwork), + executionclient.NewConfigFromNetworkConfig(networkconfig.TestNetwork), addr, contractAddr, executionclient.WithLogger(logger)) diff --git a/eth/executionclient/config.go b/eth/executionclient/config.go index 954d11fba3..f1824842c0 100644 --- a/eth/executionclient/config.go +++ b/eth/executionclient/config.go @@ -23,9 +23,9 @@ type Config struct { FollowDistance uint64 // Number of blocks to follow behind head } -// NewConfigFromNetwork creates a new Config with network-specific values +// NewConfigFromNetworkConfig creates a new Config with network-specific values // and default values for other parameters. -func NewConfigFromNetwork(networkCfg networkconfig.NetworkConfig) Config { +func NewConfigFromNetworkConfig(networkCfg networkconfig.NetworkConfig) Config { return Config{ SlotsPerEpoch: networkCfg.SlotsPerEpoch(), FinalityConsensusEpoch: networkCfg.Forks.GetFinalityConsensusEpoch(), diff --git a/eth/executionclient/execution_client_test.go b/eth/executionclient/execution_client_test.go index 8c02c4ebd6..e131d8d7ab 100644 --- a/eth/executionclient/execution_client_test.go +++ b/eth/executionclient/execution_client_test.go @@ -177,7 +177,7 @@ func TestFetchHistoricalLogs(t *testing.T) { // Create a client and connect to the simulator with finality fork enabled err = env.createClient( - NewConfigFromNetwork(networkconfig.TestNetwork), + NewConfigFromNetworkConfig(networkconfig.TestNetwork), WithLogger(logger), WithConnectionTimeout(2*time.Second), ) @@ -216,7 +216,7 @@ func TestFetchHistoricalLogs(t *testing.T) { // Create a client with finality fork disabled (using follow distance) const followDistance = 8 err = env.createClient( - NewConfigFromNetwork(networkconfig.TestNetwork).WithFollowDistance(followDistance), + NewConfigFromNetworkConfig(networkconfig.TestNetwork).WithFollowDistance(followDistance), WithLogger(logger), WithConnectionTimeout(2*time.Second), ) @@ -255,7 +255,7 @@ func TestFetchHistoricalLogs(t *testing.T) { // Create a client with a large followDistance and finality fork disabled const followDistance = 100 // Much larger than the current block number err = env.createClient( - NewConfigFromNetwork(networkconfig.TestNetwork).WithFollowDistance(followDistance), + NewConfigFromNetworkConfig(networkconfig.TestNetwork).WithFollowDistance(followDistance), WithLogger(logger), WithConnectionTimeout(2*time.Second), ) @@ -276,7 +276,7 @@ func TestFetchHistoricalLogs(t *testing.T) { // Create a client with finality fork disabled const followDistance = 8 err = env.createClient( - NewConfigFromNetwork(networkconfig.TestNetwork).WithFollowDistance(followDistance), + NewConfigFromNetworkConfig(networkconfig.TestNetwork).WithFollowDistance(followDistance), WithLogger(logger), WithConnectionTimeout(2*time.Second), ) @@ -306,7 +306,7 @@ func TestFetchHistoricalLogs(t *testing.T) { // Create a client - connection should succeed initially err = env.createClient( - NewConfigFromNetwork(networkconfig.TestNetwork), + NewConfigFromNetworkConfig(networkconfig.TestNetwork), WithLogger(logger), WithConnectionTimeout(100*time.Millisecond), ) @@ -338,7 +338,7 @@ func TestStreamLogs(t *testing.T) { // Create a client and connect to the simulator with finality fork enabled err = env.createClient( - NewConfigFromNetwork(networkconfig.TestNetwork), + NewConfigFromNetworkConfig(networkconfig.TestNetwork), WithLogger(logger)) require.NoError(t, err) @@ -389,7 +389,7 @@ func TestStreamLogs(t *testing.T) { // Create a client with explicit follow distance and disabled finality fork const followDistance = 2 err = env.createClient( - NewConfigFromNetwork(networkconfig.TestNetwork).WithFollowDistance(followDistance), + NewConfigFromNetworkConfig(networkconfig.TestNetwork).WithFollowDistance(followDistance), WithLogger(logger)) require.NoError(t, err) @@ -450,7 +450,7 @@ func TestStreamLogs(t *testing.T) { // Create a client and connect to the simulator err = env.createClient( - NewConfigFromNetwork(networkconfig.TestNetwork), + NewConfigFromNetworkConfig(networkconfig.TestNetwork), WithLogger(logger)) require.NoError(t, err) @@ -487,7 +487,7 @@ func TestStreamLogs(t *testing.T) { // Create a client without automatic cleanup err = env.createClientWithCleanup(false, - NewConfigFromNetwork(networkconfig.TestNetwork), + NewConfigFromNetworkConfig(networkconfig.TestNetwork), WithLogger(logger)) require.NoError(t, err) @@ -520,7 +520,7 @@ func TestFetchLogsInBatches(t *testing.T) { require.NoError(t, err) err = env.createClient( - NewConfigFromNetwork(networkconfig.TestNetwork), + NewConfigFromNetworkConfig(networkconfig.TestNetwork), WithLogger(logger), WithLogBatchSize(2)) require.NoError(t, err) @@ -614,7 +614,7 @@ func TestChainReorganizationLogs(t *testing.T) { // 2. Create a client and set up subscription with finality fork enabled err = env.createClient( - NewConfigFromNetwork(networkconfig.TestNetwork), + NewConfigFromNetworkConfig(networkconfig.TestNetwork), WithLogger(logger)) require.NoError(t, err) @@ -716,7 +716,7 @@ func TestChainReorganizationLogs(t *testing.T) { // 2. Create a client with follow distance mechanism (finality fork disabled) const followDistance = 5 err = env.createClient( - NewConfigFromNetwork(networkconfig.TestNetwork).WithFollowDistance(followDistance), + NewConfigFromNetworkConfig(networkconfig.TestNetwork).WithFollowDistance(followDistance), WithLogger(logger), ) require.NoError(t, err) @@ -848,7 +848,7 @@ func TestSimSSV(t *testing.T) { // Create a client and connect to the simulator with finality fork enabled err = env.createClient( - NewConfigFromNetwork(networkconfig.TestNetwork), + NewConfigFromNetworkConfig(networkconfig.TestNetwork), WithLogger(logger)) require.NoError(t, err) @@ -918,7 +918,7 @@ func TestSimSSV(t *testing.T) { // Create a client and connect to the simulator with follow distance const followDistance = 2 err = env.createClient( - NewConfigFromNetwork(networkconfig.TestNetwork).WithFollowDistance(followDistance), + NewConfigFromNetworkConfig(networkconfig.TestNetwork).WithFollowDistance(followDistance), WithLogger(logger), ) require.NoError(t, err) @@ -1001,7 +1001,7 @@ func TestFilterLogs(t *testing.T) { // Create a client and connect to the simulator err = env.createClient( - NewConfigFromNetwork(networkconfig.TestNetwork), + NewConfigFromNetworkConfig(networkconfig.TestNetwork), WithLogger(logger)) require.NoError(t, err) @@ -1036,7 +1036,7 @@ func TestFilterLogs(t *testing.T) { // Create a client - connection should succeed initially err = env.createClient( - NewConfigFromNetwork(networkconfig.TestNetwork), + NewConfigFromNetworkConfig(networkconfig.TestNetwork), WithLogger(logger), WithConnectionTimeout(100*time.Millisecond), ) @@ -1070,7 +1070,7 @@ func TestSubscribeFilterLogs(t *testing.T) { // Create a client and connect to the simulator err = env.createClient( - NewConfigFromNetwork(networkconfig.TestNetwork), + NewConfigFromNetworkConfig(networkconfig.TestNetwork), WithLogger(logger)) require.NoError(t, err) @@ -1132,7 +1132,7 @@ func TestSubscribeFilterLogs(t *testing.T) { // Create a client - connection should succeed initially err = env.createClient( - NewConfigFromNetwork(networkconfig.TestNetwork), + NewConfigFromNetworkConfig(networkconfig.TestNetwork), WithLogger(logger), WithConnectionTimeout(100*time.Millisecond), ) @@ -1168,7 +1168,7 @@ func TestBlockByNumber(t *testing.T) { // Create a client and connect to the simulator err = env.createClient( - NewConfigFromNetwork(networkconfig.TestNetwork), + NewConfigFromNetworkConfig(networkconfig.TestNetwork), WithLogger(logger)) require.NoError(t, err) @@ -1199,7 +1199,7 @@ func TestBlockByNumber(t *testing.T) { // Create a client - connection should succeed initially err = env.createClient( - NewConfigFromNetwork(networkconfig.TestNetwork), + NewConfigFromNetworkConfig(networkconfig.TestNetwork), WithLogger(logger), WithConnectionTimeout(100*time.Millisecond), ) @@ -1229,7 +1229,7 @@ func TestHeaderByNumber(t *testing.T) { // Create a client and connect to the simulator err = env.createClient( - NewConfigFromNetwork(networkconfig.TestNetwork), + NewConfigFromNetworkConfig(networkconfig.TestNetwork), WithLogger(logger)) require.NoError(t, err) @@ -1260,7 +1260,7 @@ func TestHeaderByNumber(t *testing.T) { // Create a client - connection should succeed initially err = env.createClient( - NewConfigFromNetwork(networkconfig.TestNetwork), + NewConfigFromNetworkConfig(networkconfig.TestNetwork), WithLogger(logger), WithConnectionTimeout(100*time.Millisecond), ) @@ -1288,7 +1288,7 @@ func TestFilterer(t *testing.T) { // Create a client and connect to the simulator err = env.createClient( - NewConfigFromNetwork(networkconfig.TestNetwork), + NewConfigFromNetworkConfig(networkconfig.TestNetwork), WithLogger(logger)) require.NoError(t, err) @@ -1308,7 +1308,7 @@ func TestSyncProgress(t *testing.T) { // Create a client and connect to the simulator err = env.createClient( - NewConfigFromNetwork(networkconfig.TestNetwork), + NewConfigFromNetworkConfig(networkconfig.TestNetwork), WithHealthInvalidationInterval(0)) require.NoError(t, err) @@ -1329,7 +1329,7 @@ func TestSyncProgress(t *testing.T) { t.Run("within tolerable limits", func(t *testing.T) { client, err := New( env.ctx, - NewConfigFromNetwork(networkconfig.TestNetwork), + NewConfigFromNetworkConfig(networkconfig.TestNetwork), env.wsURL, env.contractAddr, WithSyncDistanceTolerance(2), @@ -1357,7 +1357,7 @@ func TestHealthy(t *testing.T) { // Create a client and connect to the simulator err = env.createClientWithCleanup(false, - NewConfigFromNetwork(networkconfig.TestNetwork), + NewConfigFromNetworkConfig(networkconfig.TestNetwork), ) require.NoError(t, err) @@ -1376,7 +1376,7 @@ func TestHealthy(t *testing.T) { // Create a client with a health invalidation interval err = env.createClient( - NewConfigFromNetwork(networkconfig.TestNetwork), + NewConfigFromNetworkConfig(networkconfig.TestNetwork), WithHealthInvalidationInterval(10*time.Second)) require.NoError(t, err) diff --git a/eth/executionclient/multi_client_test.go b/eth/executionclient/multi_client_test.go index ecdd74003f..33ccead07c 100644 --- a/eth/executionclient/multi_client_test.go +++ b/eth/executionclient/multi_client_test.go @@ -27,7 +27,7 @@ func TestNewMulti(t *testing.T) { t.Run("no node addresses", func(t *testing.T) { ctx := context.Background() - mc, err := NewMulti(ctx, NewConfigFromNetwork(networkconfig.TestNetwork), []string{}, ethcommon.Address{}) + mc, err := NewMulti(ctx, NewConfigFromNetworkConfig(networkconfig.TestNetwork), []string{}, ethcommon.Address{}) require.Nil(t, mc, "MultiClient should be nil on error") require.Error(t, err, "expected an error due to no node addresses") @@ -39,7 +39,7 @@ func TestNewMulti(t *testing.T) { addr := "invalid-addr" addresses := []string{addr} - mc, err := NewMulti(ctx, NewConfigFromNetwork(networkconfig.TestNetwork), addresses, ethcommon.Address{}) + mc, err := NewMulti(ctx, NewConfigFromNetworkConfig(networkconfig.TestNetwork), addresses, ethcommon.Address{}) require.Nil(t, mc, "MultiClient should be nil on error") require.Error(t, err) @@ -71,7 +71,7 @@ func TestNewMulti_WithOptions(t *testing.T) { t.Run("pre-fork (follow distance)", func(t *testing.T) { mc, err := NewMulti( ctx, - NewConfigFromNetwork(networkconfig.TestNetwork).WithFollowDistance(customFollowDistance), + NewConfigFromNetworkConfig(networkconfig.TestNetwork).WithFollowDistance(customFollowDistance), addresses, contractAddr, WithLoggerMulti(customLogger), @@ -95,7 +95,7 @@ func TestNewMulti_WithOptions(t *testing.T) { mc, err := NewMulti( ctx, - NewConfigFromNetwork(networkconfig.TestNetwork).WithFinalityConsensusEpoch(customFinalityForkEpoch), + NewConfigFromNetworkConfig(networkconfig.TestNetwork).WithFinalityConsensusEpoch(customFinalityForkEpoch), addresses, contractAddr, WithLoggerMulti(customLogger), From 2baf05b071a1a80a9bf329b7d40471f6dc7bcb29 Mon Sep 17 00:00:00 2001 From: kchojn Date: Tue, 27 May 2025 13:40:50 +0700 Subject: [PATCH 44/53] feat(common_test.go): add support for finalityEpoch variable to configure finality consensus or follow distance based on useFinalityFork flag --- eth/ethtest/common_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/eth/ethtest/common_test.go b/eth/ethtest/common_test.go index 18f45efcb5..85a4e7f52c 100644 --- a/eth/ethtest/common_test.go +++ b/eth/ethtest/common_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/accounts/abi/bind" ethcommon "github.com/ethereum/go-ethereum/common" @@ -97,14 +98,13 @@ func (e *TestEnv) setup( // set up basic network/fork stuff e.execClientConfig = executionclient.NewConfigFromNetworkConfig(networkconfig.TestNetwork) + finalityEpoch := phase0.Epoch(1000) // Pre-fork config (use follow distance) if useFinalityFork { - // Post-fork config (use finality consensus) - e.execClientConfig = e.execClientConfig.WithFinalityConsensusEpoch(1) - } else { - // Pre-fork config (use follow distance) - e.execClientConfig = e.execClientConfig.WithFinalityConsensusEpoch(1000) + finalityEpoch = 1 // Post-fork config (use finality consensus) } + e.execClientConfig = e.execClientConfig.WithFinalityConsensusEpoch(finalityEpoch) + // Create operators RSA keys ops, err := createOperators(operatorsCount, 0) if err != nil { From 66ddc51bebbca9520d5e042a427dcba212663086 Mon Sep 17 00:00:00 2001 From: kchojn Date: Wed, 4 Jun 2025 10:34:08 +0700 Subject: [PATCH 45/53] post merge cleanup --- networkconfig/ssv.go | 3 +++ networkconfig/ssv_test.go | 26 +++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/networkconfig/ssv.go b/networkconfig/ssv.go index f2c7d114ce..23ea3a34b4 100644 --- a/networkconfig/ssv.go +++ b/networkconfig/ssv.go @@ -62,6 +62,7 @@ type marshaledConfig struct { Bootnodes []string `json:"Bootnodes,omitempty" yaml:"Bootnodes,omitempty"` DiscoveryProtocolID hexutil.Bytes `json:"DiscoveryProtocolID,omitempty" yaml:"DiscoveryProtocolID,omitempty"` TotalEthereumValidators int `json:"TotalEthereumValidators,omitempty" yaml:"TotalEthereumValidators,omitempty"` + Forks SSVForkConfig `json:"Forks,omitempty" yaml:"Forks,omitempty"` } // Helper method to avoid duplication between MarshalJSON and MarshalYAML @@ -73,6 +74,7 @@ func (s SSVConfig) marshal() marshaledConfig { Bootnodes: s.Bootnodes, DiscoveryProtocolID: s.DiscoveryProtocolID[:], TotalEthereumValidators: s.TotalEthereumValidators, + Forks: s.Forks, } return aux @@ -103,6 +105,7 @@ func (s *SSVConfig) unmarshalFromConfig(aux marshaledConfig) error { Bootnodes: aux.Bootnodes, DiscoveryProtocolID: [6]byte(aux.DiscoveryProtocolID), TotalEthereumValidators: aux.TotalEthereumValidators, + Forks: aux.Forks, } return nil diff --git a/networkconfig/ssv_test.go b/networkconfig/ssv_test.go index 8872d6b4a3..62ab837fa3 100644 --- a/networkconfig/ssv_test.go +++ b/networkconfig/ssv_test.go @@ -147,6 +147,18 @@ func TestFieldPreservation(t *testing.T) { RegistryContractAddr: ethcommon.HexToAddress("0x123456789abcdef0123456789abcdef012345678"), Bootnodes: []string{"bootnode1", "bootnode2"}, DiscoveryProtocolID: [6]byte{0x05, 0x06, 0x07, 0x08, 0x09, 0x0a}, + Forks: SSVForkConfig{ + Forks: SSVForks{ + { + Name: "Alan", + Epoch: 0, + }, + { + Name: "Finality Consensus", + Epoch: 1, + }, + }, + }, } // Marshal and unmarshal to test preservation @@ -168,7 +180,7 @@ func TestFieldPreservation(t *testing.T) { assert.Equal(t, originalHash, unmarshaledHash, "Hash mismatch indicates fields weren't properly preserved in JSON") // Store the expected hash - this will fail if a new field is added without updating the tests - expectedJSONHash := "3afe88f355185266dfd842df18a096ea8f40dd28f8b022710aedca1d09d59cef" + expectedJSONHash := "b1353678b60c092192f60939b50cdd34dd918648ad97890ccf6a69c66cee217b" assert.Equal(t, expectedJSONHash, originalHash, "Hash has changed. If you've added a new field, please update the expected hash in this test.") }) @@ -181,6 +193,18 @@ func TestFieldPreservation(t *testing.T) { RegistryContractAddr: ethcommon.HexToAddress("0x123456789abcdef0123456789abcdef012345678"), Bootnodes: []string{"bootnode1", "bootnode2"}, DiscoveryProtocolID: [6]byte{0x05, 0x06, 0x07, 0x08, 0x09, 0x0a}, + Forks: SSVForkConfig{ + Forks: SSVForks{ + { + Name: "Alan", + Epoch: 0, + }, + { + Name: "Finality Consensus", + Epoch: 1, + }, + }, + }, } // Marshal and unmarshal to test preservation From b3dbbcbf1a8f431e7b387e1d08df28bb1fa7efd1 Mon Sep 17 00:00:00 2001 From: kchojn Date: Wed, 4 Jun 2025 14:17:24 +0700 Subject: [PATCH 46/53] pr comments fixes --- cli/operator/node.go | 4 +- eth/ethtest/common_test.go | 51 +++--- eth/ethtest/eth_e2e_test.go | 5 +- eth/eventhandler/event_handler_test.go | 2 +- eth/eventsyncer/event_syncer_test.go | 2 +- eth/executionclient/config.go | 35 ----- eth/executionclient/execution_client.go | 154 ++++++++----------- eth/executionclient/execution_client_test.go | 113 +++++++++----- eth/executionclient/multi_client.go | 12 +- eth/executionclient/multi_client_test.go | 15 +- eth/executionclient/options.go | 14 ++ networkconfig/test-network.go | 2 +- 12 files changed, 206 insertions(+), 203 deletions(-) diff --git a/cli/operator/node.go b/cli/operator/node.go index fa7f0c5144..e7f08551af 100644 --- a/cli/operator/node.go +++ b/cli/operator/node.go @@ -286,7 +286,7 @@ var StartNodeCmd = &cobra.Command{ if len(executionAddrList) == 1 { ec, err := executionclient.New( cmd.Context(), - executionclient.NewConfigFromNetworkConfig(networkConfig), + networkConfig, executionAddrList[0], ssvNetworkConfig.RegistryContractAddr, executionclient.WithLogger(logger), @@ -302,7 +302,7 @@ var StartNodeCmd = &cobra.Command{ } else { ec, err := executionclient.NewMulti( cmd.Context(), - executionclient.NewConfigFromNetworkConfig(networkConfig), + networkConfig, executionAddrList, ssvNetworkConfig.RegistryContractAddr, executionclient.WithLoggerMulti(logger), diff --git a/eth/ethtest/common_test.go b/eth/ethtest/common_test.go index 85a4e7f52c..6ae20c0744 100644 --- a/eth/ethtest/common_test.go +++ b/eth/ethtest/common_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/ethereum/go-ethereum/accounts/abi" @@ -54,20 +55,21 @@ func NewCommonTestInput( } type TestEnv struct { - eventSyncer *eventsyncer.EventSyncer - validators []*testValidatorData - ops []*testOperator - nodeStorage storage.Storage - sim *simulator.Backend - boundContract *simcontract.Simcontract - auth *bind.TransactOpts - shares [][]byte - execClient *executionclient.ExecutionClient - rpcServer *rpc.Server - httpSrv *httptest.Server - validatorCtrl *mocks.MockController - mockCtrl *gomock.Controller - execClientConfig executionclient.Config + eventSyncer *eventsyncer.EventSyncer + validators []*testValidatorData + ops []*testOperator + nodeStorage storage.Storage + sim *simulator.Backend + boundContract *simcontract.Simcontract + auth *bind.TransactOpts + shares [][]byte + execClient *executionclient.ExecutionClient + rpcServer *rpc.Server + httpSrv *httptest.Server + validatorCtrl *mocks.MockController + mockCtrl *gomock.Controller + networkConfig networkconfig.NetworkConfig + followDistance uint64 } func (e *TestEnv) shutdown() { @@ -95,15 +97,19 @@ func (e *TestEnv) setup( ) error { logger := zaptest.NewLogger(t) - // set up basic network/fork stuff - e.execClientConfig = executionclient.NewConfigFromNetworkConfig(networkconfig.TestNetwork) + // Set up network config with recent genesis time to avoid high epoch calculations + e.networkConfig = networkconfig.TestNetwork + e.networkConfig.GenesisTime = time.Now().Add(-1 * time.Minute) // Recent genesis - finalityEpoch := phase0.Epoch(1000) // Pre-fork config (use follow distance) if useFinalityFork { - finalityEpoch = 1 // Post-fork config (use finality consensus) - } + e.followDistance = 0 + e.networkConfig.SSVConfig.Forks.Forks[1].Epoch = phase0.Epoch(1) + e.networkConfig.GenesisTime = time.Now().Add(-10 * time.Hour) // A bit earlier genesis time + } else { + e.followDistance = executionclient.DefaultFollowDistance + e.networkConfig.SSVConfig.Forks.Forks[1].Epoch = phase0.Epoch(1000000) - e.execClientConfig = e.execClientConfig.WithFinalityConsensusEpoch(finalityEpoch) + } // Create operators RSA keys ops, err := createOperators(operatorsCount, 0) @@ -180,10 +186,11 @@ func (e *TestEnv) setup( e.execClient, err = executionclient.New( ctx, - e.execClientConfig, + e.networkConfig, addr, contractAddr, executionclient.WithLogger(logger), + executionclient.WithFollowDistance(e.followDistance), ) if err != nil { return err @@ -218,7 +225,7 @@ func (e *TestEnv) setup( // MineAndFinalize mines enough blocks to ensure finality. func (e *TestEnv) MineAndFinalize(blockNum *uint64) { - for i := uint64(0); i < e.execClientConfig.SlotsPerEpoch*2; i++ { + for i := uint64(0); i < e.networkConfig.SlotsPerEpoch*2; i++ { commitBlock(e.sim, blockNum) } } diff --git a/eth/ethtest/eth_e2e_test.go b/eth/ethtest/eth_e2e_test.go index a2d64defa7..0a75ece980 100644 --- a/eth/ethtest/eth_e2e_test.go +++ b/eth/ethtest/eth_e2e_test.go @@ -121,12 +121,15 @@ func runTestEthExecLayer(t *testing.T, useFinalityFork bool) { } else { // When using follow distance, the last handled block is the current block minus follow distance currentBlock, err := testEnv.sim.Client().BlockNumber(ctx) + t.Logf("Current block number: %d", currentBlock) require.NoError(t, err) - expectedLastHandledBlock = currentBlock - testEnv.execClientConfig.FollowDistance + expectedLastHandledBlock = currentBlock - testEnv.followDistance + t.Logf("Expected last handled block: %d", expectedLastHandledBlock) } // Run SyncHistory lastHandledBlockNum, err = eventSyncer.SyncHistory(ctx, lastHandledBlockNum) + t.Logf("Last handled block number after SyncHistory: %d", lastHandledBlockNum) require.NoError(t, err) // Check that the last handled block number matches our expectation diff --git a/eth/eventhandler/event_handler_test.go b/eth/eventhandler/event_handler_test.go index 8ebc1e5b4f..25baff7762 100644 --- a/eth/eventhandler/event_handler_test.go +++ b/eth/eventhandler/event_handler_test.go @@ -115,7 +115,7 @@ func TestHandleBlockEventsStream(t *testing.T) { // Create a client and connect to the simulator client, err := executionclient.New(ctx, - executionclient.NewConfigFromNetworkConfig(networkconfig.TestNetwork), + networkconfig.TestNetwork, addr, contractAddr, executionclient.WithLogger(logger)) diff --git a/eth/eventsyncer/event_syncer_test.go b/eth/eventsyncer/event_syncer_test.go index 1ca7ef3843..b41f99d007 100644 --- a/eth/eventsyncer/event_syncer_test.go +++ b/eth/eventsyncer/event_syncer_test.go @@ -89,7 +89,7 @@ func TestEventSyncer(t *testing.T) { addr := "ws:" + strings.TrimPrefix(httpSrv.URL, "http:") client, err := executionclient.New(ctx, - executionclient.NewConfigFromNetworkConfig(networkconfig.TestNetwork), + networkconfig.TestNetwork, addr, contractAddr, executionclient.WithLogger(logger)) diff --git a/eth/executionclient/config.go b/eth/executionclient/config.go index 3680a2b3d0..63221935c3 100644 --- a/eth/executionclient/config.go +++ b/eth/executionclient/config.go @@ -2,10 +2,6 @@ package executionclient import ( "time" - - "github.com/attestantio/go-eth2-client/spec/phase0" - - "github.com/ssvlabs/ssv/networkconfig" ) // TODO: rename eth1, consider combining with consensus client options @@ -16,34 +12,3 @@ type Options struct { ConnectionTimeout time.Duration `yaml:"ETH1ConnectionTimeout" env:"ETH_1_CONNECTION_TIMEOUT" env-default:"10s" env-description:"Timeout for execution client connections"` SyncDistanceTolerance uint64 `yaml:"ETH1SyncDistanceTolerance" env:"ETH_1_SYNC_DISTANCE_TOLERANCE" env-default:"5" env-description:"Maximum number of blocks behind head considered in-sync"` } - -type Config struct { - SlotsPerEpoch uint64 // Slots per epoch - FinalityConsensusEpoch phase0.Epoch // Epoch at which finality fork activates - FollowDistance uint64 // Number of blocks to follow behind head -} - -// NewConfigFromNetworkConfig creates a new Config with network-specific values -// and default values for other parameters. -func NewConfigFromNetworkConfig(networkCfg networkconfig.NetworkConfig) Config { - return Config{ - SlotsPerEpoch: networkCfg.SlotsPerEpoch, - FinalityConsensusEpoch: networkCfg.SSVConfig.Forks.GetFinalityConsensusEpoch(), - FollowDistance: DefaultFollowDistance, - } -} - -func (c Config) WithSlotsPerEpoch(slots uint64) Config { - c.SlotsPerEpoch = slots - return c -} - -func (c Config) WithFinalityConsensusEpoch(epoch phase0.Epoch) Config { - c.FinalityConsensusEpoch = epoch - return c -} - -func (c Config) WithFollowDistance(distance uint64) Config { - c.FollowDistance = distance - return c -} diff --git a/eth/executionclient/execution_client.go b/eth/executionclient/execution_client.go index b0c5830467..bdc2093e29 100644 --- a/eth/executionclient/execution_client.go +++ b/eth/executionclient/execution_client.go @@ -22,6 +22,7 @@ import ( "github.com/ssvlabs/ssv/eth/contract" "github.com/ssvlabs/ssv/logging/fields" + "github.com/ssvlabs/ssv/networkconfig" "github.com/ssvlabs/ssv/observability" ) @@ -52,7 +53,7 @@ var _ Provider = &ExecutionClient{} // ExecutionClient represents a client for interacting with Ethereum execution client. type ExecutionClient struct { // mandatory - config Config + networkConfig networkconfig.NetworkConfig nodeAddr string contractAddress ethcommon.Address @@ -61,32 +62,33 @@ type ExecutionClient struct { connectionTimeout time.Duration healthInvalidationInterval time.Duration logBatchSize uint64 + followDistance uint64 syncDistanceTolerance uint64 syncProgressFn func(context.Context) (*ethereum.SyncProgress, error) // variables - client *ethclient.Client - closed chan struct{} - lastSyncedTime atomic.Int64 - isPostForkState atomic.Bool // TODO: use a fork name + client *ethclient.Client + closed chan struct{} + lastSyncedTime atomic.Int64 } // New creates a new instance of ExecutionClient. func New(ctx context.Context, - config Config, + networkConfig networkconfig.NetworkConfig, nodeAddr string, contractAddr ethcommon.Address, opts ...Option, ) (*ExecutionClient, error) { client := &ExecutionClient{ - config: config, + networkConfig: networkConfig, nodeAddr: nodeAddr, contractAddress: contractAddr, logger: zap.NewNop(), connectionTimeout: DefaultConnectionTimeout, healthInvalidationInterval: DefaultHealthInvalidationInterval, logBatchSize: DefaultHistoricalLogsBatchSize, // TODO Make batch of logs adaptive depending on "websocket: read limit" + followDistance: DefaultFollowDistance, closed: make(chan struct{}), } for _, opt := range opts { @@ -125,7 +127,19 @@ func (ec *ExecutionClient) Close() error { func (ec *ExecutionClient) FetchHistoricalLogs(ctx context.Context, fromBlock uint64) (logs <-chan BlockLogs, errors <-chan error, err error) { var toBlock uint64 - if ec.isPostForkState.Load() { + header, err := ec.client.HeaderByNumber(ctx, nil) + if err != nil { + ec.logger.Error(elResponseErrMsg, + zap.String("method", "eth_getBlockByNumber"), + zap.Error(err)) + return nil, nil, fmt.Errorf("failed to get block header: %w", err) + } + + currentBlock := header.Number.Uint64() + currentEpoch := ec.epochFromBlockHeader(header) + + if currentEpoch > ec.networkConfig.SSVConfig.Forks.GetFinalityConsensusEpoch() { + // Post-fork: use finalized block toBlock, err = ec.getFinalizedBlock(ctx) if err != nil { ec.logger.Error(elResponseErrMsg, @@ -135,35 +149,11 @@ func (ec *ExecutionClient) FetchHistoricalLogs(ctx context.Context, fromBlock ui return nil, nil, err } } else { - currentBlock, err := ec.client.BlockNumber(ctx) - if err != nil { - ec.logger.Error(elResponseErrMsg, - zap.String("method", "eth_blockNumber"), - zap.Error(err)) - return nil, nil, fmt.Errorf("failed to get current block: %w", err) - } - - // Check if we're past the fork - currentEpoch := phase0.Epoch(currentBlock / ec.config.SlotsPerEpoch) - - if currentEpoch > ec.config.FinalityConsensusEpoch { - // Just passed the fork threshold - ec.isPostForkState.Store(true) - toBlock, err = ec.getFinalizedBlock(ctx) - if err != nil { - ec.logger.Error(elResponseErrMsg, - zap.String("method", "eth_getBlockByNumber"), - zap.String("tag", "finalized"), - zap.Error(err)) - return nil, nil, err - } - } else { - // Pre-fork: use follow distance - if currentBlock < ec.config.FollowDistance { - return nil, nil, ErrNothingToSync - } - toBlock = currentBlock - ec.config.FollowDistance + // Pre-fork: use follow distance + if currentBlock < ec.followDistance { + return nil, nil, ErrNothingToSync } + toBlock = currentBlock - ec.followDistance } if toBlock < fromBlock { @@ -451,29 +441,20 @@ func (ec *ExecutionClient) healthy(ctx context.Context) error { } // 3. Check finalized block availability (post-fork only) - if ec.isPostForkState.Load() { + header, err := ec.client.HeaderByNumber(ctx, nil) + if err != nil { + recordExecutionClientStatus(ctx, statusFailure, ec.nodeAddr) + return err + } + + currentEpoch := ec.epochFromBlockHeader(header) + + if currentEpoch > ec.networkConfig.SSVConfig.Forks.GetFinalityConsensusEpoch() { _, err := ec.getFinalizedBlock(ctx) if err != nil { recordExecutionClientStatus(ctx, statusFailure, ec.nodeAddr) return err } - } else { - // Check if we've just passed the fork point - currentBlock, err := ec.client.BlockNumber(ctx) - if err != nil { - recordExecutionClientStatus(ctx, statusFailure, ec.nodeAddr) - return err - } - - currentEpoch := phase0.Epoch(currentBlock / ec.config.SlotsPerEpoch) - if currentEpoch > ec.config.FinalityConsensusEpoch { - ec.isPostForkState.Store(true) - _, err := ec.getFinalizedBlock(ctx) - if err != nil { - recordExecutionClientStatus(ctx, statusFailure, ec.nodeAddr) - return err - } - } } recordExecutionClientStatus(ctx, statusReady, ec.nodeAddr) @@ -585,7 +566,9 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logCh chan<- Bl var toBlock uint64 // Determine target block based on fork state - if ec.isPostForkState.Load() { + currentEpoch := ec.epochFromBlockHeader(header) + + if currentEpoch > ec.networkConfig.SSVConfig.Forks.GetFinalityConsensusEpoch() { // Post-fork: use finalized block finalizedBlock, err := ec.getFinalizedBlock(ctx) if err != nil { @@ -594,40 +577,31 @@ func (ec *ExecutionClient) streamLogsToChan(ctx context.Context, logCh chan<- Bl toBlock = finalizedBlock if toBlock != lastFinalized { - finalizedEpoch := phase0.Epoch(toBlock / ec.config.SlotsPerEpoch) - ec.logger.Info("⏱ finalized block changed", - zap.Uint64("new_finalized", toBlock), - zap.Uint64("estimated_epoch", uint64(finalizedEpoch)), - zap.Uint64("previous_finalized", lastFinalized)) + finalizedHeader, err := ec.client.HeaderByNumber(ctx, new(big.Int).SetUint64(toBlock)) + if err == nil { + finalizedEpoch := ec.epochFromBlockHeader(finalizedHeader) + ec.logger.Info("⏱ finalized block changed", + zap.Uint64("new_finalized", toBlock), + zap.Uint64("estimated_epoch", uint64(finalizedEpoch)), + zap.Uint64("previous_finalized", lastFinalized)) + } lastFinalized = toBlock } } else { - // Check if we need to transition to post-fork - currentEpoch := phase0.Epoch(headerNum / ec.config.SlotsPerEpoch) - - if currentEpoch > ec.config.FinalityConsensusEpoch { - ec.isPostForkState.Store(true) - finalizedBlock, err := ec.getFinalizedBlock(ctx) - if err != nil { - return fromBlock, err - } - - toBlock = finalizedBlock - } else { - // Pre-fork: follow distance approach - if headerNum < ec.config.FollowDistance { - continue - } - toBlock = headerNum - ec.config.FollowDistance + // Pre-fork: follow distance approach + if headerNum < ec.followDistance { + continue } + toBlock = headerNum - ec.followDistance } // Skip if toBlock is less than fromBlock if toBlock < fromBlock { + isUsingFinalized := currentEpoch > ec.networkConfig.SSVConfig.Forks.GetFinalityConsensusEpoch() ec.logger.Info("waiting for target block to reach fromBlock", fields.FromBlock(fromBlock), fields.ToBlock(toBlock), - zap.Bool("finalized_fork", ec.isPostForkState.Load())) + zap.Bool("finalized_fork", isUsingFinalized)) continue } @@ -662,27 +636,21 @@ func (ec *ExecutionClient) ChainID(ctx context.Context) (*big.Int, error) { // IsFinalizedFork returns whether finalized blocks should be used instead of follow distance. // Returns true if we've passed the finality fork epoch threshold. func (ec *ExecutionClient) IsFinalizedFork(ctx context.Context) bool { - if ec.isPostForkState.Load() { - return true - } - - // Only make this RPC call if we don't know our fork state yet - currentBlock, err := ec.client.BlockNumber(ctx) + header, err := ec.client.HeaderByNumber(ctx, nil) if err != nil { ec.logger.Error(elResponseErrMsg, - zap.String("method", "eth_blockNumber"), + zap.String("method", "eth_getBlockByNumber"), zap.Error(err)) return false } - currentEpoch := phase0.Epoch(currentBlock / ec.config.SlotsPerEpoch) + currentEpoch := ec.epochFromBlockHeader(header) // Check if we've passed the fork point - if currentEpoch > ec.config.FinalityConsensusEpoch { - ec.isPostForkState.Store(true) + if currentEpoch > ec.networkConfig.SSVConfig.Forks.GetFinalityConsensusEpoch() { ec.logger.Info("finality fork threshold passed, using finalized blocks", zap.Uint64("current_epoch", uint64(currentEpoch)), - zap.Uint64("finality_fork_epoch", uint64(ec.config.FinalityConsensusEpoch))) + zap.Uint64("finality_fork_epoch", uint64(ec.networkConfig.SSVConfig.Forks.GetFinalityConsensusEpoch()))) return true } @@ -697,6 +665,14 @@ func (ec *ExecutionClient) getFinalizedBlock(ctx context.Context) (uint64, error return finalizedBlock.Number.Uint64(), nil } +// epochFromBlockHeader calculates the epoch from a block header +func (ec *ExecutionClient) epochFromBlockHeader(header *ethtypes.Header) phase0.Epoch { + blockTime := time.Unix(int64(header.Time), 0) // #nosec G115 + + slot := ec.networkConfig.EstimatedSlotAtTime(blockTime) + return ec.networkConfig.EstimatedEpochAtSlot(slot) +} + // connect connects to Ethereum execution client. // It must not be called twice in parallel. func (ec *ExecutionClient) connect(ctx context.Context) error { diff --git a/eth/executionclient/execution_client_test.go b/eth/executionclient/execution_client_test.go index 56aa31b884..0a45a08a16 100644 --- a/eth/executionclient/execution_client_test.go +++ b/eth/executionclient/execution_client_test.go @@ -129,16 +129,16 @@ func (env *testEnv) deployCallableContract() (*bind.BoundContract, error) { } // createClient creates and validates a new execution client with given options. -func (env *testEnv) createClient(cfg Config, options ...Option) error { - return env.createClientWithCleanup(true, cfg, options...) +func (env *testEnv) createClient(networkCfg networkconfig.NetworkConfig, options ...Option) error { + return env.createClientWithCleanup(true, networkCfg, options...) } // createClientWithCleanup creates and initializes an execution client, optionally registering it for cleanup. // If registerCleanup is false, the caller is responsible for closing the client. -func (env *testEnv) createClientWithCleanup(registerCleanup bool, cfg Config, options ...Option) error { +func (env *testEnv) createClientWithCleanup(registerCleanup bool, networkCfg networkconfig.NetworkConfig, options ...Option) error { allOptions := append([]Option{}, options...) var err error - env.client, err = New(env.ctx, cfg, env.wsURL, env.contractAddr, allOptions...) + env.client, err = New(env.ctx, networkCfg, env.wsURL, env.contractAddr, allOptions...) if err != nil { return err } @@ -182,7 +182,7 @@ func TestFetchHistoricalLogs(t *testing.T) { // Create a client and connect to the simulator with finality fork enabled err = env.createClient( - NewConfigFromNetworkConfig(networkconfig.TestNetwork), + networkconfig.TestNetwork, WithLogger(logger), WithConnectionTimeout(2*time.Second), ) @@ -220,10 +220,17 @@ func TestFetchHistoricalLogs(t *testing.T) { // Create a client with finality fork disabled (using follow distance) const followDistance = 8 + + // Create a test network config with recent genesis time and high finality fork epoch + testNetwork := networkconfig.TestNetwork + testNetwork.GenesisTime = time.Now().Add(-1 * time.Minute) // Recent genesis + testNetwork.SSVConfig.Forks.Forks[1].Epoch = 10000 // High epoch to ensure pre-fork + err = env.createClient( - NewConfigFromNetworkConfig(networkconfig.TestNetwork).WithFollowDistance(followDistance), + testNetwork, WithLogger(logger), WithConnectionTimeout(2*time.Second), + WithFollowDistance(followDistance), ) require.NoError(t, err) @@ -259,10 +266,17 @@ func TestFetchHistoricalLogs(t *testing.T) { // Create a client with a large followDistance and finality fork disabled const followDistance = 100 // Much larger than the current block number + + // Create a test network config with recent genesis time and high finality fork epoch + testNetwork := networkconfig.TestNetwork + testNetwork.GenesisTime = time.Now().Add(-1 * time.Minute) // Recent genesis + testNetwork.SSVConfig.Forks.Forks[1].Epoch = 10000 // High epoch to ensure pre-fork + err = env.createClient( - NewConfigFromNetworkConfig(networkconfig.TestNetwork).WithFollowDistance(followDistance), + testNetwork, WithLogger(logger), WithConnectionTimeout(2*time.Second), + WithFollowDistance(followDistance), ) require.NoError(t, err) @@ -281,9 +295,10 @@ func TestFetchHistoricalLogs(t *testing.T) { // Create a client with finality fork disabled const followDistance = 8 err = env.createClient( - NewConfigFromNetworkConfig(networkconfig.TestNetwork).WithFollowDistance(followDistance), + networkconfig.TestNetwork, WithLogger(logger), WithConnectionTimeout(2*time.Second), + WithFollowDistance(followDistance), ) require.NoError(t, err) @@ -304,14 +319,14 @@ func TestFetchHistoricalLogs(t *testing.T) { require.Nil(t, fetchErrCh) }) - t.Run("error when BlockNumber fails", func(t *testing.T) { + t.Run("error when HeaderByNumber fails", func(t *testing.T) { env := setupTestEnv(t, 1*time.Second) _, err := env.deployCallableContract() require.NoError(t, err) // Create a client - connection should succeed initially err = env.createClient( - NewConfigFromNetworkConfig(networkconfig.TestNetwork), + networkconfig.TestNetwork, WithLogger(logger), WithConnectionTimeout(100*time.Millisecond), ) @@ -321,12 +336,12 @@ func TestFetchHistoricalLogs(t *testing.T) { blockNumCtx, blockNumCancel := context.WithTimeout(env.ctx, 1*time.Nanosecond) defer blockNumCancel() - // Fetch logs - should fail because BlockNumber returns an error + // Fetch logs - should fail because HeaderByNumber will timeout logs, fetchErrCh, err := env.client.FetchHistoricalLogs(blockNumCtx, 0) require.Error(t, err) require.Nil(t, logs) require.Nil(t, fetchErrCh) - require.ErrorContains(t, err, "failed to get current block") + require.ErrorIs(t, err, context.DeadlineExceeded) }) } @@ -430,11 +445,17 @@ func TestFetchHistoricalLogs_Subdivide(t *testing.T) { srv := httptest.NewServer(wrapped) t.Cleanup(srv.Close) - opts := []Option{WithLogBatchSize(100000)} + opts := []Option{WithLogBatchSize(100000), + WithFollowDistance(0), + } + + // Create a test network config with recent genesis time and high finality fork epoch + testNetwork := networkconfig.TestNetwork + testNetwork.GenesisTime = time.Now().Add(-1 * time.Minute) // Recent genesis + testNetwork.SSVConfig.Forks.Forks[1].Epoch = 10000 // High epoch to ensure pre-fork - cfg := NewConfigFromNetworkConfig(networkconfig.TestNetwork).WithFollowDistance(0) client, err := New(t.Context(), - cfg, + testNetwork, srv.URL, env.contractAddr, opts..., @@ -479,7 +500,7 @@ func TestStreamLogs(t *testing.T) { // Create a client and connect to the simulator with finality fork enabled err = env.createClient( - NewConfigFromNetworkConfig(networkconfig.TestNetwork), + networkconfig.TestNetwork, WithLogger(logger)) require.NoError(t, err) @@ -529,9 +550,17 @@ func TestStreamLogs(t *testing.T) { // Create a client with explicit follow distance and disabled finality fork const followDistance = 2 + + // Create a test network config with recent genesis time and high finality fork epoch + testNetwork := networkconfig.TestNetwork + testNetwork.GenesisTime = time.Now().Add(-1 * time.Minute) // Recent genesis + testNetwork.SSVConfig.Forks.Forks[1].Epoch = 10000 // High epoch to ensure pre-fork + err = env.createClient( - NewConfigFromNetworkConfig(networkconfig.TestNetwork).WithFollowDistance(followDistance), - WithLogger(logger)) + testNetwork, + WithLogger(logger), + WithFollowDistance(followDistance), + ) require.NoError(t, err) logsCh := env.client.StreamLogs(env.ctx, 0) @@ -591,7 +620,7 @@ func TestStreamLogs(t *testing.T) { // Create a client and connect to the simulator err = env.createClient( - NewConfigFromNetworkConfig(networkconfig.TestNetwork), + networkconfig.TestNetwork, WithLogger(logger)) require.NoError(t, err) @@ -628,7 +657,7 @@ func TestStreamLogs(t *testing.T) { // Create a client without automatic cleanup err = env.createClientWithCleanup(false, - NewConfigFromNetworkConfig(networkconfig.TestNetwork), + networkconfig.TestNetwork, WithLogger(logger)) require.NoError(t, err) @@ -661,7 +690,7 @@ func TestFetchLogsInBatches(t *testing.T) { require.NoError(t, err) err = env.createClient( - NewConfigFromNetworkConfig(networkconfig.TestNetwork), + networkconfig.TestNetwork, WithLogger(logger), WithLogBatchSize(2)) require.NoError(t, err) @@ -755,7 +784,7 @@ func TestChainReorganizationLogs(t *testing.T) { // 2. Create a client and set up subscription with finality fork enabled err = env.createClient( - NewConfigFromNetworkConfig(networkconfig.TestNetwork), + networkconfig.TestNetwork, WithLogger(logger)) require.NoError(t, err) @@ -857,8 +886,9 @@ func TestChainReorganizationLogs(t *testing.T) { // 2. Create a client with follow distance mechanism (finality fork disabled) const followDistance = 5 err = env.createClient( - NewConfigFromNetworkConfig(networkconfig.TestNetwork).WithFollowDistance(followDistance), + networkconfig.TestNetwork, WithLogger(logger), + WithFollowDistance(followDistance), ) require.NoError(t, err) @@ -989,7 +1019,7 @@ func TestSimSSV(t *testing.T) { // Create a client and connect to the simulator with finality fork enabled err = env.createClient( - NewConfigFromNetworkConfig(networkconfig.TestNetwork), + networkconfig.TestNetwork, WithLogger(logger)) require.NoError(t, err) @@ -1058,9 +1088,16 @@ func TestSimSSV(t *testing.T) { // Create a client and connect to the simulator with follow distance const followDistance = 2 + + // Create a test network config with recent genesis time and high finality fork epoch + testNetwork := networkconfig.TestNetwork + testNetwork.GenesisTime = time.Now().Add(-1 * time.Minute) // Recent genesis + testNetwork.SSVConfig.Forks.Forks[1].Epoch = 10000 // High epoch to ensure pre-fork + err = env.createClient( - NewConfigFromNetworkConfig(networkconfig.TestNetwork).WithFollowDistance(followDistance), + testNetwork, WithLogger(logger), + WithFollowDistance(followDistance), ) require.NoError(t, err) @@ -1142,7 +1179,7 @@ func TestFilterLogs(t *testing.T) { // Create a client and connect to the simulator err = env.createClient( - NewConfigFromNetworkConfig(networkconfig.TestNetwork), + networkconfig.TestNetwork, WithLogger(logger)) require.NoError(t, err) @@ -1177,7 +1214,7 @@ func TestFilterLogs(t *testing.T) { // Create a client - connection should succeed initially err = env.createClient( - NewConfigFromNetworkConfig(networkconfig.TestNetwork), + networkconfig.TestNetwork, WithLogger(logger), WithConnectionTimeout(100*time.Millisecond), ) @@ -1211,7 +1248,7 @@ func TestSubscribeFilterLogs(t *testing.T) { // Create a client and connect to the simulator err = env.createClient( - NewConfigFromNetworkConfig(networkconfig.TestNetwork), + networkconfig.TestNetwork, WithLogger(logger)) require.NoError(t, err) @@ -1273,7 +1310,7 @@ func TestSubscribeFilterLogs(t *testing.T) { // Create a client - connection should succeed initially err = env.createClient( - NewConfigFromNetworkConfig(networkconfig.TestNetwork), + networkconfig.TestNetwork, WithLogger(logger), WithConnectionTimeout(100*time.Millisecond), ) @@ -1309,7 +1346,7 @@ func TestBlockByNumber(t *testing.T) { // Create a client and connect to the simulator err = env.createClient( - NewConfigFromNetworkConfig(networkconfig.TestNetwork), + networkconfig.TestNetwork, WithLogger(logger)) require.NoError(t, err) @@ -1340,7 +1377,7 @@ func TestBlockByNumber(t *testing.T) { // Create a client - connection should succeed initially err = env.createClient( - NewConfigFromNetworkConfig(networkconfig.TestNetwork), + networkconfig.TestNetwork, WithLogger(logger), WithConnectionTimeout(100*time.Millisecond), ) @@ -1370,7 +1407,7 @@ func TestHeaderByNumber(t *testing.T) { // Create a client and connect to the simulator err = env.createClient( - NewConfigFromNetworkConfig(networkconfig.TestNetwork), + networkconfig.TestNetwork, WithLogger(logger)) require.NoError(t, err) @@ -1401,7 +1438,7 @@ func TestHeaderByNumber(t *testing.T) { // Create a client - connection should succeed initially err = env.createClient( - NewConfigFromNetworkConfig(networkconfig.TestNetwork), + networkconfig.TestNetwork, WithLogger(logger), WithConnectionTimeout(100*time.Millisecond), ) @@ -1429,7 +1466,7 @@ func TestFilterer(t *testing.T) { // Create a client and connect to the simulator err = env.createClient( - NewConfigFromNetworkConfig(networkconfig.TestNetwork), + networkconfig.TestNetwork, WithLogger(logger)) require.NoError(t, err) @@ -1449,7 +1486,7 @@ func TestSyncProgress(t *testing.T) { // Create a client and connect to the simulator err = env.createClient( - NewConfigFromNetworkConfig(networkconfig.TestNetwork), + networkconfig.TestNetwork, WithHealthInvalidationInterval(0)) require.NoError(t, err) @@ -1470,7 +1507,7 @@ func TestSyncProgress(t *testing.T) { t.Run("within tolerable limits", func(t *testing.T) { client, err := New( env.ctx, - NewConfigFromNetworkConfig(networkconfig.TestNetwork), + networkconfig.TestNetwork, env.wsURL, env.contractAddr, WithSyncDistanceTolerance(2), @@ -1498,7 +1535,7 @@ func TestHealthy(t *testing.T) { // Create a client and connect to the simulator err = env.createClientWithCleanup(false, - NewConfigFromNetworkConfig(networkconfig.TestNetwork), + networkconfig.TestNetwork, ) require.NoError(t, err) @@ -1517,7 +1554,7 @@ func TestHealthy(t *testing.T) { // Create a client with a health invalidation interval err = env.createClient( - NewConfigFromNetworkConfig(networkconfig.TestNetwork), + networkconfig.TestNetwork, WithHealthInvalidationInterval(10*time.Second)) require.NoError(t, err) diff --git a/eth/executionclient/multi_client.go b/eth/executionclient/multi_client.go index cba89386b5..ee00dd4fa8 100644 --- a/eth/executionclient/multi_client.go +++ b/eth/executionclient/multi_client.go @@ -16,6 +16,8 @@ import ( "go.uber.org/zap" "go.uber.org/zap/zapcore" + "github.com/ssvlabs/ssv/networkconfig" + "github.com/ssvlabs/ssv/eth/contract" "github.com/ssvlabs/ssv/logging/fields" ) @@ -52,7 +54,7 @@ var _ Provider = &MultiClient{} // The execution MultiClient switches to EL2, the consensus multi client switches to CL2, // This shouldn't cause significant duty misses. type MultiClient struct { - config Config + networkConfig networkconfig.NetworkConfig // optional logger *zap.Logger @@ -60,6 +62,7 @@ type MultiClient struct { healthInvalidationInterval time.Duration logBatchSize uint64 syncDistanceTolerance uint64 + followDistance uint64 contractAddress ethcommon.Address chainID atomic.Pointer[big.Int] @@ -75,7 +78,7 @@ type MultiClient struct { // NewMulti creates a new instance of MultiClient. func NewMulti( ctx context.Context, - config Config, + networkConfig networkconfig.NetworkConfig, nodeAddrs []string, contractAddr ethcommon.Address, opts ...OptionMulti, @@ -85,7 +88,7 @@ func NewMulti( } multiClient := &MultiClient{ - config: config, + networkConfig: networkConfig, nodeAddrs: nodeAddrs, clients: make([]SingleClientProvider, len(nodeAddrs)), // initialized with nil values (not connected) clientsMu: make([]sync.Mutex, len(nodeAddrs)), @@ -93,6 +96,7 @@ func NewMulti( logger: zap.NewNop(), connectionTimeout: DefaultConnectionTimeout, logBatchSize: DefaultHistoricalLogsBatchSize, + followDistance: DefaultFollowDistance, } for _, opt := range opts { @@ -148,7 +152,7 @@ func (mc *MultiClient) connect(ctx context.Context, clientIndex int) error { singleClient, err := New( ctx, - mc.config, + mc.networkConfig, mc.nodeAddrs[clientIndex], mc.contractAddress, WithLogger(logger), diff --git a/eth/executionclient/multi_client_test.go b/eth/executionclient/multi_client_test.go index c96b03e055..802eec7ea4 100644 --- a/eth/executionclient/multi_client_test.go +++ b/eth/executionclient/multi_client_test.go @@ -11,7 +11,6 @@ import ( "testing" "time" - "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/ethereum/go-ethereum" ethcommon "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" @@ -27,7 +26,7 @@ func TestNewMulti(t *testing.T) { t.Run("no node addresses", func(t *testing.T) { ctx := t.Context() - mc, err := NewMulti(ctx, NewConfigFromNetworkConfig(networkconfig.TestNetwork), []string{}, ethcommon.Address{}) + mc, err := NewMulti(ctx, networkconfig.TestNetwork, []string{}, ethcommon.Address{}) require.Nil(t, mc, "MultiClient should be nil on error") require.Error(t, err, "expected an error due to no node addresses") @@ -39,7 +38,7 @@ func TestNewMulti(t *testing.T) { addr := "invalid-addr" addresses := []string{addr} - mc, err := NewMulti(ctx, NewConfigFromNetworkConfig(networkconfig.TestNetwork), addresses, ethcommon.Address{}) + mc, err := NewMulti(ctx, networkconfig.TestNetwork, addresses, ethcommon.Address{}) require.Nil(t, mc, "MultiClient should be nil on error") require.Error(t, err) @@ -71,7 +70,7 @@ func TestNewMulti_WithOptions(t *testing.T) { t.Run("pre-fork (follow distance)", func(t *testing.T) { mc, err := NewMulti( ctx, - NewConfigFromNetworkConfig(networkconfig.TestNetwork).WithFollowDistance(customFollowDistance), + networkconfig.TestNetwork, addresses, contractAddr, WithLoggerMulti(customLogger), @@ -79,11 +78,12 @@ func TestNewMulti_WithOptions(t *testing.T) { WithHealthInvalidationIntervalMulti(customHealthInvalidationInterval), WithLogBatchSizeMulti(customLogBatchSize), WithSyncDistanceToleranceMulti(customSyncDistanceTolerance), + WithFollowDistanceMulti(customFollowDistance), ) require.NoError(t, err) require.NotNil(t, mc) require.Equal(t, customLogger.Named("execution_client_multi"), mc.logger) - require.EqualValues(t, customFollowDistance, mc.config.FollowDistance) + require.EqualValues(t, customFollowDistance, mc.followDistance) require.EqualValues(t, customTimeout, mc.connectionTimeout) require.EqualValues(t, customHealthInvalidationInterval, mc.healthInvalidationInterval) require.EqualValues(t, customLogBatchSize, mc.logBatchSize) @@ -91,11 +91,9 @@ func TestNewMulti_WithOptions(t *testing.T) { }) t.Run("post-fork (finality)", func(t *testing.T) { - const customFinalityForkEpoch = phase0.Epoch(5) - mc, err := NewMulti( ctx, - NewConfigFromNetworkConfig(networkconfig.TestNetwork).WithFinalityConsensusEpoch(customFinalityForkEpoch), + networkconfig.TestNetwork, addresses, contractAddr, WithLoggerMulti(customLogger), @@ -111,7 +109,6 @@ func TestNewMulti_WithOptions(t *testing.T) { require.EqualValues(t, customHealthInvalidationInterval, mc.healthInvalidationInterval) require.EqualValues(t, customLogBatchSize, mc.logBatchSize) require.EqualValues(t, customSyncDistanceTolerance, mc.syncDistanceTolerance) - require.EqualValues(t, customFinalityForkEpoch, mc.config.FinalityConsensusEpoch) }) } diff --git a/eth/executionclient/options.go b/eth/executionclient/options.go index d58d031d01..4415878a9b 100644 --- a/eth/executionclient/options.go +++ b/eth/executionclient/options.go @@ -81,3 +81,17 @@ func WithSyncDistanceToleranceMulti(count uint64) OptionMulti { c.syncDistanceTolerance = count } } + +// WithFollowDistance sets the follow distance for pre-fork block processing. +func WithFollowDistance(distance uint64) Option { + return func(c *ExecutionClient) { + c.followDistance = distance + } +} + +// WithFollowDistanceMulti sets the follow distance for pre-fork block processing. +func WithFollowDistanceMulti(distance uint64) OptionMulti { + return func(c *MultiClient) { + c.followDistance = distance + } +} diff --git a/networkconfig/test-network.go b/networkconfig/test-network.go index dea5a783bf..4fe5aa6f0c 100644 --- a/networkconfig/test-network.go +++ b/networkconfig/test-network.go @@ -77,7 +77,7 @@ var TestNetwork = NetworkConfig{ }, { Name: "Finality Consensus", - Epoch: 1, // TODO: use a different name when we have a better one, value as well + Epoch: 100, // TODO: use a different name when we have a better one, value as well }, }, }, From 4ae6ada077296af7229bd543abfe75b91682deeb Mon Sep 17 00:00:00 2001 From: kchojn Date: Wed, 4 Jun 2025 16:09:15 +0700 Subject: [PATCH 47/53] refactor(event_syncer.go): simplify staleness threshold comparison in Healthy method --- eth/eventsyncer/event_syncer.go | 57 +++++++----------- eth/eventsyncer/event_syncer_test.go | 90 +++++++++++++++++++++------- 2 files changed, 90 insertions(+), 57 deletions(-) diff --git a/eth/eventsyncer/event_syncer.go b/eth/eventsyncer/event_syncer.go index 417d0d7bbb..3c726c3622 100644 --- a/eth/eventsyncer/event_syncer.go +++ b/eth/eventsyncer/event_syncer.go @@ -24,8 +24,7 @@ import ( // https://github.com/ssvlabs/ssv/pull/1053 const ( - defaultStalenessThreshold = 300 * time.Second - defaultFinalizedStalenessThreshold = 3 * 32 * 12 * time.Second // 3 epochs // TODO: set a proper value? + defaultStalenessThreshold = 300 * time.Second ) type ExecutionClient interface { @@ -48,8 +47,7 @@ type EventSyncer struct { logger *zap.Logger - stalenessThreshold time.Duration - finalizedStalenessThreshold time.Duration + stalenessThreshold time.Duration lastProcessedBlock uint64 lastProcessedBlockChange time.Time @@ -61,9 +59,8 @@ func New(nodeStorage nodestorage.Storage, executionClient ExecutionClient, event executionClient: executionClient, eventHandler: eventHandler, - logger: zap.NewNop(), - stalenessThreshold: defaultStalenessThreshold, - finalizedStalenessThreshold: defaultFinalizedStalenessThreshold, + logger: zap.NewNop(), + stalenessThreshold: defaultStalenessThreshold, } for _, opt := range opts { @@ -91,13 +88,8 @@ func (es *EventSyncer) Healthy(ctx context.Context) error { } staleness := time.Since(es.lastProcessedBlockChange) - threshold := es.stalenessThreshold - if es.executionClient.IsFinalizedFork(ctx) { - threshold = es.finalizedStalenessThreshold - } - - if staleness > threshold { + if staleness > es.stalenessThreshold { return fmt.Errorf("syncing is stuck at block %d", lastBlockNum) } @@ -106,38 +98,29 @@ func (es *EventSyncer) Healthy(ctx context.Context) error { // blockBelowThreshold checks if the specified block is recent enough. func (es *EventSyncer) blockBelowThreshold(ctx context.Context, block *big.Int) error { - usingFinalized := es.executionClient.IsFinalizedFork(ctx) + header, err := es.executionClient.HeaderByNumber(ctx, block) + if err != nil { + return fmt.Errorf("failed to get header for block %d: %w", block, err) + } + + // #nosec G115 + blockTime := time.Unix(int64(header.Time), 0) + latestBlockTime := time.Now() - if usingFinalized { - // When using finalized blocks, only check if the finalized block is fresh + if es.executionClient.IsFinalizedFork(ctx) { finalizedHeader, err := es.executionClient.HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) if err != nil { return fmt.Errorf("failed to get finalized block header: %w", err) } - // #nosec G115 - blockTime := time.Unix(int64(finalizedHeader.Time), 0) - staleness := time.Since(blockTime) - - if staleness > es.finalizedStalenessThreshold { - return fmt.Errorf("finalized block %d is too old (age: %s)", - finalizedHeader.Number.Uint64(), staleness.Round(time.Second)) - } - } else { - // When using safety distance, check the specific block - header, err := es.executionClient.HeaderByNumber(ctx, block) - if err != nil { - return fmt.Errorf("failed to get header for block %d: %w", block, err) - } + latestBlockTime = time.Unix(int64(finalizedHeader.Time), 0) + } - // #nosec G115 - blockTime := time.Unix(int64(header.Time), 0) - staleness := time.Since(blockTime) + staleness := latestBlockTime.Sub(blockTime) - if staleness > es.stalenessThreshold { - return fmt.Errorf("block %d is too old (age: %s)", - block.Uint64(), staleness.Round(time.Second)) - } + if staleness > es.stalenessThreshold { + return fmt.Errorf("block %d is too old (age: %s)", + block.Uint64(), staleness.Round(time.Second)) } return nil diff --git a/eth/eventsyncer/event_syncer_test.go b/eth/eventsyncer/event_syncer_test.go index b41f99d007..bf79b8f43e 100644 --- a/eth/eventsyncer/event_syncer_test.go +++ b/eth/eventsyncer/event_syncer_test.go @@ -241,63 +241,113 @@ func setupOperatorStorage(logger *zap.Logger, db basedb.Database, privKey keys.O } func TestBlockBelowThreshold(t *testing.T) { - ctrl := gomock.NewController(t) - m := NewMockExecutionClient(ctrl) + t.Parallel() + ctx := t.Context() - s := New(nil, m, nil) + newSyncer := func(m *MockExecutionClient) *EventSyncer { + return New(nil, m, nil) + } t.Run("fails on EC error", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + m := NewMockExecutionClient(ctrl) + err1 := errors.New("ec err") - m.EXPECT().IsFinalizedFork(ctx).Return(false) m.EXPECT().HeaderByNumber(ctx, big.NewInt(1)).Return(nil, err1) + + s := newSyncer(m) err := s.blockBelowThreshold(ctx, big.NewInt(1)) require.ErrorIs(t, err, err1) }) t.Run("fails if outside threshold", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + m := NewMockExecutionClient(ctrl) + header := ðtypes.Header{Time: uint64(time.Now().Add(-(defaultStalenessThreshold + time.Second)).Unix())} m.EXPECT().IsFinalizedFork(ctx).Return(false) m.EXPECT().HeaderByNumber(ctx, big.NewInt(1)).Return(header, nil) - err := s.blockBelowThreshold(ctx, big.NewInt(1)) - require.Error(t, err) + + s := newSyncer(m) + require.Error(t, s.blockBelowThreshold(ctx, big.NewInt(1))) }) t.Run("success", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + m := NewMockExecutionClient(ctrl) + header := ðtypes.Header{Time: uint64(time.Now().Add(-(defaultStalenessThreshold - time.Second)).Unix())} m.EXPECT().IsFinalizedFork(ctx).Return(false) m.EXPECT().HeaderByNumber(ctx, big.NewInt(1)).Return(header, nil) - err := s.blockBelowThreshold(ctx, big.NewInt(1)) - require.NoError(t, err) + + s := newSyncer(m) + require.NoError(t, s.blockBelowThreshold(ctx, big.NewInt(1))) }) t.Run("finalized fork success", func(t *testing.T) { - finalizedHeader := ðtypes.Header{ - Time: uint64(time.Now().Add(-(defaultFinalizedStalenessThreshold - time.Second)).Unix()), - Number: big.NewInt(100), - } + t.Parallel() + + ctrl := gomock.NewController(t) + m := NewMockExecutionClient(ctrl) + + finalizedTime := time.Now().Add(-100 * time.Second) + finalizedHeader := ðtypes.Header{Time: uint64(finalizedTime.Unix()), Number: big.NewInt(100)} + processedTime := finalizedTime.Add(-50 * time.Second) + processedHeader := ðtypes.Header{Time: uint64(processedTime.Unix()), Number: big.NewInt(90)} + + m.EXPECT().HeaderByNumber(ctx, big.NewInt(90)).Return(processedHeader, nil) m.EXPECT().IsFinalizedFork(ctx).Return(true) m.EXPECT().HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())).Return(finalizedHeader, nil) - err := s.blockBelowThreshold(ctx, big.NewInt(1)) // The block parameter is not used when IsFinalizedFork is true - require.NoError(t, err) + + s := newSyncer(m) + require.NoError(t, s.blockBelowThreshold(ctx, big.NewInt(90))) }) t.Run("finalized fork too old", func(t *testing.T) { - finalizedHeader := ðtypes.Header{ - Time: uint64(time.Now().Add(-(defaultFinalizedStalenessThreshold + time.Second)).Unix()), - Number: big.NewInt(100), - } + t.Parallel() + + ctrl := gomock.NewController(t) + m := NewMockExecutionClient(ctrl) + + finalizedTime := time.Now().Add(-100 * time.Second) + finalizedHeader := ðtypes.Header{Time: uint64(finalizedTime.Unix()), Number: big.NewInt(100)} + processedTime := finalizedTime.Add(-400 * time.Second) + processedHeader := ðtypes.Header{Time: uint64(processedTime.Unix()), Number: big.NewInt(50)} + + m.EXPECT().HeaderByNumber(ctx, big.NewInt(50)).Return(processedHeader, nil) m.EXPECT().IsFinalizedFork(ctx).Return(true) m.EXPECT().HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())).Return(finalizedHeader, nil) - err := s.blockBelowThreshold(ctx, big.NewInt(1)) - require.Error(t, err) + + s := newSyncer(m) + require.Error(t, s.blockBelowThreshold(ctx, big.NewInt(50))) }) t.Run("finalized fork error", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + m := NewMockExecutionClient(ctrl) + err1 := errors.New("finalized block error") + processedHeader := ðtypes.Header{ + Time: uint64(time.Now().Add(-(defaultStalenessThreshold + time.Second)).Unix()), + Number: big.NewInt(1), + } + + m.EXPECT().HeaderByNumber(ctx, big.NewInt(1)).Return(processedHeader, nil) m.EXPECT().IsFinalizedFork(ctx).Return(true) m.EXPECT().HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())).Return(nil, err1) + + s := newSyncer(m) err := s.blockBelowThreshold(ctx, big.NewInt(1)) + require.Error(t, err) require.ErrorIs(t, err, err1) }) } From d6dc4de3192c014d2a313c68d4073b51b78d0ef1 Mon Sep 17 00:00:00 2001 From: kchojn Date: Wed, 4 Jun 2025 16:32:54 +0700 Subject: [PATCH 48/53] refactor(execution_client_test.go): extract copyNetworkConfig function to create a deep copy of network configuration for test consistency --- eth/executionclient/execution_client_test.go | 22 +++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/eth/executionclient/execution_client_test.go b/eth/executionclient/execution_client_test.go index 0a45a08a16..fbedc0cdfd 100644 --- a/eth/executionclient/execution_client_test.go +++ b/eth/executionclient/execution_client_test.go @@ -66,6 +66,18 @@ func simTestBackend(testAddr ethcommon.Address) *simulator.Backend { ) } +// copyNetworkConfig creates a deep copy of the provided network configuration. +func copyNetworkConfig(cfg networkconfig.NetworkConfig) networkconfig.NetworkConfig { + result := cfg + + if cfg.SSVConfig.Forks.Forks != nil { + result.SSVConfig.Forks.Forks = make(networkconfig.SSVForks, len(cfg.SSVConfig.Forks.Forks)) + copy(result.SSVConfig.Forks.Forks, cfg.SSVConfig.Forks.Forks) + } + + return result +} + // testEnv is a helper struct to set up and manage test environment. type testEnv struct { t *testing.T @@ -222,7 +234,7 @@ func TestFetchHistoricalLogs(t *testing.T) { const followDistance = 8 // Create a test network config with recent genesis time and high finality fork epoch - testNetwork := networkconfig.TestNetwork + testNetwork := copyNetworkConfig(networkconfig.TestNetwork) testNetwork.GenesisTime = time.Now().Add(-1 * time.Minute) // Recent genesis testNetwork.SSVConfig.Forks.Forks[1].Epoch = 10000 // High epoch to ensure pre-fork @@ -268,7 +280,7 @@ func TestFetchHistoricalLogs(t *testing.T) { const followDistance = 100 // Much larger than the current block number // Create a test network config with recent genesis time and high finality fork epoch - testNetwork := networkconfig.TestNetwork + testNetwork := copyNetworkConfig(networkconfig.TestNetwork) testNetwork.GenesisTime = time.Now().Add(-1 * time.Minute) // Recent genesis testNetwork.SSVConfig.Forks.Forks[1].Epoch = 10000 // High epoch to ensure pre-fork @@ -450,7 +462,7 @@ func TestFetchHistoricalLogs_Subdivide(t *testing.T) { } // Create a test network config with recent genesis time and high finality fork epoch - testNetwork := networkconfig.TestNetwork + testNetwork := copyNetworkConfig(networkconfig.TestNetwork) testNetwork.GenesisTime = time.Now().Add(-1 * time.Minute) // Recent genesis testNetwork.SSVConfig.Forks.Forks[1].Epoch = 10000 // High epoch to ensure pre-fork @@ -552,7 +564,7 @@ func TestStreamLogs(t *testing.T) { const followDistance = 2 // Create a test network config with recent genesis time and high finality fork epoch - testNetwork := networkconfig.TestNetwork + testNetwork := copyNetworkConfig(networkconfig.TestNetwork) testNetwork.GenesisTime = time.Now().Add(-1 * time.Minute) // Recent genesis testNetwork.SSVConfig.Forks.Forks[1].Epoch = 10000 // High epoch to ensure pre-fork @@ -1090,7 +1102,7 @@ func TestSimSSV(t *testing.T) { const followDistance = 2 // Create a test network config with recent genesis time and high finality fork epoch - testNetwork := networkconfig.TestNetwork + testNetwork := copyNetworkConfig(networkconfig.TestNetwork) testNetwork.GenesisTime = time.Now().Add(-1 * time.Minute) // Recent genesis testNetwork.SSVConfig.Forks.Forks[1].Epoch = 10000 // High epoch to ensure pre-fork From 73e79762d6818c4217cc263f99d795ced2e07dcd Mon Sep 17 00:00:00 2001 From: kchojn Date: Wed, 4 Jun 2025 16:44:32 +0700 Subject: [PATCH 49/53] refactor(execution_client_test.go): remove unnecessary copyNetworkConfig function and directly use networkconfig.TestNetwork for test setup to simplify code and improve readability --- eth/executionclient/execution_client_test.go | 23 +++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/eth/executionclient/execution_client_test.go b/eth/executionclient/execution_client_test.go index fbedc0cdfd..3cc6df1a03 100644 --- a/eth/executionclient/execution_client_test.go +++ b/eth/executionclient/execution_client_test.go @@ -66,18 +66,6 @@ func simTestBackend(testAddr ethcommon.Address) *simulator.Backend { ) } -// copyNetworkConfig creates a deep copy of the provided network configuration. -func copyNetworkConfig(cfg networkconfig.NetworkConfig) networkconfig.NetworkConfig { - result := cfg - - if cfg.SSVConfig.Forks.Forks != nil { - result.SSVConfig.Forks.Forks = make(networkconfig.SSVForks, len(cfg.SSVConfig.Forks.Forks)) - copy(result.SSVConfig.Forks.Forks, cfg.SSVConfig.Forks.Forks) - } - - return result -} - // testEnv is a helper struct to set up and manage test environment. type testEnv struct { t *testing.T @@ -234,7 +222,7 @@ func TestFetchHistoricalLogs(t *testing.T) { const followDistance = 8 // Create a test network config with recent genesis time and high finality fork epoch - testNetwork := copyNetworkConfig(networkconfig.TestNetwork) + testNetwork := networkconfig.TestNetwork testNetwork.GenesisTime = time.Now().Add(-1 * time.Minute) // Recent genesis testNetwork.SSVConfig.Forks.Forks[1].Epoch = 10000 // High epoch to ensure pre-fork @@ -280,7 +268,7 @@ func TestFetchHistoricalLogs(t *testing.T) { const followDistance = 100 // Much larger than the current block number // Create a test network config with recent genesis time and high finality fork epoch - testNetwork := copyNetworkConfig(networkconfig.TestNetwork) + testNetwork := networkconfig.TestNetwork testNetwork.GenesisTime = time.Now().Add(-1 * time.Minute) // Recent genesis testNetwork.SSVConfig.Forks.Forks[1].Epoch = 10000 // High epoch to ensure pre-fork @@ -404,7 +392,6 @@ func TestFetchHistoricalLogs_Subdivide(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - t.Parallel() env := setupTestEnv(t, 5*time.Second) contract, err := env.deployCallableContract() @@ -462,7 +449,7 @@ func TestFetchHistoricalLogs_Subdivide(t *testing.T) { } // Create a test network config with recent genesis time and high finality fork epoch - testNetwork := copyNetworkConfig(networkconfig.TestNetwork) + testNetwork := networkconfig.TestNetwork testNetwork.GenesisTime = time.Now().Add(-1 * time.Minute) // Recent genesis testNetwork.SSVConfig.Forks.Forks[1].Epoch = 10000 // High epoch to ensure pre-fork @@ -564,7 +551,7 @@ func TestStreamLogs(t *testing.T) { const followDistance = 2 // Create a test network config with recent genesis time and high finality fork epoch - testNetwork := copyNetworkConfig(networkconfig.TestNetwork) + testNetwork := networkconfig.TestNetwork testNetwork.GenesisTime = time.Now().Add(-1 * time.Minute) // Recent genesis testNetwork.SSVConfig.Forks.Forks[1].Epoch = 10000 // High epoch to ensure pre-fork @@ -1102,7 +1089,7 @@ func TestSimSSV(t *testing.T) { const followDistance = 2 // Create a test network config with recent genesis time and high finality fork epoch - testNetwork := copyNetworkConfig(networkconfig.TestNetwork) + testNetwork := networkconfig.TestNetwork testNetwork.GenesisTime = time.Now().Add(-1 * time.Minute) // Recent genesis testNetwork.SSVConfig.Forks.Forks[1].Epoch = 10000 // High epoch to ensure pre-fork From 17171c6f10f2cee884d3348ba115dcd25fd786f5 Mon Sep 17 00:00:00 2001 From: kchojn Date: Wed, 4 Jun 2025 17:37:41 +0700 Subject: [PATCH 50/53] refactor(execution_client.go): remove logging statement for finality fork threshold passed to improve code readability and reduce noise --- eth/executionclient/execution_client.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/eth/executionclient/execution_client.go b/eth/executionclient/execution_client.go index bdc2093e29..4b5af99f9a 100644 --- a/eth/executionclient/execution_client.go +++ b/eth/executionclient/execution_client.go @@ -648,9 +648,6 @@ func (ec *ExecutionClient) IsFinalizedFork(ctx context.Context) bool { // Check if we've passed the fork point if currentEpoch > ec.networkConfig.SSVConfig.Forks.GetFinalityConsensusEpoch() { - ec.logger.Info("finality fork threshold passed, using finalized blocks", - zap.Uint64("current_epoch", uint64(currentEpoch)), - zap.Uint64("finality_fork_epoch", uint64(ec.networkConfig.SSVConfig.Forks.GetFinalityConsensusEpoch()))) return true } From 503cc9c7fab2aeb557bd3d9ddcdd8c6454967b93 Mon Sep 17 00:00:00 2001 From: kchojn Date: Wed, 4 Jun 2025 19:40:19 +0700 Subject: [PATCH 51/53] refactor(event_syncer.go): improve comments and error messages for better clarity and understanding of the Healthy and blockBelowThreshold functions --- eth/eventsyncer/event_syncer.go | 51 ++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/eth/eventsyncer/event_syncer.go b/eth/eventsyncer/event_syncer.go index 3c726c3622..3e57bfc1e1 100644 --- a/eth/eventsyncer/event_syncer.go +++ b/eth/eventsyncer/event_syncer.go @@ -70,7 +70,10 @@ func New(nodeStorage nodestorage.Storage, executionClient ExecutionClient, event return es } -// Healthy returns nil if the syncer is syncing ongoing events. +// Healthy determines if the EventSyncer is functioning correctly. It returns nil if healthy. +// It checks if the syncer has processed new blocks, hasn't been "stuck" without progress for too long +// (this check is skipped if in finalized mode and already caught up), and if its last processed block +// is sufficiently close to the relevant chain head (current time or EL finalized block time). func (es *EventSyncer) Healthy(ctx context.Context) error { lastProcessedBlock, found, err := es.nodeStorage.GetLastProcessedBlock(nil) if err != nil { @@ -87,40 +90,54 @@ func (es *EventSyncer) Healthy(ctx context.Context) error { return nil } - staleness := time.Since(es.lastProcessedBlockChange) - - if staleness > es.stalenessThreshold { - return fmt.Errorf("syncing is stuck at block %d", lastBlockNum) + // Check if we're making progress (only relevant for follow-distance approach) + if !es.executionClient.IsFinalizedFork(ctx) { + if time.Since(es.lastProcessedBlockChange) > es.stalenessThreshold { + return fmt.Errorf("syncing is stuck at block %d", lastBlockNum) + } } + // Check if our current position is too far behind return es.blockBelowThreshold(ctx, lastProcessedBlock) } -// blockBelowThreshold checks if the specified block is recent enough. +// blockBelowThreshold checks if the given block is acceptably recent. Returns nil if fresh. +// Pre-finality, it compares the block's time against `time.Now()` using `es.stalenessThreshold`. +// Post-finality, it compares against the EL's finalized block time. If the given block is newer +// than the EL's finalized block, it's considered fresh (waiting for EL finality). func (es *EventSyncer) blockBelowThreshold(ctx context.Context, block *big.Int) error { header, err := es.executionClient.HeaderByNumber(ctx, block) if err != nil { - return fmt.Errorf("failed to get header for block %d: %w", block, err) + // Ensure block number is used in error message if that's the intent for %d + return fmt.Errorf("failed to get header for block %d: %w", block.Uint64(), err) } - // #nosec G115 - blockTime := time.Unix(int64(header.Time), 0) - latestBlockTime := time.Now() - + var referenceTime time.Time if es.executionClient.IsFinalizedFork(ctx) { + // Post-fork: Compare against finalized block time finalizedHeader, err := es.executionClient.HeaderByNumber(ctx, big.NewInt(rpc.FinalizedBlockNumber.Int64())) if err != nil { return fmt.Errorf("failed to get finalized block header: %w", err) } + + // If we're ahead of finalized, we're not behind - just waiting + if finalizedHeader.Number.Uint64() < block.Uint64() { + return nil + } + // #nosec G115 - latestBlockTime = time.Unix(int64(finalizedHeader.Time), 0) + referenceTime = time.Unix(int64(finalizedHeader.Time), 0) + } else { + // Pre-fork: Compare against current time + referenceTime = time.Now() } - staleness := latestBlockTime.Sub(blockTime) - - if staleness > es.stalenessThreshold { - return fmt.Errorf("block %d is too old (age: %s)", - block.Uint64(), staleness.Round(time.Second)) + // Check if block is older than threshold from reference time + // #nosec G115 + blockTime := time.Unix(int64(header.Time), 0) + if blockTime.Before(referenceTime.Add(-es.stalenessThreshold)) { + return fmt.Errorf("block %d is too old (age: %s behind reference)", + block.Uint64(), referenceTime.Sub(blockTime).Round(time.Second)) } return nil From 7d85ef2eeb53589c02892b81f7780c9e6b8d5460 Mon Sep 17 00:00:00 2001 From: kchojn Date: Thu, 5 Jun 2025 20:41:04 +0700 Subject: [PATCH 52/53] mock --- networkconfig/hoodi-stage.go | 12 ++++++++++++ networkconfig/hoodi.go | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/networkconfig/hoodi-stage.go b/networkconfig/hoodi-stage.go index b78942dc05..38a22a2dde 100644 --- a/networkconfig/hoodi-stage.go +++ b/networkconfig/hoodi-stage.go @@ -18,4 +18,16 @@ var HoodiStageSSV = SSVConfig{ "enr:-Ja4QJZcaYfS0GpX-5xREVBa26a-E-QHMFek-EndsJdgM6loIM7pfbJwPDCNK1VzPkUhMjwcTTuNASiHU6X-sjsrxFmGAZWjNu06gmlkgnY0gmlwhErcGnyJc2VjcDI1NmsxoQP_bBE-ZYvaXKBR3dRYMN5K_lZP-q-YsBzDZEtxH_4T_YNzc3YBg3RjcIITioN1ZHCCD6I", }, TotalEthereumValidators: HoodiSSV.TotalEthereumValidators, + Forks: SSVForkConfig{ + Forks: SSVForks{ + { + Name: "Alan", + Epoch: 0, + }, + { + Name: "Finality Consensus", + Epoch: 100, // TODO: MaxEpoch + }, + }, + }, } diff --git a/networkconfig/hoodi.go b/networkconfig/hoodi.go index 0b32951ce1..7476bd1af5 100644 --- a/networkconfig/hoodi.go +++ b/networkconfig/hoodi.go @@ -27,7 +27,7 @@ var HoodiSSV = SSVConfig{ }, { Name: "Finality Consensus", - Epoch: MaxEpoch, + Epoch: 100, // TODO: MaxEpoch }, }, }, From dbc9e09d5fa63420891e29722fcb5f127316ec9a Mon Sep 17 00:00:00 2001 From: Karol Chojnowski Date: Thu, 26 Jun 2025 14:49:57 +0700 Subject: [PATCH 53/53] adapt gas-limit: default to 36 #2307 --- networkconfig/holesky-e2e.go | 17 +++++++++++++- networkconfig/holesky-stage.go | 17 +++++++++++++- networkconfig/holesky.go | 17 +++++++++++++- networkconfig/hoodi-stage.go | 5 +++- networkconfig/hoodi.go | 5 +++- networkconfig/local-testnet.go | 17 +++++++++++++- networkconfig/mainnet.go | 8 +++---- networkconfig/network_mock.go | 14 ++++++------ networkconfig/sepolia.go | 5 +++- networkconfig/ssv.go | 13 +++-------- networkconfig/ssv_forks.go | 13 +++++++++++ networkconfig/ssv_test.go | 38 +++++++++++++++++++++++++++---- protocol/v2/ssv/validator/opts.go | 2 +- utils/testutils.go | 2 +- 14 files changed, 138 insertions(+), 35 deletions(-) diff --git a/networkconfig/holesky-e2e.go b/networkconfig/holesky-e2e.go index 7ed821e98f..6252fbad03 100644 --- a/networkconfig/holesky-e2e.go +++ b/networkconfig/holesky-e2e.go @@ -16,5 +16,20 @@ var HoleskyE2ESSV = SSVConfig{ RegistrySyncOffset: big.NewInt(405579), Bootnodes: []string{}, TotalEthereumValidators: HoleskySSV.TotalEthereumValidators, - GasLimit36Epoch: 0, + Forks: SSVForkConfig{ + Forks: SSVForks{ + { + Name: "Alan", + Epoch: 0, + }, + { + Name: "Gas Limit 36M", + Epoch: 0, // Already active on holesky-e2e + }, + { + Name: "Finality Consensus", + Epoch: MaxEpoch, + }, + }, + }, } diff --git a/networkconfig/holesky-stage.go b/networkconfig/holesky-stage.go index 1920180963..07315ac42f 100644 --- a/networkconfig/holesky-stage.go +++ b/networkconfig/holesky-stage.go @@ -21,5 +21,20 @@ var HoleskyStageSSV = SSVConfig{ "enr:-Ja4QDRUBjWOvVfGxpxvv3FqaCy3psm7IsKu5ETb1GXiexGYDFppD33t7AHRfmQddoAkBiyb7pt4t7ZN0sNB9CsW4I-GAZGOmChMgmlkgnY0gmlwhAorXxuJc2VjcDI1NmsxoQP_bBE-ZYvaXKBR3dRYMN5K_lZP-q-YsBzDZEtxH_4T_YNzc3YBg3RjcIITioN1ZHCCD6I", }, TotalEthereumValidators: HoleskySSV.TotalEthereumValidators, - GasLimit36Epoch: 0, + Forks: SSVForkConfig{ + Forks: SSVForks{ + { + Name: "Alan", + Epoch: 0, + }, + { + Name: "Gas Limit 36M", + Epoch: 0, // Already active on holesky-stage + }, + { + Name: "Finality Consensus", + Epoch: MaxEpoch, + }, + }, + }, } diff --git a/networkconfig/holesky.go b/networkconfig/holesky.go index 47b31cb8d7..f86f96abc3 100644 --- a/networkconfig/holesky.go +++ b/networkconfig/holesky.go @@ -20,5 +20,20 @@ var HoleskySSV = SSVConfig{ "enr:-Ja4QKFD3u5tZob7xukp-JKX9QJMFqqI68cItsE4tBbhsOyDR0M_1UUjb35hbrqvTP3bnXO_LnKh-jNLTeaUqN4xiduGAZKaP_sagmlkgnY0gmlwhDb0fh6Jc2VjcDI1NmsxoQMw_H2anuiqP9NmEaZwbUfdvPFog7PvcKmoVByDa576SINzc3YBg3RjcIITioN1ZHCCD6I", }, TotalEthereumValidators: 1757795, // active_validators from https://holesky.beaconcha.in/index/data on Nov 20, 2024 - GasLimit36Epoch: 0, + Forks: SSVForkConfig{ + Forks: SSVForks{ + { + Name: "Alan", + Epoch: 0, + }, + { + Name: "Gas Limit 36M", + Epoch: 0, // Already active on holesky + }, + { + Name: "Finality Consensus", + Epoch: MaxEpoch, + }, + }, + }, } diff --git a/networkconfig/hoodi-stage.go b/networkconfig/hoodi-stage.go index ecb9f3a921..9aa6d57799 100644 --- a/networkconfig/hoodi-stage.go +++ b/networkconfig/hoodi-stage.go @@ -24,11 +24,14 @@ var HoodiStageSSV = SSVConfig{ Name: "Alan", Epoch: 0, }, + { + Name: "Gas Limit 36M", + Epoch: 0, // Already active on hoodi-stage + }, { Name: "Finality Consensus", Epoch: 100, // TODO: MaxEpoch }, }, }, - GasLimit36Epoch: 0, } diff --git a/networkconfig/hoodi.go b/networkconfig/hoodi.go index 4818453222..e420d37d00 100644 --- a/networkconfig/hoodi.go +++ b/networkconfig/hoodi.go @@ -26,11 +26,14 @@ var HoodiSSV = SSVConfig{ Name: "Alan", Epoch: 0, }, + { + Name: "Gas Limit 36M", + Epoch: 0, // Already active on hoodi + }, { Name: "Finality Consensus", Epoch: 100, // TODO: MaxEpoch }, }, }, - GasLimit36Epoch: 0, } diff --git a/networkconfig/local-testnet.go b/networkconfig/local-testnet.go index b9482bae2a..f779da5ab1 100644 --- a/networkconfig/local-testnet.go +++ b/networkconfig/local-testnet.go @@ -17,5 +17,20 @@ var LocalTestnetSSV = SSVConfig{ "enr:-Li4QLR4Y1VbwiqFYKy6m-WFHRNDjhMDZ_qJwIABu2PY9BHjIYwCKpTvvkVmZhu43Q6zVA29sEUhtz10rQjDJkK3Hd-GAYiGrW2Bh2F0dG5ldHOIAAAAAAAAAACEZXRoMpD1pf1CAAAAAP__________gmlkgnY0gmlwhCLdu_SJc2VjcDI1NmsxoQJTcI7GHPw-ZqIflPZYYDK_guurp_gsAFF5Erns3-PAvIN0Y3CCE4mDdWRwgg-h", }, DiscoveryProtocolID: [6]byte{'s', 's', 'v', 'd', 'v', '5'}, TotalEthereumValidators: TestNetwork.TotalEthereumValidators, - GasLimit36Epoch: 0, + Forks: SSVForkConfig{ + Forks: SSVForks{ + { + Name: "Alan", + Epoch: 0, + }, + { + Name: "Gas Limit 36M", + Epoch: 0, // Already active on local-testnet + }, + { + Name: "Finality Consensus", + Epoch: MaxEpoch, + }, + }, + }, } diff --git a/networkconfig/mainnet.go b/networkconfig/mainnet.go index 12cafe0394..411f344c89 100644 --- a/networkconfig/mainnet.go +++ b/networkconfig/mainnet.go @@ -1,10 +1,8 @@ package networkconfig import ( - "math" "math/big" - "github.com/attestantio/go-eth2-client/spec/phase0" ethcommon "github.com/ethereum/go-ethereum/common" spectypes "github.com/ssvlabs/ssv-spec/types" @@ -37,12 +35,14 @@ var MainnetSSV = SSVConfig{ Name: "Alan", Epoch: 0, }, + { + Name: "Gas Limit 36M", + Epoch: MaxEpoch, // TODO - set proper value for mainnet + }, { Name: "Finality Consensus", Epoch: MaxEpoch, }, }, }, - // TODO - set proper value for mainnet - GasLimit36Epoch: phase0.Epoch(math.MaxUint64), } diff --git a/networkconfig/network_mock.go b/networkconfig/network_mock.go index 0656806062..34d467aa76 100644 --- a/networkconfig/network_mock.go +++ b/networkconfig/network_mock.go @@ -240,18 +240,18 @@ func (mr *MockNetworkMockRecorder) GetEpochsPerSyncCommitteePeriod() *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEpochsPerSyncCommitteePeriod", reflect.TypeOf((*MockNetwork)(nil).GetEpochsPerSyncCommitteePeriod)) } -// GetGasLimit36Epoch mocks base method. -func (m *MockNetwork) GetGasLimit36Epoch() phase0.Epoch { +// GetForks mocks base method. +func (m *MockNetwork) GetForks() SSVForkConfig { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetGasLimit36Epoch") - ret0, _ := ret[0].(phase0.Epoch) + ret := m.ctrl.Call(m, "GetForks") + ret0, _ := ret[0].(SSVForkConfig) return ret0 } -// GetGasLimit36Epoch indicates an expected call of GetGasLimit36Epoch. -func (mr *MockNetworkMockRecorder) GetGasLimit36Epoch() *gomock.Call { +// GetForks indicates an expected call of GetForks. +func (mr *MockNetworkMockRecorder) GetForks() *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGasLimit36Epoch", reflect.TypeOf((*MockNetwork)(nil).GetGasLimit36Epoch)) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetForks", reflect.TypeOf((*MockNetwork)(nil).GetForks)) } // GetGenesisTime mocks base method. diff --git a/networkconfig/sepolia.go b/networkconfig/sepolia.go index f29f6ea898..8dfa26724f 100644 --- a/networkconfig/sepolia.go +++ b/networkconfig/sepolia.go @@ -26,11 +26,14 @@ var SepoliaSSV = SSVConfig{ Name: "Alan", Epoch: 0, }, + { + Name: "Gas Limit 36M", + Epoch: 0, // Already active on sepolia + }, { Name: "Finality Consensus", Epoch: MaxEpoch, }, }, }, - GasLimit36Epoch: 0, } diff --git a/networkconfig/ssv.go b/networkconfig/ssv.go index d2487f4b7b..e3ed25228c 100644 --- a/networkconfig/ssv.go +++ b/networkconfig/ssv.go @@ -5,7 +5,6 @@ import ( "fmt" "math/big" - "github.com/attestantio/go-eth2-client/spec/phase0" ethcommon "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" @@ -35,7 +34,7 @@ func GetSSVConfigByName(name string) (SSVConfig, error) { type SSV interface { GetDomainType() spectypes.DomainType - GetGasLimit36Epoch() phase0.Epoch + GetForks() SSVForkConfig } type SSVConfig struct { @@ -47,9 +46,6 @@ type SSVConfig struct { // TotalEthereumValidators value needs to be maintained — consider getting it from external API // with default or per-network value(s) as fallback TotalEthereumValidators int - // GasLimit36Epoch is an epoch when to upgrade from default gas limit value of 30_000_000 - // to 36_000_000. - GasLimit36Epoch phase0.Epoch Forks SSVForkConfig } @@ -71,7 +67,6 @@ type marshaledConfig struct { DiscoveryProtocolID hexutil.Bytes `json:"DiscoveryProtocolID,omitempty" yaml:"DiscoveryProtocolID,omitempty"` TotalEthereumValidators int `json:"TotalEthereumValidators,omitempty" yaml:"TotalEthereumValidators,omitempty"` Forks SSVForkConfig `json:"Forks,omitempty" yaml:"Forks,omitempty"` - GasLimit36Epoch phase0.Epoch `json:"GasLimit36Epoch,omitempty" yaml:"GasLimit36Epoch,omitempty"` } // Helper method to avoid duplication between MarshalJSON and MarshalYAML @@ -84,7 +79,6 @@ func (s SSVConfig) marshal() marshaledConfig { DiscoveryProtocolID: s.DiscoveryProtocolID[:], TotalEthereumValidators: s.TotalEthereumValidators, Forks: s.Forks, - GasLimit36Epoch: s.GasLimit36Epoch, } return aux @@ -116,7 +110,6 @@ func (s *SSVConfig) unmarshalFromConfig(aux marshaledConfig) error { DiscoveryProtocolID: [6]byte(aux.DiscoveryProtocolID), TotalEthereumValidators: aux.TotalEthereumValidators, Forks: aux.Forks, - GasLimit36Epoch: aux.GasLimit36Epoch, } return nil @@ -144,6 +137,6 @@ func (s SSVConfig) GetDomainType() spectypes.DomainType { return s.DomainType } -func (s SSVConfig) GetGasLimit36Epoch() phase0.Epoch { - return s.GasLimit36Epoch +func (s SSVConfig) GetForks() SSVForkConfig { + return s.Forks } diff --git a/networkconfig/ssv_forks.go b/networkconfig/ssv_forks.go index e6205f7d85..1b810de8f7 100644 --- a/networkconfig/ssv_forks.go +++ b/networkconfig/ssv_forks.go @@ -11,6 +11,7 @@ type SSVForkName int const ( Alan SSVForkName = iota + GasLimit36M // Gas limit increase from 30M to 36M - upgrade from the default gas limit value of 30_000_000 to 36_000_000 FinalityConsensus // TODO: use a different name when we have a better one ) @@ -25,6 +26,7 @@ func (f SSVForkName) String() string { var forkToString = map[SSVForkName]string{ Alan: "Alan", + GasLimit36M: "Gas Limit 36M", FinalityConsensus: "Finality Consensus", // TODO: use a different name when we have a better one } @@ -107,6 +109,17 @@ func (c SSVForkConfig) IsForkActive(name string, epoch phase0.Epoch) bool { return c.Forks.IsForkActive(name, epoch) } +// GetGasLimit36Epoch returns the epoch at which the Gas Limit 36M fork is activated. +// This fork upgrades the default gas limit value from 30_000_000 to 36_000_000. +// If the fork is not found, returns MaxEpoch (undefined). +func (c SSVForkConfig) GetGasLimit36Epoch() phase0.Epoch { + fork := c.FindForkByName("Gas Limit 36M") + if fork != nil { + return fork.Epoch + } + return MaxEpoch +} + // GetFinalityConsensusEpoch returns the epoch at which the Finality Consensus fork is activated. // If the fork is not found, returns MaxEpoch (undefined). func (c SSVForkConfig) GetFinalityConsensusEpoch() phase0.Epoch { diff --git a/networkconfig/ssv_test.go b/networkconfig/ssv_test.go index ffcb587925..fadb9ee56d 100644 --- a/networkconfig/ssv_test.go +++ b/networkconfig/ssv_test.go @@ -26,7 +26,18 @@ func TestSSVConfig_MarshalUnmarshalJSON(t *testing.T) { RegistryContractAddr: ethcommon.HexToAddress("0x123456789abcdef0123456789abcdef012345678"), Bootnodes: []string{"bootnode1", "bootnode2"}, DiscoveryProtocolID: [6]byte{0x05, 0x06, 0x07, 0x08, 0x09, 0x0a}, - GasLimit36Epoch: 0, + Forks: SSVForkConfig{ + Forks: SSVForks{ + { + Name: "Alan", + Epoch: 0, + }, + { + Name: "Gas Limit 36M", + Epoch: 0, + }, + }, + }, } // Marshal to JSON @@ -61,7 +72,18 @@ func TestSSVConfig_MarshalUnmarshalYAML(t *testing.T) { RegistryContractAddr: ethcommon.HexToAddress("0x123456789abcdef0123456789abcdef012345678"), Bootnodes: []string{"bootnode1", "bootnode2"}, DiscoveryProtocolID: [6]byte{0x05, 0x06, 0x07, 0x08, 0x09, 0x0a}, - GasLimit36Epoch: 0, + Forks: SSVForkConfig{ + Forks: SSVForks{ + { + Name: "Alan", + Epoch: 0, + }, + { + Name: "Gas Limit 36M", + Epoch: 0, + }, + }, + }, } // Marshal to YAML @@ -156,13 +178,16 @@ func TestFieldPreservation(t *testing.T) { Name: "Alan", Epoch: 0, }, + { + Name: "Gas Limit 36M", + Epoch: 0, + }, { Name: "Finality Consensus", Epoch: 1, }, }, }, - GasLimit36Epoch: 0, } // Marshal and unmarshal to test preservation @@ -184,7 +209,7 @@ func TestFieldPreservation(t *testing.T) { assert.Equal(t, originalHash, unmarshaledHash, "Hash mismatch indicates fields weren't properly preserved in JSON") // Store the expected hash - this will fail if a new field is added without updating the tests - expectedJSONHash := "b1353678b60c092192f60939b50cdd34dd918648ad97890ccf6a69c66cee217b" + expectedJSONHash := "e1aac5bc6d1459fb82a942e170d00732e04c201a39743a0b8cfe5d71dc81dfab" assert.Equal(t, expectedJSONHash, originalHash, "Hash has changed. If you've added a new field, please update the expected hash in this test.") }) @@ -203,13 +228,16 @@ func TestFieldPreservation(t *testing.T) { Name: "Alan", Epoch: 0, }, + { + Name: "Gas Limit 36M", + Epoch: 0, + }, { Name: "Finality Consensus", Epoch: 1, }, }, }, - GasLimit36Epoch: 0, } // Marshal and unmarshal to test preservation diff --git a/protocol/v2/ssv/validator/opts.go b/protocol/v2/ssv/validator/opts.go index ed19dda664..11936e5269 100644 --- a/protocol/v2/ssv/validator/opts.go +++ b/protocol/v2/ssv/validator/opts.go @@ -96,7 +96,7 @@ func NewCommonOptions( // on the current epoch as compared to when this transition is supposed to happen. if result.GasLimit == 0 { defaultGasLimit := DefaultGasLimit - if result.NetworkConfig.EstimatedCurrentEpoch() < result.NetworkConfig.GetGasLimit36Epoch() { + if result.NetworkConfig.EstimatedCurrentEpoch() < result.NetworkConfig.GetForks().GetGasLimit36Epoch() { defaultGasLimit = DefaultGasLimitOld } result.GasLimit = defaultGasLimit diff --git a/utils/testutils.go b/utils/testutils.go index 1b840406e7..04f9af05c7 100644 --- a/utils/testutils.go +++ b/utils/testutils.go @@ -106,7 +106,7 @@ func SetupMockNetworkConfig(t *testing.T, domainType spectypes.DomainType, curre mockNetwork.EXPECT().GetNetworkName().Return(string(beaconNetwork)).AnyTimes() - mockNetwork.EXPECT().GetGasLimit36Epoch().Return(phase0.Epoch(0)).AnyTimes() + mockNetwork.EXPECT().GetForks().Return(networkconfig.SSVForkConfig{}).AnyTimes() return mockNetwork }