diff --git a/apps/evm/single/go.mod b/apps/evm/single/go.mod index d31ba219c2..4cd7f05ab8 100644 --- a/apps/evm/single/go.mod +++ b/apps/evm/single/go.mod @@ -6,6 +6,7 @@ replace github.com/celestiaorg/go-header => github.com/julienrbrt/go-header v0.0 replace ( github.com/evstack/ev-node => ../../../ + github.com/evstack/ev-node/core => ../../../core github.com/evstack/ev-node/da => ../../../da github.com/evstack/ev-node/execution/evm => ../../../execution/evm github.com/evstack/ev-node/sequencers/single => ../../../sequencers/single diff --git a/apps/evm/single/go.sum b/apps/evm/single/go.sum index db48b43b3e..173ac70d7e 100644 --- a/apps/evm/single/go.sum +++ b/apps/evm/single/go.sum @@ -103,8 +103,6 @@ github.com/ethereum/go-ethereum v1.16.5 h1:GZI995PZkzP7ySCxEFaOPzS8+bd8NldE//1qv github.com/ethereum/go-ethereum v1.16.5/go.mod h1:kId9vOtlYg3PZk9VwKbGlQmSACB5ESPTBGT+M9zjmok= github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8= github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk= -github.com/evstack/ev-node/core v1.0.0-beta.3 h1:01K2Ygm3puX4m2OBxvg/HDxu+he54jeNv+KDmpgujFc= -github.com/evstack/ev-node/core v1.0.0-beta.3/go.mod h1:n2w/LhYQTPsi48m6lMj16YiIqsaQw6gxwjyJvR+B3sY= github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= github.com/filecoin-project/go-clock v0.1.0 h1:SFbYIM75M8NnFm1yMHhN9Ahy3W5bEZV9gd6MPfXbKVU= diff --git a/apps/grpc/single/go.mod b/apps/grpc/single/go.mod index 54b805fe39..949cffc18e 100644 --- a/apps/grpc/single/go.mod +++ b/apps/grpc/single/go.mod @@ -166,6 +166,7 @@ require ( replace ( github.com/evstack/ev-node => ../../../ + github.com/evstack/ev-node/core => ../../../core github.com/evstack/ev-node/da => ../../../da github.com/evstack/ev-node/execution/grpc => ../../../execution/grpc github.com/evstack/ev-node/sequencers/single => ../../../sequencers/single diff --git a/apps/grpc/single/go.sum b/apps/grpc/single/go.sum index 111011daf3..716ba61caf 100644 --- a/apps/grpc/single/go.sum +++ b/apps/grpc/single/go.sum @@ -62,8 +62,6 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/evstack/ev-node/core v1.0.0-beta.3 h1:01K2Ygm3puX4m2OBxvg/HDxu+he54jeNv+KDmpgujFc= -github.com/evstack/ev-node/core v1.0.0-beta.3/go.mod h1:n2w/LhYQTPsi48m6lMj16YiIqsaQw6gxwjyJvR+B3sY= github.com/filecoin-project/go-clock v0.1.0 h1:SFbYIM75M8NnFm1yMHhN9Ahy3W5bEZV9gd6MPfXbKVU= github.com/filecoin-project/go-clock v0.1.0/go.mod h1:4uB/O4PvOjlx1VCMdZ9MyDZXRm//gkj1ELEbxfI1AZs= github.com/filecoin-project/go-jsonrpc v0.8.0 h1:2yqlN3Vd8Gx5UtA3fib7tQu2aW1cSOJt253LEBWExo4= diff --git a/apps/testapp/go.mod b/apps/testapp/go.mod index 2677e37d52..efacede79b 100644 --- a/apps/testapp/go.mod +++ b/apps/testapp/go.mod @@ -6,6 +6,7 @@ replace github.com/celestiaorg/go-header => github.com/julienrbrt/go-header v0.0 replace ( github.com/evstack/ev-node => ../../. + github.com/evstack/ev-node/core => ../../core github.com/evstack/ev-node/da => ../../da github.com/evstack/ev-node/sequencers/single => ../../sequencers/single ) diff --git a/apps/testapp/go.sum b/apps/testapp/go.sum index c2e333c3c0..e4cbfa9a7b 100644 --- a/apps/testapp/go.sum +++ b/apps/testapp/go.sum @@ -60,8 +60,6 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/evstack/ev-node/core v1.0.0-beta.3 h1:01K2Ygm3puX4m2OBxvg/HDxu+he54jeNv+KDmpgujFc= -github.com/evstack/ev-node/core v1.0.0-beta.3/go.mod h1:n2w/LhYQTPsi48m6lMj16YiIqsaQw6gxwjyJvR+B3sY= github.com/filecoin-project/go-clock v0.1.0 h1:SFbYIM75M8NnFm1yMHhN9Ahy3W5bEZV9gd6MPfXbKVU= github.com/filecoin-project/go-clock v0.1.0/go.mod h1:4uB/O4PvOjlx1VCMdZ9MyDZXRm//gkj1ELEbxfI1AZs= github.com/filecoin-project/go-jsonrpc v0.8.0 h1:2yqlN3Vd8Gx5UtA3fib7tQu2aW1cSOJt253LEBWExo4= diff --git a/block/internal/common/replay.go b/block/internal/common/replay.go new file mode 100644 index 0000000000..4c4a4b26de --- /dev/null +++ b/block/internal/common/replay.go @@ -0,0 +1,183 @@ +package common + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + + "github.com/rs/zerolog" + + coreexecutor "github.com/evstack/ev-node/core/execution" + "github.com/evstack/ev-node/pkg/genesis" + "github.com/evstack/ev-node/pkg/store" + "github.com/evstack/ev-node/types" +) + +// Replayer handles synchronization of the execution layer with ev-node's state. +// It replays blocks from the store to bring the execution layer up to date. +type Replayer struct { + store store.Store + exec coreexecutor.Executor + genesis genesis.Genesis + logger zerolog.Logger +} + +// NewReplayer creates a new execution layer replayer. +func NewReplayer( + store store.Store, + exec coreexecutor.Executor, + genesis genesis.Genesis, + logger zerolog.Logger, +) *Replayer { + return &Replayer{ + store: store, + exec: exec, + genesis: genesis, + logger: logger.With().Str("component", "execution_replayer").Logger(), + } +} + +// SyncToHeight checks if the execution layer is behind ev-node and syncs it to the target height. +// This is useful for crash recovery scenarios where ev-node is ahead of the execution layer. +// +// Returns: +// - error if sync fails or if execution layer is ahead of ev-node (unexpected state) +func (s *Replayer) SyncToHeight(ctx context.Context, targetHeight uint64) error { + // Check if the executor implements HeightProvider + execHeightProvider, ok := s.exec.(coreexecutor.HeightProvider) + if !ok { + s.logger.Debug().Msg("executor does not implement HeightProvider, skipping sync") + return nil + } + + // Skip sync check if we're at genesis + if targetHeight < s.genesis.InitialHeight { + s.logger.Debug().Msg("at genesis height, skipping execution layer sync check") + return nil + } + + execHeight, err := execHeightProvider.GetLatestHeight(ctx) + if err != nil { + return fmt.Errorf("failed to get execution layer height: %w", err) + } + + s.logger.Info(). + Uint64("target_height", targetHeight). + Uint64("exec_layer_height", execHeight). + Msg("execution layer height check") + + // If execution layer is ahead, this is unexpected, fail hard + if execHeight > targetHeight { + s.logger.Error(). + Uint64("target_height", targetHeight). + Uint64("exec_layer_height", execHeight). + Msg("execution layer is ahead of target height - this should not happen") + return fmt.Errorf("execution layer height (%d) is ahead of target height (%d)", execHeight, targetHeight) + } + + // If execution layer is behind, sync the missing blocks + if execHeight < targetHeight { + s.logger.Info(). + Uint64("target_height", targetHeight). + Uint64("exec_layer_height", execHeight). + Uint64("blocks_to_sync", targetHeight-execHeight). + Msg("execution layer is behind, syncing blocks") + + // Sync blocks from execHeight+1 to targetHeight + for height := execHeight + 1; height <= targetHeight; height++ { + if err := s.replayBlock(ctx, height); err != nil { + return fmt.Errorf("failed to replay block %d to execution layer: %w", height, err) + } + } + + s.logger.Info(). + Uint64("synced_blocks", targetHeight-execHeight). + Msg("successfully synced execution layer") + } else { + s.logger.Info().Msg("execution layer is in sync") + } + + return nil +} + +// replayBlock replays a specific block from the store to the execution layer. +// +// Validation assumptions: +// - Blocks in the store have already been fully validated (signatures, timestamps, etc.) +// - We only verify the AppHash matches to detect state divergence +// - We skip re-validating signatures and consensus rules since this is a replay +// - This is safe because we're re-executing transactions against a known-good state +func (s *Replayer) replayBlock(ctx context.Context, height uint64) error { + s.logger.Info().Uint64("height", height).Msg("replaying block to execution layer") + + // Get the block from store + header, data, err := s.store.GetBlockData(ctx, height) + if err != nil { + return fmt.Errorf("failed to get block data from store: %w", err) + } + + // Get the previous state + var prevState types.State + if height == s.genesis.InitialHeight { + // For the first block, use genesis state + prevState = types.State{ + ChainID: s.genesis.ChainID, + InitialHeight: s.genesis.InitialHeight, + LastBlockHeight: s.genesis.InitialHeight - 1, + LastBlockTime: s.genesis.StartTime, + AppHash: header.AppHash, // This will be updated by InitChain + } + } else { + // Get previous state from store + prevState, err = s.store.GetState(ctx) + if err != nil { + return fmt.Errorf("failed to get previous state: %w", err) + } + // We need the state at height-1, so load that block's app hash + prevHeader, _, err := s.store.GetBlockData(ctx, height-1) + if err != nil { + return fmt.Errorf("failed to get previous block header: %w", err) + } + prevState.AppHash = prevHeader.AppHash + prevState.LastBlockHeight = height - 1 + } + + // Prepare transactions + rawTxs := make([][]byte, len(data.Txs)) + for i, tx := range data.Txs { + rawTxs[i] = []byte(tx) + } + + // Execute transactions on the execution layer + s.logger.Debug(). + Uint64("height", height). + Int("tx_count", len(rawTxs)). + Msg("executing transactions on execution layer") + + newAppHash, _, err := s.exec.ExecuteTxs(ctx, rawTxs, height, header.Time(), prevState.AppHash) + if err != nil { + return fmt.Errorf("failed to execute transactions: %w", err) + } + + // Verify the app hash matches + if !bytes.Equal(newAppHash, header.AppHash) { + err := fmt.Errorf("app hash mismatch: expected %s got %s", + hex.EncodeToString(header.AppHash), + hex.EncodeToString(newAppHash), + ) + s.logger.Error(). + Str("expected", hex.EncodeToString(header.AppHash)). + Str("got", hex.EncodeToString(newAppHash)). + Uint64("height", height). + Err(err). + Msg("app hash mismatch during replay") + return err + } + + s.logger.Info(). + Uint64("height", height). + Msg("successfully replayed block to execution layer") + + return nil +} diff --git a/block/internal/common/replay_test.go b/block/internal/common/replay_test.go new file mode 100644 index 0000000000..cbbfac8ea8 --- /dev/null +++ b/block/internal/common/replay_test.go @@ -0,0 +1,407 @@ +package common + +import ( + "context" + "testing" + "time" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/evstack/ev-node/pkg/genesis" + "github.com/evstack/ev-node/test/mocks" + "github.com/evstack/ev-node/types" +) + +func TestReplayer_SyncToHeight_ExecutorBehind(t *testing.T) { + ctx := context.Background() + mockExec := mocks.NewMockHeightAwareExecutor(t) + mockStore := mocks.NewMockStore(t) + logger := zerolog.Nop() + + gen := genesis.Genesis{ + ChainID: "test-chain", + InitialHeight: 1, + StartTime: time.Now().UTC(), + } + + syncer := NewReplayer(mockStore, mockExec, gen, logger) + + // Setup: target height is 100, execution layer is at 99 + targetHeight := uint64(100) + execHeight := uint64(99) + + mockExec.On("GetLatestHeight", mock.Anything).Return(execHeight, nil) + + now := uint64(time.Now().UnixNano()) + + // Setup store to return block data for height 100 + mockStore.EXPECT().GetBlockData(mock.Anything, uint64(100)).Return( + &types.SignedHeader{ + Header: types.Header{ + BaseHeader: types.BaseHeader{ + Height: 100, + Time: now, + ChainID: "test-chain", + }, + AppHash: []byte("app-hash-100"), + }, + }, + &types.Data{ + Txs: []types.Tx{[]byte("tx1")}, + }, + nil, + ) + + // Setup store to return previous block for state + mockStore.EXPECT().GetBlockData(mock.Anything, uint64(99)).Return( + &types.SignedHeader{ + Header: types.Header{ + BaseHeader: types.BaseHeader{ + Height: 99, + Time: now - 1000000000, + ChainID: "test-chain", + }, + AppHash: []byte("app-hash-99"), + }, + }, + &types.Data{}, + nil, + ) + + // Setup state + mockStore.EXPECT().GetState(mock.Anything).Return( + types.State{ + ChainID: gen.ChainID, + InitialHeight: gen.InitialHeight, + LastBlockHeight: 99, + AppHash: []byte("app-hash-99"), + }, + nil, + ) + + // Expect ExecuteTxs to be called for height 100 + mockExec.On("ExecuteTxs", mock.Anything, mock.Anything, uint64(100), mock.Anything, []byte("app-hash-99")). + Return([]byte("app-hash-100"), uint64(1000), nil) + + // Execute sync + err := syncer.SyncToHeight(ctx, targetHeight) + require.NoError(t, err) + + // Verify expectations + mockExec.AssertExpectations(t) +} + +func TestReplayer_SyncToHeight_InSync(t *testing.T) { + ctx := context.Background() + mockExec := mocks.NewMockHeightAwareExecutor(t) + mockStore := mocks.NewMockStore(t) + logger := zerolog.Nop() + + gen := genesis.Genesis{ + ChainID: "test-chain", + InitialHeight: 1, + StartTime: time.Now().UTC(), + } + + syncer := NewReplayer(mockStore, mockExec, gen, logger) + + // Setup: both at height 100 + targetHeight := uint64(100) + execHeight := uint64(100) + + mockExec.On("GetLatestHeight", mock.Anything).Return(execHeight, nil) + + // Execute sync - should do nothing + err := syncer.SyncToHeight(ctx, targetHeight) + require.NoError(t, err) + + // ExecuteTxs should not be called + mockExec.AssertNotCalled(t, "ExecuteTxs") +} + +func TestReplayer_SyncToHeight_ExecutorAhead(t *testing.T) { + ctx := context.Background() + mockExec := mocks.NewMockHeightAwareExecutor(t) + mockStore := mocks.NewMockStore(t) + logger := zerolog.Nop() + + gen := genesis.Genesis{ + ChainID: "test-chain", + InitialHeight: 1, + StartTime: time.Now().UTC(), + } + + syncer := NewReplayer(mockStore, mockExec, gen, logger) + + // Setup: target height is 100, execution layer is at 101 (unexpected!) + targetHeight := uint64(100) + execHeight := uint64(101) + + mockExec.On("GetLatestHeight", mock.Anything).Return(execHeight, nil) + + // Execute sync - should fail + err := syncer.SyncToHeight(ctx, targetHeight) + require.Error(t, err) + require.Contains(t, err.Error(), "execution layer height (101) is ahead of target height (100)") +} + +func TestReplayer_SyncToHeight_NoHeightProvider(t *testing.T) { + ctx := context.Background() + mockExec := mocks.NewMockExecutor(t) // Regular executor without HeightProvider + mockStore := mocks.NewMockStore(t) + logger := zerolog.Nop() + + gen := genesis.Genesis{ + ChainID: "test-chain", + InitialHeight: 1, + StartTime: time.Now().UTC(), + } + + syncer := NewReplayer(mockStore, mockExec, gen, logger) + + // Execute sync - should skip silently + err := syncer.SyncToHeight(ctx, 100) + require.NoError(t, err) + + // No methods should be called + mockExec.AssertNotCalled(t, "GetLatestHeight") + mockExec.AssertNotCalled(t, "ExecuteTxs") +} + +func TestReplayer_SyncToHeight_AtGenesis(t *testing.T) { + ctx := context.Background() + mockExec := mocks.NewMockHeightAwareExecutor(t) + mockStore := mocks.NewMockStore(t) + logger := zerolog.Nop() + + gen := genesis.Genesis{ + ChainID: "test-chain", + InitialHeight: 10, + StartTime: time.Now().UTC(), + } + + syncer := NewReplayer(mockStore, mockExec, gen, logger) + + // Target height is below genesis initial height + targetHeight := uint64(5) + + // Execute sync - should skip + err := syncer.SyncToHeight(ctx, targetHeight) + require.NoError(t, err) + + // No calls should be made + mockExec.AssertNotCalled(t, "GetLatestHeight") + mockExec.AssertNotCalled(t, "ExecuteTxs") +} + +func TestReplayer_SyncToHeight_MultipleBlocks(t *testing.T) { + ctx := context.Background() + mockExec := mocks.NewMockHeightAwareExecutor(t) + mockStore := mocks.NewMockStore(t) + logger := zerolog.Nop() + + gen := genesis.Genesis{ + ChainID: "test-chain", + InitialHeight: 1, + StartTime: time.Now().UTC(), + } + + syncer := NewReplayer(mockStore, mockExec, gen, logger) + + // Setup: target height is 100, execution layer is at 97 (need to sync 3 blocks: 98, 99, 100) + targetHeight := uint64(100) + execHeight := uint64(97) + + mockExec.On("GetLatestHeight", mock.Anything).Return(execHeight, nil) + + now := uint64(time.Now().UnixNano()) + + // Setup mocks for blocks 98, 99, 100 + for height := uint64(98); height <= 100; height++ { + prevHeight := height - 1 + + // Current block data + mockStore.EXPECT().GetBlockData(mock.Anything, height).Return( + &types.SignedHeader{ + Header: types.Header{ + BaseHeader: types.BaseHeader{ + Height: height, + Time: now + (height * 1000000000), + ChainID: "test-chain", + }, + AppHash: []byte("app-hash-" + string(rune('0'+height))), + }, + }, + &types.Data{ + Txs: []types.Tx{[]byte("tx-" + string(rune('0'+height)))}, + }, + nil, + ).Once() + + // Previous block data (for getting previous app hash) + mockStore.EXPECT().GetBlockData(mock.Anything, prevHeight).Return( + &types.SignedHeader{ + Header: types.Header{ + BaseHeader: types.BaseHeader{ + Height: prevHeight, + Time: now + (prevHeight * 1000000000), + ChainID: "test-chain", + }, + AppHash: []byte("app-hash-" + string(rune('0'+prevHeight))), + }, + }, + &types.Data{}, + nil, + ).Once() + + // State (returns the state of previous block) + mockStore.EXPECT().GetState(mock.Anything).Return( + types.State{ + ChainID: gen.ChainID, + InitialHeight: gen.InitialHeight, + LastBlockHeight: prevHeight, + AppHash: []byte("app-hash-" + string(rune('0'+prevHeight))), + }, + nil, + ).Once() + + // ExecuteTxs for current block + mockExec.On("ExecuteTxs", mock.Anything, mock.Anything, height, mock.Anything, mock.Anything). + Return([]byte("app-hash-"+string(rune('0'+height))), uint64(1000), nil).Once() + } + + // Execute sync + err := syncer.SyncToHeight(ctx, targetHeight) + require.NoError(t, err) + + // Verify ExecuteTxs was called 3 times (for blocks 98, 99, 100) + mockExec.AssertNumberOfCalls(t, "ExecuteTxs", 3) + mockExec.AssertExpectations(t) + mockStore.AssertExpectations(t) +} + +func TestReplayer_ReplayBlock_FirstBlock(t *testing.T) { + ctx := context.Background() + mockExec := mocks.NewMockHeightAwareExecutor(t) + mockStore := mocks.NewMockStore(t) + logger := zerolog.Nop() + + gen := genesis.Genesis{ + ChainID: "test-chain", + InitialHeight: 1, + StartTime: time.Now().UTC(), + } + + syncer := NewReplayer(mockStore, mockExec, gen, logger) + + now := uint64(time.Now().UnixNano()) + + // Setup store to return first block (at initial height) + mockStore.EXPECT().GetBlockData(mock.Anything, uint64(1)).Return( + &types.SignedHeader{ + Header: types.Header{ + BaseHeader: types.BaseHeader{ + Height: 1, + Time: now, + ChainID: "test-chain", + }, + AppHash: []byte("app-hash-1"), + }, + }, + &types.Data{ + Txs: []types.Tx{[]byte("tx1")}, + }, + nil, + ) + + // For first block, ExecuteTxs should be called with genesis app hash + mockExec.On("ExecuteTxs", mock.Anything, mock.Anything, uint64(1), mock.Anything, []byte("app-hash-1")). + Return([]byte("app-hash-1"), uint64(1000), nil) + + // Call replayBlock directly (this is a private method, so we test it through SyncToHeight) + mockExec.On("GetLatestHeight", mock.Anything).Return(uint64(0), nil) + + err := syncer.SyncToHeight(ctx, 1) + require.NoError(t, err) + + mockExec.AssertExpectations(t) + mockStore.AssertExpectations(t) +} + +func TestReplayer_AppHashMismatch(t *testing.T) { + ctx := context.Background() + mockExec := mocks.NewMockHeightAwareExecutor(t) + mockStore := mocks.NewMockStore(t) + logger := zerolog.Nop() + + gen := genesis.Genesis{ + ChainID: "test-chain", + InitialHeight: 1, + StartTime: time.Now().UTC(), + } + + syncer := NewReplayer(mockStore, mockExec, gen, logger) + + targetHeight := uint64(100) + execHeight := uint64(99) + + mockExec.On("GetLatestHeight", mock.Anything).Return(execHeight, nil) + + now := uint64(time.Now().UnixNano()) + + mockStore.EXPECT().GetBlockData(mock.Anything, uint64(100)).Return( + &types.SignedHeader{ + Header: types.Header{ + BaseHeader: types.BaseHeader{ + Height: 100, + Time: now, + ChainID: "test-chain", + }, + AppHash: []byte("expected-app-hash"), + }, + }, + &types.Data{ + Txs: []types.Tx{[]byte("tx1")}, + }, + nil, + ) + + mockStore.EXPECT().GetBlockData(mock.Anything, uint64(99)).Return( + &types.SignedHeader{ + Header: types.Header{ + BaseHeader: types.BaseHeader{ + Height: 99, + Time: now - 1000000000, + ChainID: "test-chain", + }, + AppHash: []byte("app-hash-99"), + }, + }, + &types.Data{}, + nil, + ) + + mockStore.EXPECT().GetState(mock.Anything).Return( + types.State{ + ChainID: gen.ChainID, + InitialHeight: gen.InitialHeight, + LastBlockHeight: 99, + AppHash: []byte("app-hash-99"), + }, + nil, + ) + + // ExecuteTxs returns a different app hash than expected + mockExec.On("ExecuteTxs", mock.Anything, mock.Anything, uint64(100), mock.Anything, []byte("app-hash-99")). + Return([]byte("different-app-hash"), uint64(1000), nil) + + // Should fail with mismatch error + err := syncer.SyncToHeight(ctx, targetHeight) + require.Error(t, err) + require.Contains(t, err.Error(), "app hash mismatch") + + mockExec.AssertExpectations(t) + mockStore.AssertExpectations(t) +} diff --git a/block/internal/executing/executor.go b/block/internal/executing/executor.go index 2890615c07..26fc095ba9 100644 --- a/block/internal/executing/executor.go +++ b/block/internal/executing/executor.go @@ -220,6 +220,13 @@ func (e *Executor) initializeState() error { e.logger.Info().Uint64("height", state.LastBlockHeight). Str("chain_id", state.ChainID).Msg("initialized state") + // Sync execution layer with store on startup + execReplayer := common.NewReplayer(e.store, e.exec, e.genesis, e.logger) + if err := execReplayer.SyncToHeight(e.ctx, state.LastBlockHeight); err != nil { + e.sendCriticalError(fmt.Errorf("failed to sync execution layer: %w", err)) + return fmt.Errorf("failed to sync execution layer: %w", err) + } + return nil } diff --git a/block/internal/syncing/syncer.go b/block/internal/syncing/syncer.go index 7260abe34a..0e0b7efb83 100644 --- a/block/internal/syncing/syncer.go +++ b/block/internal/syncing/syncer.go @@ -204,6 +204,12 @@ func (s *Syncer) initializeState() error { Str("chain_id", state.ChainID). Msg("initialized syncer state") + // Sync execution layer with store on startup + execReplayer := common.NewReplayer(s.store, s.exec, s.genesis, s.logger) + if err := execReplayer.SyncToHeight(s.ctx, state.LastBlockHeight); err != nil { + return fmt.Errorf("failed to sync execution layer on startup: %w", err) + } + return nil } diff --git a/block/internal/syncing/syncer_test.go b/block/internal/syncing/syncer_test.go index a3dfccb392..002ca13962 100644 --- a/block/internal/syncing/syncer_test.go +++ b/block/internal/syncing/syncer_test.go @@ -4,6 +4,7 @@ import ( "context" crand "crypto/rand" "errors" + "sync/atomic" "testing" "time" @@ -548,3 +549,61 @@ func TestSyncer_executeTxsWithRetry(t *testing.T) { }) } } + +func TestSyncer_InitializeState_CallsReplayer(t *testing.T) { + // This test verifies that initializeState() invokes Replayer. + // The detailed replay logic is tested in block/internal/common/replay_test.go + + // Create mocks + mockStore := testmocks.NewMockStore(t) + mockExec := testmocks.NewMockHeightAwareExecutor(t) + + // Setup genesis + gen := genesis.Genesis{ + ChainID: "test-chain", + InitialHeight: 1, + StartTime: time.Now().UTC(), + DAStartHeight: 0, + } + + // Setup state in store + storeHeight := uint64(10) + mockStore.EXPECT().GetState(mock.Anything).Return( + types.State{ + ChainID: gen.ChainID, + InitialHeight: gen.InitialHeight, + LastBlockHeight: storeHeight, + LastBlockTime: time.Now().UTC(), + DAHeight: 5, + AppHash: []byte("app-hash"), + }, + nil, + ) + + // Setup execution layer to be in sync + mockExec.On("GetLatestHeight", mock.Anything).Return(storeHeight, nil) + + // Create syncer with minimal dependencies + syncer := &Syncer{ + store: mockStore, + exec: mockExec, + genesis: gen, + lastState: &atomic.Pointer[types.State]{}, + daHeight: &atomic.Uint64{}, + logger: zerolog.Nop(), + ctx: context.Background(), + } + + // Initialize state - this should call Replayer + err := syncer.initializeState() + require.NoError(t, err) + + // Verify state was initialized correctly + state := syncer.GetLastState() + assert.Equal(t, storeHeight, state.LastBlockHeight) + assert.Equal(t, gen.ChainID, state.ChainID) + assert.Equal(t, uint64(5), syncer.GetDAHeight()) + + // Verify that GetLatestHeight was called (proves Replayer was invoked) + mockExec.AssertCalled(t, "GetLatestHeight", mock.Anything) +} diff --git a/core/execution/execution.go b/core/execution/execution.go index f589275373..896e2d65af 100644 --- a/core/execution/execution.go +++ b/core/execution/execution.go @@ -85,3 +85,19 @@ type Executor interface { // - error: Any errors during finalization SetFinal(ctx context.Context, blockHeight uint64) error } + +// HeightProvider is an optional interface that execution clients can implement +// to support height synchronization checks between ev-node and the execution layer. +type HeightProvider interface { + // GetLatestHeight returns the current block height of the execution layer. + // This is useful for detecting desynchronization between ev-node and the execution layer + // after crashes or restarts. + // + // Parameters: + // - ctx: Context for timeout/cancellation control + // + // Returns: + // - height: Current block height of the execution layer + // - error: Any errors during height retrieval + GetLatestHeight(ctx context.Context) (uint64, error) +} diff --git a/docs/learn/specs/block-manager.md b/docs/learn/specs/block-manager.md index f7994f6db2..b74087a4c9 100644 --- a/docs/learn/specs/block-manager.md +++ b/docs/learn/specs/block-manager.md @@ -626,6 +626,7 @@ The components communicate through well-defined interfaces: ### Initialization and State Management - Components load the initial state from the local store and use genesis if not found in the local store, when the node (re)starts +- During startup the Syncer invokes the execution Replayer to re-execute any blocks the local execution layer is missing; the replayer enforces strict app-hash matching so a mismatch aborts initialization instead of silently drifting out of sync - The default mode for aggregator nodes is normal (not lazy) - Components coordinate through channels and shared cache structures diff --git a/execution/evm/execution.go b/execution/evm/execution.go index f1b35a5412..f168fc1254 100644 --- a/execution/evm/execution.go +++ b/execution/evm/execution.go @@ -309,6 +309,15 @@ func (c *EngineClient) getBlockInfo(ctx context.Context, height uint64) (common. return header.Hash(), header.Root, header.GasLimit, header.Time, nil } +// GetLatestHeight returns the current block height of the execution layer +func (c *EngineClient) GetLatestHeight(ctx context.Context) (uint64, error) { + header, err := c.ethClient.HeaderByNumber(ctx, nil) // nil = latest block + if err != nil { + return 0, fmt.Errorf("failed to get latest block: %w", err) + } + return header.Number.Uint64(), nil +} + // decodeSecret decodes a hex-encoded JWT secret string into a byte slice. func decodeSecret(jwtSecret string) ([]byte, error) { secret, err := hex.DecodeString(strings.TrimPrefix(jwtSecret, "0x")) diff --git a/go.mod b/go.mod index 8c92637b64..73a0a33966 100644 --- a/go.mod +++ b/go.mod @@ -161,3 +161,5 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/blake3 v1.4.1 // indirect ) + +replace github.com/evstack/ev-node/core => ./core diff --git a/go.sum b/go.sum index 4ff3295ac2..5b1debef77 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,6 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/evstack/ev-node/core v1.0.0-beta.3 h1:01K2Ygm3puX4m2OBxvg/HDxu+he54jeNv+KDmpgujFc= -github.com/evstack/ev-node/core v1.0.0-beta.3/go.mod h1:n2w/LhYQTPsi48m6lMj16YiIqsaQw6gxwjyJvR+B3sY= github.com/filecoin-project/go-clock v0.1.0 h1:SFbYIM75M8NnFm1yMHhN9Ahy3W5bEZV9gd6MPfXbKVU= github.com/filecoin-project/go-clock v0.1.0/go.mod h1:4uB/O4PvOjlx1VCMdZ9MyDZXRm//gkj1ELEbxfI1AZs= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= diff --git a/test/mocks/height_aware_executor.go b/test/mocks/height_aware_executor.go new file mode 100644 index 0000000000..8d8e7d60aa --- /dev/null +++ b/test/mocks/height_aware_executor.go @@ -0,0 +1,60 @@ +// Package mocks provides mock implementations for testing. +// This file contains a manual mock that combines Executor and HeightProvider interfaces. +package mocks + +import ( + "context" + "time" + + "github.com/stretchr/testify/mock" +) + +// NewMockHeightAwareExecutor creates a new instance of MockHeightAwareExecutor. +// It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewMockHeightAwareExecutor(t interface { + mock.TestingT + Cleanup(func()) +}) *MockHeightAwareExecutor { + mockExec := &MockHeightAwareExecutor{} + mockExec.Test(t) + + t.Cleanup(func() { mockExec.AssertExpectations(t) }) + + return mockExec +} + +// MockHeightAwareExecutor is a mock that implements both Executor and HeightProvider interfaces. +// This allows testing code that needs an executor with height awareness capability. +type MockHeightAwareExecutor struct { + mock.Mock +} + +// InitChain implements the Executor interface. +func (m *MockHeightAwareExecutor) InitChain(ctx context.Context, genesisTime time.Time, initialHeight uint64, chainID string) ([]byte, uint64, error) { + args := m.Called(ctx, genesisTime, initialHeight, chainID) + return args.Get(0).([]byte), args.Get(1).(uint64), args.Error(2) +} + +// GetTxs implements the Executor interface. +func (m *MockHeightAwareExecutor) GetTxs(ctx context.Context) ([][]byte, error) { + args := m.Called(ctx) + return args.Get(0).([][]byte), args.Error(1) +} + +// ExecuteTxs implements the Executor interface. +func (m *MockHeightAwareExecutor) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) ([]byte, uint64, error) { + args := m.Called(ctx, txs, blockHeight, timestamp, prevStateRoot) + return args.Get(0).([]byte), args.Get(1).(uint64), args.Error(2) +} + +// SetFinal implements the Executor interface. +func (m *MockHeightAwareExecutor) SetFinal(ctx context.Context, blockHeight uint64) error { + args := m.Called(ctx, blockHeight) + return args.Error(0) +} + +// GetLatestHeight implements the HeightProvider interface. +func (m *MockHeightAwareExecutor) GetLatestHeight(ctx context.Context) (uint64, error) { + args := m.Called(ctx) + return args.Get(0).(uint64), args.Error(1) +}