diff --git a/.github/workflows/proto.yml b/.github/workflows/proto.yml index ace34104b1..361cc08520 100644 --- a/.github/workflows/proto.yml +++ b/.github/workflows/proto.yml @@ -15,3 +15,17 @@ jobs: - uses: bufbuild/buf-action@v1 with: format: false + breaking: false + - name: Check protobuf breaking changes + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }} + run: | + if [ -z "$BASE_SHA" ] || [[ "$BASE_SHA" =~ ^0+$ ]]; then + echo "No base SHA available for buf breaking check" + exit 0 + fi + + buf breaking proto \ + --limit-to-input-files \ + --error-format github-actions \ + --against "https://github.com/${{ github.repository }}.git#format=git,commit=${BASE_SHA}" diff --git a/apps/evm/go.mod b/apps/evm/go.mod index 79ceff132c..3370b09d8f 100644 --- a/apps/evm/go.mod +++ b/apps/evm/go.mod @@ -4,6 +4,7 @@ go 1.25.8 replace ( github.com/evstack/ev-node => ../../ + github.com/evstack/ev-node/core => ../../core github.com/evstack/ev-node/execution/evm => ../../execution/evm ) diff --git a/apps/evm/go.sum b/apps/evm/go.sum index 06dcdaa2fc..eccf113762 100644 --- a/apps/evm/go.sum +++ b/apps/evm/go.sum @@ -228,8 +228,6 @@ github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJ github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= github.com/ethereum/go-ethereum v1.17.3 h1:Ev/sQHH+UdKZHWjuVzhu2pxhi/sXaPZl23Q+Q5LDd4Q= github.com/ethereum/go-ethereum v1.17.3/go.mod h1:f2EhRwqewIZkGoQekywI2Y2RZAMTSavLNkD9qItFy1A= -github.com/evstack/ev-node/core v1.0.0 h1:s0Tx0uWHme7SJn/ZNEtee4qNM8UO6PIxXnHhPbbKTz8= -github.com/evstack/ev-node/core v1.0.0/go.mod h1:n2w/LhYQTPsi48m6lMj16YiIqsaQw6gxwjyJvR+B3sY= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= diff --git a/apps/grpc/go.mod b/apps/grpc/go.mod index e0b54cab06..c910ba5505 100644 --- a/apps/grpc/go.mod +++ b/apps/grpc/go.mod @@ -4,6 +4,7 @@ go 1.25.8 replace ( github.com/evstack/ev-node => ../../ + github.com/evstack/ev-node/core => ../../core github.com/evstack/ev-node/execution/grpc => ../../execution/grpc ) diff --git a/apps/grpc/go.sum b/apps/grpc/go.sum index 1aa14866c1..670f159dd1 100644 --- a/apps/grpc/go.sum +++ b/apps/grpc/go.sum @@ -188,8 +188,6 @@ github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= -github.com/evstack/ev-node/core v1.0.0 h1:s0Tx0uWHme7SJn/ZNEtee4qNM8UO6PIxXnHhPbbKTz8= -github.com/evstack/ev-node/core v1.0.0/go.mod h1:n2w/LhYQTPsi48m6lMj16YiIqsaQw6gxwjyJvR+B3sY= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= diff --git a/apps/testapp/Dockerfile b/apps/testapp/Dockerfile index e41335f7b6..dd21ab5697 100644 --- a/apps/testapp/Dockerfile +++ b/apps/testapp/Dockerfile @@ -21,6 +21,7 @@ WORKDIR /ev-node # Dependencies are only re-downloaded when go.mod or go.sum change. COPY go.mod go.sum ./ COPY apps/testapp/go.mod apps/testapp/go.sum ./apps/testapp/ +COPY core/go.mod core/go.sum ./core/ RUN go mod download && (cd apps/testapp && go mod download) # Copy the rest of the source and build. diff --git a/apps/testapp/go.mod b/apps/testapp/go.mod index e429abf388..8dc07fabd2 100644 --- a/apps/testapp/go.mod +++ b/apps/testapp/go.mod @@ -2,7 +2,10 @@ module github.com/evstack/ev-node/apps/testapp go 1.25.8 -replace github.com/evstack/ev-node => ../../. +replace ( + github.com/evstack/ev-node => ../../. + github.com/evstack/ev-node/core => ../../core +) require ( github.com/evstack/ev-node v1.1.1 diff --git a/apps/testapp/go.sum b/apps/testapp/go.sum index 1aa14866c1..670f159dd1 100644 --- a/apps/testapp/go.sum +++ b/apps/testapp/go.sum @@ -188,8 +188,6 @@ github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= -github.com/evstack/ev-node/core v1.0.0 h1:s0Tx0uWHme7SJn/ZNEtee4qNM8UO6PIxXnHhPbbKTz8= -github.com/evstack/ev-node/core v1.0.0/go.mod h1:n2w/LhYQTPsi48m6lMj16YiIqsaQw6gxwjyJvR+B3sY= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= diff --git a/apps/testapp/kv/kvexecutor.go b/apps/testapp/kv/kvexecutor.go index aef3aedf3a..1a3ec4b776 100644 --- a/apps/testapp/kv/kvexecutor.go +++ b/apps/testapp/kv/kvexecutor.go @@ -239,16 +239,16 @@ func (k *KVExecutor) GetTxs(ctx context.Context) ([][]byte, error) { // ExecuteTxs processes each transaction assumed to be in the format "key=value". // It updates the database accordingly using a batch and removes the executed transactions from the mempool. // Invalid transactions are filtered out and logged, but execution continues. -func (k *KVExecutor) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) ([]byte, error) { +func (k *KVExecutor) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) (execution.ExecuteResult, error) { select { case <-ctx.Done(): - return nil, ctx.Err() + return execution.ExecuteResult{}, ctx.Err() default: } batch, err := k.db.Batch(ctx) if err != nil { - return nil, fmt.Errorf("failed to create database batch: %w", err) + return execution.ExecuteResult{}, fmt.Errorf("failed to create database batch: %w", err) } validTxCount := 0 @@ -291,7 +291,7 @@ func (k *KVExecutor) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight u err = batch.Put(ctx, dsKey, []byte(value)) if err != nil { // This error is unlikely for Put unless the context is cancelled. - return nil, fmt.Errorf("failed to stage put operation in batch for key '%s': %w", key, err) + return execution.ExecuteResult{}, fmt.Errorf("failed to stage put operation in batch for key '%s': %w", key, err) } validTxCount++ } @@ -304,7 +304,7 @@ func (k *KVExecutor) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight u // Commit the batch to apply all changes atomically err = batch.Commit(ctx) if err != nil { - return nil, fmt.Errorf("failed to commit transaction batch: %w", err) + return execution.ExecuteResult{}, fmt.Errorf("failed to commit transaction batch: %w", err) } k.blocksProduced.Add(1) @@ -315,10 +315,10 @@ func (k *KVExecutor) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight u if err != nil { // This is problematic, state was changed but root calculation failed. // May need more robust error handling or recovery logic. - return nil, fmt.Errorf("failed to compute state root after executing transactions: %w", err) + return execution.ExecuteResult{}, fmt.Errorf("failed to compute state root after executing transactions: %w", err) } - return stateRoot, nil + return execution.ExecuteResult{UpdatedStateRoot: stateRoot}, nil } // SetFinal marks a block as finalized at the specified height. diff --git a/apps/testapp/kv/kvexecutor_test.go b/apps/testapp/kv/kvexecutor_test.go index 97280aee10..486fa576f8 100644 --- a/apps/testapp/kv/kvexecutor_test.go +++ b/apps/testapp/kv/kvexecutor_test.go @@ -105,13 +105,13 @@ func TestExecuteTxs_Valid(t *testing.T) { []byte("key2=value2"), } - stateRoot, err := exec.ExecuteTxs(ctx, txs, 1, time.Now(), []byte("")) + result, err := exec.ExecuteTxs(ctx, txs, 1, time.Now(), []byte("")) if err != nil { t.Fatalf("ExecuteTxs failed: %v", err) } // Check that stateRoot contains the updated key-value pairs - rootStr := string(stateRoot) + rootStr := string(result.UpdatedStateRoot) if !strings.Contains(rootStr, "key1:value1;") || !strings.Contains(rootStr, "key2:value2;") { t.Errorf("State root does not contain expected key-values: %s", rootStr) } @@ -134,13 +134,13 @@ func TestExecuteTxs_Invalid(t *testing.T) { []byte(""), } - stateRoot, err := exec.ExecuteTxs(ctx, txs, 1, time.Now(), []byte("")) + result, err := exec.ExecuteTxs(ctx, txs, 1, time.Now(), []byte("")) if err != nil { t.Fatalf("ExecuteTxs should handle gibberish gracefully, got error: %v", err) } // State root should still be computed (empty block is valid) - if stateRoot == nil { + if result.UpdatedStateRoot == nil { t.Error("Expected non-nil state root even with all invalid transactions") } @@ -152,13 +152,13 @@ func TestExecuteTxs_Invalid(t *testing.T) { []byte(""), } - stateRoot2, err := exec.ExecuteTxs(ctx, mixedTxs, 2, time.Now(), stateRoot) + result2, err := exec.ExecuteTxs(ctx, mixedTxs, 2, time.Now(), result.UpdatedStateRoot) if err != nil { t.Fatalf("ExecuteTxs should filter invalid transactions and process valid ones, got error: %v", err) } // State root should contain only the valid transactions - rootStr := string(stateRoot2) + rootStr := string(result2.UpdatedStateRoot) if !strings.Contains(rootStr, "valid_key:valid_value") || !strings.Contains(rootStr, "another_valid:value2") { t.Errorf("State root should contain valid transactions: %s", rootStr) } diff --git a/block/internal/common/replay.go b/block/internal/common/replay.go index ba13a5a4b7..23bf6f7a6a 100644 --- a/block/internal/common/replay.go +++ b/block/internal/common/replay.go @@ -150,13 +150,19 @@ func (s *Replayer) replayBlock(ctx context.Context, height uint64) error { // Get the previous state var prevState types.State if height == s.genesis.InitialHeight { - // For the first block, use genesis state. + // For the first block, use genesis state. Mirror Syncer.initializeState(): + // prefer the execution layer's view of the next proposer, fall back to genesis. + nextProposer := append([]byte(nil), s.genesis.ProposerAddress...) + if info, infoErr := s.exec.GetExecutionInfo(ctx); infoErr == nil && len(info.NextProposerAddress) > 0 { + nextProposer = append([]byte(nil), info.NextProposerAddress...) + } prevState = types.State{ - ChainID: s.genesis.ChainID, - InitialHeight: s.genesis.InitialHeight, - LastBlockHeight: s.genesis.InitialHeight - 1, - LastBlockTime: s.genesis.StartTime, - AppHash: header.AppHash, // Genesis app hash (input to first block execution) + ChainID: s.genesis.ChainID, + InitialHeight: s.genesis.InitialHeight, + LastBlockHeight: s.genesis.InitialHeight - 1, + LastBlockTime: s.genesis.StartTime, + AppHash: header.AppHash, // Genesis app hash (input to first block execution) + NextProposerAddress: nextProposer, } } else { // GetStateAtHeight(height-1) returns the state AFTER block height-1 was executed, @@ -179,10 +185,16 @@ func (s *Replayer) replayBlock(ctx context.Context, height uint64) error { Int("tx_count", len(rawTxs)). Msg("executing transactions on execution layer") - newAppHash, err := s.exec.ExecuteTxs(ctx, rawTxs, height, header.Time(), prevState.AppHash) + result, err := s.exec.ExecuteTxs(ctx, rawTxs, height, header.Time(), prevState.AppHash) if err != nil { return fmt.Errorf("failed to execute transactions: %w", err) } + newAppHash := result.UpdatedStateRoot + + newState, err := prevState.NextState(header.Header, newAppHash, result.NextProposerAddress) + if err != nil { + return fmt.Errorf("calculate next state: %w", err) + } // The result of ExecuteTxs (newAppHash) should match the stored state at this height. // Note: header.AppHash is the PREVIOUS state's app hash (input), not the expected output. @@ -207,6 +219,15 @@ func (s *Replayer) replayBlock(ctx context.Context, height uint64) error { Msg("app hash mismatch during replay") return err } + if len(expectedState.NextProposerAddress) > 0 { + if !bytes.Equal(newState.NextProposerAddress, expectedState.NextProposerAddress) { + return fmt.Errorf("next proposer mismatch at height %d: expected %x got %x", + height, + expectedState.NextProposerAddress, + newState.NextProposerAddress, + ) + } + } s.logger.Debug(). Uint64("height", height). Str("app_hash", hex.EncodeToString(newAppHash)). @@ -219,12 +240,6 @@ func (s *Replayer) replayBlock(ctx context.Context, height uint64) error { Msg("replayBlock: ExecuteTxs completed (no stored state to verify against)") } - // Calculate new state - newState, err := prevState.NextState(header.Header, newAppHash) - if err != nil { - return fmt.Errorf("calculate next state: %w", err) - } - // Persist the new state batch, err := s.store.NewBatch(ctx) if err != nil { diff --git a/block/internal/executing/executor.go b/block/internal/executing/executor.go index f5be5e1b40..4d62f8600d 100644 --- a/block/internal/executing/executor.go +++ b/block/internal/executing/executor.go @@ -121,14 +121,6 @@ func NewExecutor( return nil, errors.New("signer cannot be nil") } - addr, err := signer.GetAddress() - if err != nil { - return nil, fmt.Errorf("failed to get address: %w", err) - } - - if !bytes.Equal(addr, genesis.ProposerAddress) { - return nil, common.ErrNotProposer - } } if raftNode != nil && reflect.ValueOf(raftNode).IsNil() { raftNode = nil @@ -242,15 +234,22 @@ func (e *Executor) initializeState() error { } state = types.State{ - ChainID: e.genesis.ChainID, - InitialHeight: e.genesis.InitialHeight, - LastBlockHeight: e.genesis.InitialHeight - 1, - LastBlockTime: e.genesis.StartTime, - AppHash: stateRoot, + ChainID: e.genesis.ChainID, + InitialHeight: e.genesis.InitialHeight, + LastBlockHeight: e.genesis.InitialHeight - 1, + LastBlockTime: e.genesis.StartTime, + AppHash: stateRoot, + NextProposerAddress: e.initialProposerAddress(e.ctx), // DA start height is usually 0 at InitChain unless it is a re-genesis or a based sequencer. DAHeight: e.genesis.DAStartHeight, } } + if len(state.NextProposerAddress) == 0 { + state.NextProposerAddress = e.initialProposerAddress(e.ctx) + } + if err := e.assertConfiguredSigner(state.NextProposerAddress); err != nil { + return err + } if e.raftNode != nil { // Ensure node is fully synced before producing any blocks @@ -379,6 +378,32 @@ func (e *Executor) initializeState() error { return nil } +func (e *Executor) initialProposerAddress(ctx context.Context) []byte { + if e.exec != nil { + info, err := e.exec.GetExecutionInfo(ctx) + if err != nil { + e.logger.Warn().Err(err).Msg("failed to get execution info for proposer, falling back to genesis proposer") + } else if len(info.NextProposerAddress) > 0 { + return append([]byte(nil), info.NextProposerAddress...) + } + } + return append([]byte(nil), e.genesis.ProposerAddress...) +} + +func (e *Executor) assertConfiguredSigner(expectedProposer []byte) error { + if e.config.Node.BasedSequencer { + return nil + } + addr, err := e.signer.GetAddress() + if err != nil { + return fmt.Errorf("failed to get address: %w", err) + } + if !bytes.Equal(addr, expectedProposer) { + return common.ErrNotProposer + } + return nil +} + // executionLoop handles block production and aggregation func (e *Executor) executionLoop() { e.logger.Info().Msg("starting execution loop") @@ -696,6 +721,10 @@ func (e *Executor) RetrieveBatch(ctx context.Context) (*BatchData, error) { func (e *Executor) CreateBlock(ctx context.Context, height uint64, batchData *BatchData) (*types.SignedHeader, *types.Data, error) { currentState := e.getLastState() headerTime := uint64(e.genesis.StartTime.UnixNano()) + proposerAddress := currentState.NextProposerAddress + if len(proposerAddress) == 0 { + proposerAddress = e.genesis.ProposerAddress + } var lastHeaderHash types.Hash var lastDataHash types.Hash @@ -736,14 +765,21 @@ func (e *Executor) CreateBlock(ctx context.Context, height uint64, batchData *Ba if err != nil { return nil, nil, fmt.Errorf("failed to get public key: %w", err) } + addr, err := e.signer.GetAddress() + if err != nil { + return nil, nil, fmt.Errorf("failed to get address: %w", err) + } + if !bytes.Equal(addr, proposerAddress) { + return nil, nil, common.ErrNotProposer + } - validatorHash, err = e.options.ValidatorHasherProvider(e.genesis.ProposerAddress, pubKey) + validatorHash, err = e.options.ValidatorHasherProvider(proposerAddress, pubKey) if err != nil { return nil, nil, fmt.Errorf("failed to get validator hash: %w", err) } } else { var err error - validatorHash, err = e.options.ValidatorHasherProvider(e.genesis.ProposerAddress, nil) + validatorHash, err = e.options.ValidatorHasherProvider(proposerAddress, nil) if err != nil { return nil, nil, fmt.Errorf("failed to get validator hash: %w", err) } @@ -763,13 +799,13 @@ func (e *Executor) CreateBlock(ctx context.Context, height uint64, batchData *Ba }, LastHeaderHash: lastHeaderHash, AppHash: currentState.AppHash, - ProposerAddress: e.genesis.ProposerAddress, + ProposerAddress: proposerAddress, ValidatorHash: validatorHash, }, Signature: lastSignature, Signer: types.Signer{ PubKey: pubKey, - Address: e.genesis.ProposerAddress, + Address: proposerAddress, }, } @@ -813,14 +849,14 @@ func (e *Executor) ApplyBlock(ctx context.Context, header types.Header, data *ty // Execute transactions execCtx := context.WithValue(ctx, types.HeaderContextKey, header) - newAppHash, err := e.executeTxsWithRetry(execCtx, rawTxs, header, currentState) + result, err := e.executeTxsWithRetry(execCtx, rawTxs, header, currentState) if err != nil { e.sendCriticalError(fmt.Errorf("failed to execute transactions: %w", err)) return types.State{}, fmt.Errorf("failed to execute transactions: %w", err) } // Create new state - newState, err := currentState.NextState(header, newAppHash) + newState, err := currentState.NextState(header, result.UpdatedStateRoot, result.NextProposerAddress) if err != nil { return types.State{}, fmt.Errorf("failed to create next state: %w", err) } @@ -851,12 +887,12 @@ func (e *Executor) signHeader(ctx context.Context, header *types.Header) (types. // executeTxsWithRetry executes transactions with retry logic. // NOTE: the function retries the execution client call regardless of the error. Some execution clients errors are irrecoverable, and will eventually halt the node, as expected. -func (e *Executor) executeTxsWithRetry(ctx context.Context, rawTxs [][]byte, header types.Header, currentState types.State) ([]byte, error) { +func (e *Executor) executeTxsWithRetry(ctx context.Context, rawTxs [][]byte, header types.Header, currentState types.State) (coreexecutor.ExecuteResult, error) { for attempt := 1; attempt <= common.MaxRetriesBeforeHalt; attempt++ { - newAppHash, err := e.exec.ExecuteTxs(ctx, rawTxs, header.Height(), header.Time(), currentState.AppHash) + result, err := e.exec.ExecuteTxs(ctx, rawTxs, header.Height(), header.Time(), currentState.AppHash) if err != nil { if attempt == common.MaxRetriesBeforeHalt { - return nil, fmt.Errorf("failed to execute transactions: %w", err) + return coreexecutor.ExecuteResult{}, fmt.Errorf("failed to execute transactions: %w", err) } e.logger.Error().Err(err). @@ -869,14 +905,14 @@ func (e *Executor) executeTxsWithRetry(ctx context.Context, rawTxs [][]byte, hea case <-time.After(common.MaxRetriesTimeout): continue case <-e.ctx.Done(): - return nil, fmt.Errorf("context cancelled during retry: %w", e.ctx.Err()) + return coreexecutor.ExecuteResult{}, fmt.Errorf("context cancelled during retry: %w", e.ctx.Err()) } } - return newAppHash, nil + return result, nil } - return nil, nil + return coreexecutor.ExecuteResult{}, nil } // sendCriticalError sends a critical error to the error channel without blocking diff --git a/block/internal/executing/executor_benchmark_test.go b/block/internal/executing/executor_benchmark_test.go index be71d8fe26..da13a5f760 100644 --- a/block/internal/executing/executor_benchmark_test.go +++ b/block/internal/executing/executor_benchmark_test.go @@ -149,8 +149,8 @@ func (s *stubExecClient) InitChain(context.Context, time.Time, uint64, string) ( return s.stateRoot, nil } func (s *stubExecClient) GetTxs(context.Context) ([][]byte, error) { return nil, nil } -func (s *stubExecClient) ExecuteTxs(_ context.Context, _ [][]byte, _ uint64, _ time.Time, _ []byte) ([]byte, error) { - return s.stateRoot, nil +func (s *stubExecClient) ExecuteTxs(_ context.Context, _ [][]byte, _ uint64, _ time.Time, _ []byte) (coreexec.ExecuteResult, error) { + return coreexec.ExecuteResult{UpdatedStateRoot: s.stateRoot}, nil } func (s *stubExecClient) SetFinal(context.Context, uint64) error { return nil } func (s *stubExecClient) GetExecutionInfo(context.Context) (coreexec.ExecutionInfo, error) { diff --git a/block/internal/executing/executor_logic_test.go b/block/internal/executing/executor_logic_test.go index 1498bf5f79..f010dd80ef 100644 --- a/block/internal/executing/executor_logic_test.go +++ b/block/internal/executing/executor_logic_test.go @@ -19,6 +19,7 @@ import ( "github.com/evstack/ev-node/block/internal/cache" "github.com/evstack/ev-node/block/internal/common" + coreexec "github.com/evstack/ev-node/core/execution" coreseq "github.com/evstack/ev-node/core/sequencer" "github.com/evstack/ev-node/pkg/config" "github.com/evstack/ev-node/pkg/genesis" @@ -68,6 +69,41 @@ func TestProduceBlock_EmptyBatch_SetsEmptyDataHash(t *testing.T) { require.NoError(t, err) assert.Equal(t, 0, len(data.Txs)) assert.EqualValues(t, common.DataHashForEmptyTxs, sh.DataHash) + + state, err := fx.MemStore.GetState(context.Background()) + require.NoError(t, err) + assert.Equal(t, fx.Exec.genesis.ProposerAddress, state.NextProposerAddress) +} + +func TestProduceBlock_PersistsExecutionNextProposer(t *testing.T) { + fx := setupTestExecutor(t, 1000) + defer fx.Cancel() + + nextAddr, _, _ := buildTestSigner(t) + + fx.MockSeq.EXPECT().GetNextBatch(mock.Anything, mock.AnythingOfType("sequencer.GetNextBatchRequest")). + RunAndReturn(func(ctx context.Context, req coreseq.GetNextBatchRequest) (*coreseq.GetNextBatchResponse, error) { + return &coreseq.GetNextBatchResponse{Batch: &coreseq.Batch{Transactions: nil}, Timestamp: time.Now()}, nil + }).Once() + + fx.MockExec.EXPECT().ExecuteTxs(mock.Anything, mock.Anything, uint64(1), mock.AnythingOfType("time.Time"), fx.InitStateRoot). + Return(coreexec.ExecuteResult{ + UpdatedStateRoot: []byte("new_root"), + NextProposerAddress: nextAddr, + }, nil).Once() + + fx.MockSeq.EXPECT().GetDAHeight().Return(uint64(0)).Once() + + require.NoError(t, fx.Exec.ProduceBlock(fx.Exec.ctx)) + + header, data, err := fx.MemStore.GetBlockData(context.Background(), 1) + require.NoError(t, err) + require.NoError(t, header.ValidateBasicWithData(data)) + + state, err := fx.MemStore.GetState(context.Background()) + require.NoError(t, err) + assert.Equal(t, nextAddr, state.NextProposerAddress) + assert.Equal(t, header.Hash(), state.LastHeaderHash) } func TestProduceBlock_OutputPassesValidation(t *testing.T) { @@ -220,7 +256,7 @@ func TestExecutor_executeTxsWithRetry(t *testing.T) { if tt.expectSuccess { require.NoError(t, err) - assert.Equal(t, tt.expectHash, result) + assert.Equal(t, tt.expectHash, result.UpdatedStateRoot) } else { require.Error(t, err) if tt.expectError != "" { diff --git a/block/internal/reaping/bench_test.go b/block/internal/reaping/bench_test.go index 5ec0aaa69d..3c879148a1 100644 --- a/block/internal/reaping/bench_test.go +++ b/block/internal/reaping/bench_test.go @@ -60,8 +60,8 @@ func (e *infiniteExecutor) GetTxs(_ context.Context) ([][]byte, error) { return txs, nil } -func (e *infiniteExecutor) ExecuteTxs(_ context.Context, _ [][]byte, _ uint64, _ time.Time, _ []byte) ([]byte, error) { - return nil, nil +func (e *infiniteExecutor) ExecuteTxs(_ context.Context, _ [][]byte, _ uint64, _ time.Time, _ []byte) (coreexecutor.ExecuteResult, error) { + return coreexecutor.ExecuteResult{}, nil } func (e *infiniteExecutor) FilterTxs(_ context.Context, txs [][]byte, _ uint64, _ uint64, _ bool) ([]coreexecutor.FilterStatus, error) { diff --git a/block/internal/submitting/da_submitter.go b/block/internal/submitting/da_submitter.go index 83f56d9cb5..0a71a347ce 100644 --- a/block/internal/submitting/da_submitter.go +++ b/block/internal/submitting/da_submitter.go @@ -363,6 +363,14 @@ func (s *DASubmitter) signEnvelopesParallel( // signAndCacheEnvelope signs a single header and caches the result. func (s *DASubmitter) signAndCacheEnvelope(ctx context.Context, header *types.SignedHeader, marshalledHeader []byte, signer signer.Signer) ([]byte, error) { + addr, err := signer.GetAddress() + if err != nil { + return nil, fmt.Errorf("failed to get signer address: %w", err) + } + if len(header.Signer.Address) > 0 && !bytes.Equal(addr, header.Signer.Address) { + return nil, fmt.Errorf("envelope signer address mismatch: got %x, expected %x", addr, header.Signer.Address) + } + // Sign the pre-marshalled header content envelopeSignature, err := signer.Sign(ctx, marshalledHeader) if err != nil { @@ -417,7 +425,7 @@ func (s *DASubmitter) setCachedEnvelope(height uint64, envelope []byte) { } // SubmitData submits pending data to DA layer -func (s *DASubmitter) SubmitData(ctx context.Context, unsignedDataList []*types.SignedData, marshalledData [][]byte, cache cache.Manager, signer signer.Signer, genesis genesis.Genesis) error { +func (s *DASubmitter) SubmitData(ctx context.Context, unsignedDataList []*types.SignedData, marshalledData [][]byte, cache cache.Manager, signer signer.Signer) error { if len(unsignedDataList) == 0 { return nil } @@ -427,7 +435,7 @@ func (s *DASubmitter) SubmitData(ctx context.Context, unsignedDataList []*types. } // Sign the data (cache returns unsigned SignedData structs) - signedDataList, signedDataListBz, err := s.signData(ctx, unsignedDataList, marshalledData, signer, genesis) + signedDataList, signedDataListBz, err := s.signData(ctx, unsignedDataList, marshalledData, signer) if err != nil { return fmt.Errorf("failed to sign data: %w", err) } @@ -461,7 +469,7 @@ func (s *DASubmitter) SubmitData(ctx context.Context, unsignedDataList []*types. } // signData signs unsigned SignedData structs returned from cache -func (s *DASubmitter) signData(ctx context.Context, unsignedDataList []*types.SignedData, unsignedDataListBz [][]byte, signer signer.Signer, genesis genesis.Genesis) ([]*types.SignedData, [][]byte, error) { +func (s *DASubmitter) signData(ctx context.Context, unsignedDataList []*types.SignedData, unsignedDataListBz [][]byte, signer signer.Signer) ([]*types.SignedData, [][]byte, error) { if signer == nil { return nil, nil, fmt.Errorf("signer is nil") } @@ -476,10 +484,6 @@ func (s *DASubmitter) signData(ctx context.Context, unsignedDataList []*types.Si return nil, nil, fmt.Errorf("failed to get address: %w", err) } - if len(genesis.ProposerAddress) > 0 && !bytes.Equal(addr, genesis.ProposerAddress) { - return nil, nil, fmt.Errorf("signer address mismatch with genesis proposer") - } - signerInfo := types.Signer{ PubKey: pubKey, Address: addr, diff --git a/block/internal/submitting/da_submitter_integration_test.go b/block/internal/submitting/da_submitter_integration_test.go index b2c4efcd20..09f9d8aa43 100644 --- a/block/internal/submitting/da_submitter_integration_test.go +++ b/block/internal/submitting/da_submitter_integration_test.go @@ -105,7 +105,7 @@ func TestDASubmitter_SubmitHeadersAndData_MarksInclusionAndUpdatesLastSubmitted( dataList, marshalledData, err := cm.GetPendingData(context.Background()) require.NoError(t, err) - require.NoError(t, daSubmitter.SubmitData(context.Background(), dataList, marshalledData, cm, n, gen)) + require.NoError(t, daSubmitter.SubmitData(context.Background(), dataList, marshalledData, cm, n)) // After submission, inclusion markers should be set _, ok := cm.GetHeaderDAIncludedByHeight(1) diff --git a/block/internal/submitting/da_submitter_test.go b/block/internal/submitting/da_submitter_test.go index d25786018b..5c2342539e 100644 --- a/block/internal/submitting/da_submitter_test.go +++ b/block/internal/submitting/da_submitter_test.go @@ -261,7 +261,6 @@ func TestDASubmitter_SubmitData_Success(t *testing.T) { // Create test signer addr, pub, signer := createTestSigner(t) - gen.ProposerAddress = addr // Update submitter genesis to use correct proposer submitter.genesis.ProposerAddress = addr @@ -333,7 +332,7 @@ func TestDASubmitter_SubmitData_Success(t *testing.T) { // Get data from cache and submit signedDataList, marshalledData, err := cm.GetPendingData(ctx) require.NoError(t, err) - err = submitter.SubmitData(ctx, signedDataList, marshalledData, cm, signer, gen) + err = submitter.SubmitData(ctx, signedDataList, marshalledData, cm, signer) require.NoError(t, err) // Verify data is marked as DA included @@ -349,7 +348,6 @@ func TestDASubmitter_SubmitData_SkipsEmptyData(t *testing.T) { // Create test signer addr, pub, signer := createTestSigner(t) - gen.ProposerAddress = addr // Create empty data emptyData := &types.Data{ @@ -387,7 +385,7 @@ func TestDASubmitter_SubmitData_SkipsEmptyData(t *testing.T) { // Get data from cache and submit signedDataList, marshalledData, err := cm.GetPendingData(ctx) require.NoError(t, err) - err = submitter.SubmitData(ctx, signedDataList, marshalledData, cm, signer, gen) + err = submitter.SubmitData(ctx, signedDataList, marshalledData, cm, signer) require.NoError(t, err) mockDA.AssertNotCalled(t, "Submit", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) @@ -397,7 +395,7 @@ func TestDASubmitter_SubmitData_SkipsEmptyData(t *testing.T) { } func TestDASubmitter_SubmitData_NoPendingData(t *testing.T) { - submitter, _, cm, mockDA, gen := setupDASubmitterTest(t) + submitter, _, cm, mockDA, _ := setupDASubmitterTest(t) ctx := context.Background() // Create test signer @@ -406,7 +404,7 @@ func TestDASubmitter_SubmitData_NoPendingData(t *testing.T) { // Get data from cache (should be empty) and submit dataList, marshalledData, err := cm.GetPendingData(ctx) require.NoError(t, err) - err = submitter.SubmitData(ctx, dataList, marshalledData, cm, signer, gen) + err = submitter.SubmitData(ctx, dataList, marshalledData, cm, signer) require.NoError(t, err) // Should succeed with no action mockDA.AssertNotCalled(t, "Submit", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) } @@ -447,7 +445,7 @@ func TestDASubmitter_SubmitData_NilSigner(t *testing.T) { // Get data from cache and submit with nil signer - should fail signedDataList, marshalledData, err := cm.GetPendingData(ctx) require.NoError(t, err) - err = submitter.SubmitData(ctx, signedDataList, marshalledData, cm, nil, gen) + err = submitter.SubmitData(ctx, signedDataList, marshalledData, cm, nil) require.Error(t, err) assert.Contains(t, err.Error(), "signer is nil") mockDA.AssertNotCalled(t, "Submit", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) @@ -503,7 +501,7 @@ func TestDASubmitter_SignData(t *testing.T) { } // Create signed data - resultData, resultDataBz, err := submitter.signData(t.Context(), dataList, dataListBz, signer, gen) + resultData, resultDataBz, err := submitter.signData(t.Context(), dataList, dataListBz, signer) require.NoError(t, err) // Should have 2 items (empty data skipped) @@ -542,7 +540,7 @@ func TestDASubmitter_SignData_NilSigner(t *testing.T) { } // Create signed data with nil signer - should fail - _, _, err := submitter.signData(t.Context(), dataList, dataListBz, nil, gen) + _, _, err := submitter.signData(t.Context(), dataList, dataListBz, nil) require.Error(t, err) assert.Contains(t, err.Error(), "signer is nil") } diff --git a/block/internal/submitting/da_submitter_tracing.go b/block/internal/submitting/da_submitter_tracing.go index e3c531fcf8..6d0ab1a9cb 100644 --- a/block/internal/submitting/da_submitter_tracing.go +++ b/block/internal/submitting/da_submitter_tracing.go @@ -9,7 +9,6 @@ import ( "go.opentelemetry.io/otel/trace" "github.com/evstack/ev-node/block/internal/cache" - "github.com/evstack/ev-node/pkg/genesis" "github.com/evstack/ev-node/pkg/signer" "github.com/evstack/ev-node/types" ) @@ -63,7 +62,7 @@ func (t *tracedDASubmitter) SubmitHeaders(ctx context.Context, headers []*types. return nil } -func (t *tracedDASubmitter) SubmitData(ctx context.Context, signedDataList []*types.SignedData, marshalledData [][]byte, cache cache.Manager, signer signer.Signer, genesis genesis.Genesis) error { +func (t *tracedDASubmitter) SubmitData(ctx context.Context, signedDataList []*types.SignedData, marshalledData [][]byte, cache cache.Manager, signer signer.Signer) error { ctx, span := t.tracer.Start(ctx, "DASubmitter.SubmitData", trace.WithAttributes( attribute.Int("data.count", len(signedDataList)), @@ -86,7 +85,7 @@ func (t *tracedDASubmitter) SubmitData(ctx context.Context, signedDataList []*ty ) } - err := t.inner.SubmitData(ctx, signedDataList, marshalledData, cache, signer, genesis) + err := t.inner.SubmitData(ctx, signedDataList, marshalledData, cache, signer) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) diff --git a/block/internal/submitting/da_submitter_tracing_test.go b/block/internal/submitting/da_submitter_tracing_test.go index 6edc5c5ec1..a6049aadd2 100644 --- a/block/internal/submitting/da_submitter_tracing_test.go +++ b/block/internal/submitting/da_submitter_tracing_test.go @@ -12,7 +12,6 @@ import ( "go.opentelemetry.io/otel/sdk/trace/tracetest" "github.com/evstack/ev-node/block/internal/cache" - "github.com/evstack/ev-node/pkg/genesis" "github.com/evstack/ev-node/pkg/signer" "github.com/evstack/ev-node/pkg/telemetry/testutil" "github.com/evstack/ev-node/types" @@ -20,7 +19,7 @@ import ( type mockDASubmitterAPI struct { submitHeadersFn func(ctx context.Context, headers []*types.SignedHeader, marshalledHeaders [][]byte, cache cache.Manager, signer signer.Signer) error - submitDataFn func(ctx context.Context, signedDataList []*types.SignedData, marshalledData [][]byte, cache cache.Manager, signer signer.Signer, genesis genesis.Genesis) error + submitDataFn func(ctx context.Context, signedDataList []*types.SignedData, marshalledData [][]byte, cache cache.Manager, signer signer.Signer) error } func (m *mockDASubmitterAPI) SubmitHeaders(ctx context.Context, headers []*types.SignedHeader, marshalledHeaders [][]byte, cache cache.Manager, signer signer.Signer) error { @@ -30,9 +29,9 @@ func (m *mockDASubmitterAPI) SubmitHeaders(ctx context.Context, headers []*types return nil } -func (m *mockDASubmitterAPI) SubmitData(ctx context.Context, signedDataList []*types.SignedData, marshalledData [][]byte, cache cache.Manager, signer signer.Signer, genesis genesis.Genesis) error { +func (m *mockDASubmitterAPI) SubmitData(ctx context.Context, signedDataList []*types.SignedData, marshalledData [][]byte, cache cache.Manager, signer signer.Signer) error { if m.submitDataFn != nil { - return m.submitDataFn(ctx, signedDataList, marshalledData, cache, signer, genesis) + return m.submitDataFn(ctx, signedDataList, marshalledData, cache, signer) } return nil } @@ -131,7 +130,7 @@ func TestTracedDASubmitter_SubmitHeaders_Empty(t *testing.T) { func TestTracedDASubmitter_SubmitData_Success(t *testing.T) { mock := &mockDASubmitterAPI{ - submitDataFn: func(ctx context.Context, signedDataList []*types.SignedData, marshalledData [][]byte, cache cache.Manager, signer signer.Signer, genesis genesis.Genesis) error { + submitDataFn: func(ctx context.Context, signedDataList []*types.SignedData, marshalledData [][]byte, cache cache.Manager, signer signer.Signer) error { return nil }, } @@ -147,7 +146,7 @@ func TestTracedDASubmitter_SubmitData_Success(t *testing.T) { []byte("data2data2"), } - err := submitter.SubmitData(ctx, signedDataList, marshalledData, nil, nil, genesis.Genesis{}) + err := submitter.SubmitData(ctx, signedDataList, marshalledData, nil, nil) require.NoError(t, err) spans := sr.Ended() @@ -166,7 +165,7 @@ func TestTracedDASubmitter_SubmitData_Success(t *testing.T) { func TestTracedDASubmitter_SubmitData_Error(t *testing.T) { expectedErr := errors.New("data submission failed") mock := &mockDASubmitterAPI{ - submitDataFn: func(ctx context.Context, signedDataList []*types.SignedData, marshalledData [][]byte, cache cache.Manager, signer signer.Signer, genesis genesis.Genesis) error { + submitDataFn: func(ctx context.Context, signedDataList []*types.SignedData, marshalledData [][]byte, cache cache.Manager, signer signer.Signer) error { return expectedErr }, } @@ -178,7 +177,7 @@ func TestTracedDASubmitter_SubmitData_Error(t *testing.T) { } marshalledData := [][]byte{[]byte("data1")} - err := submitter.SubmitData(ctx, signedDataList, marshalledData, nil, nil, genesis.Genesis{}) + err := submitter.SubmitData(ctx, signedDataList, marshalledData, nil, nil) require.Error(t, err) require.Equal(t, expectedErr, err) diff --git a/block/internal/submitting/submitter.go b/block/internal/submitting/submitter.go index 25dcd781a1..fc4a72fe49 100644 --- a/block/internal/submitting/submitter.go +++ b/block/internal/submitting/submitter.go @@ -26,7 +26,7 @@ import ( // DASubmitterAPI defines minimal methods needed by Submitter for DA submissions. type DASubmitterAPI interface { SubmitHeaders(ctx context.Context, headers []*types.SignedHeader, marshalledHeaders [][]byte, cache cache.Manager, signer signer.Signer) error - SubmitData(ctx context.Context, signedDataList []*types.SignedData, marshalledData [][]byte, cache cache.Manager, signer signer.Signer, genesis genesis.Genesis) error + SubmitData(ctx context.Context, signedDataList []*types.SignedData, marshalledData [][]byte, cache cache.Manager, signer signer.Signer) error } // Submitter handles DA submission and inclusion processing for both sync and aggregator nodes @@ -291,7 +291,7 @@ func (s *Submitter) daSubmissionLoop() { Dur("time_since_last", timeSinceLastSubmit). Msg("batching strategy triggered data submission") - if err := s.daSubmitter.SubmitData(s.ctx, signedDataList, marshalledData, s.cache, s.signer, s.genesis); err != nil { + if err := s.daSubmitter.SubmitData(s.ctx, signedDataList, marshalledData, s.cache, s.signer); err != nil { // Check for unrecoverable errors that indicate a critical issue if errors.Is(err, common.ErrOversizedItem) { s.logger.Error().Err(err). diff --git a/block/internal/submitting/submitter_test.go b/block/internal/submitting/submitter_test.go index ff7d2d4e51..ff511c6a1d 100644 --- a/block/internal/submitting/submitter_test.go +++ b/block/internal/submitting/submitter_test.go @@ -429,7 +429,7 @@ func (f *fakeDASubmitter) SubmitHeaders(ctx context.Context, _ []*types.SignedHe return nil } -func (f *fakeDASubmitter) SubmitData(ctx context.Context, _ []*types.SignedData, _ [][]byte, _ cache.Manager, _ signer.Signer, _ genesis.Genesis) error { +func (f *fakeDASubmitter) SubmitData(ctx context.Context, _ []*types.SignedData, _ [][]byte, _ cache.Manager, _ signer.Signer) error { select { case f.chData <- struct{}{}: default: diff --git a/block/internal/syncing/assert.go b/block/internal/syncing/assert.go index 7c77400571..3a23a06876 100644 --- a/block/internal/syncing/assert.go +++ b/block/internal/syncing/assert.go @@ -1,7 +1,6 @@ package syncing import ( - "bytes" "errors" "fmt" @@ -9,21 +8,12 @@ import ( "github.com/evstack/ev-node/types" ) -func assertExpectedProposer(genesis genesis.Genesis, proposerAddr []byte) error { - if !bytes.Equal(proposerAddr, genesis.ProposerAddress) { - return fmt.Errorf("unexpected proposer: got %x, expected %x", - proposerAddr, genesis.ProposerAddress) - } - return nil -} - func assertValidSignedData(signedData *types.SignedData, genesis genesis.Genesis) error { if signedData == nil || signedData.Txs == nil { return errors.New("empty signed data") } - - if err := assertExpectedProposer(genesis, signedData.Signer.Address); err != nil { - return err + if signedData.Signer.PubKey == nil { + return errors.New("missing signer public key in signed data") } dataBytes, err := signedData.Data.MarshalBinary() diff --git a/block/internal/syncing/da_retriever.go b/block/internal/syncing/da_retriever.go index f0e12c1282..b856c72686 100644 --- a/block/internal/syncing/da_retriever.go +++ b/block/internal/syncing/da_retriever.go @@ -28,6 +28,10 @@ type DARetriever interface { ProcessBlobs(ctx context.Context, blobs [][]byte, daHeight uint64) []common.DAHeightEvent } +type pendingDataCleaner interface { + removePendingData(height uint64) +} + // daRetriever handles DA retrieval operations for syncing type daRetriever struct { client da.Client @@ -213,7 +217,6 @@ func (r *daRetriever) processBlobs(ctx context.Context, blobs [][]byte, daHeight } } else { delete(r.pendingHeaders, height) - delete(r.pendingData, height) } // Create height event @@ -245,6 +248,13 @@ func (r *daRetriever) processBlobs(ctx context.Context, blobs [][]byte, daHeight return events } +func (r *daRetriever) removePendingData(height uint64) { + r.mu.Lock() + defer r.mu.Unlock() + + delete(r.pendingData, height) +} + // tryDecodeHeader attempts to decode a blob as a header func (r *daRetriever) tryDecodeHeader(bz []byte, daHeight uint64) *types.SignedHeader { header := new(types.SignedHeader) @@ -299,11 +309,6 @@ func (r *daRetriever) tryDecodeHeader(bz []byte, daHeight uint64) *types.SignedH return nil } - if err := r.assertExpectedProposer(header.ProposerAddress); err != nil { - r.logger.Debug().Err(err).Msg("unexpected proposer") - return nil - } - if isValidEnvelope && !r.strictMode { r.logger.Info().Uint64("height", header.Height()).Msg("valid DA envelope detected, switching to STRICT MODE") r.strictMode = true @@ -355,11 +360,6 @@ func (r *daRetriever) tryDecodeData(bz []byte, daHeight uint64) *types.Data { return &signedData.Data } -// assertExpectedProposer validates the proposer address -func (r *daRetriever) assertExpectedProposer(proposerAddr []byte) error { - return assertExpectedProposer(r.genesis, proposerAddr) -} - // assertValidSignedData validates signed data using the configured signature provider func (r *daRetriever) assertValidSignedData(signedData *types.SignedData) error { return assertValidSignedData(signedData, r.genesis) diff --git a/block/internal/syncing/da_retriever_test.go b/block/internal/syncing/da_retriever_test.go index 3b587def1f..5e180461b1 100644 --- a/block/internal/syncing/da_retriever_test.go +++ b/block/internal/syncing/da_retriever_test.go @@ -215,15 +215,18 @@ func TestDARetriever_TryDecodeHeaderAndData_Basic(t *testing.T) { assert.Nil(t, r.tryDecodeData([]byte("junk"), 1)) } -func TestDARetriever_tryDecodeData_InvalidSignatureOrProposer(t *testing.T) { +func TestDARetriever_tryDecodeData_InvalidSignature(t *testing.T) { - goodAddr, pub, signer := buildSyncTestSigner(t) - badAddr := []byte("not-the-proposer") - gen := genesis.Genesis{ChainID: "tchain", InitialHeight: 1, StartTime: time.Now().Add(-time.Second), ProposerAddress: badAddr} + addr, pub, signer := buildSyncTestSigner(t) + gen := genesis.Genesis{ChainID: "tchain", InitialHeight: 1, StartTime: time.Now().Add(-time.Second), ProposerAddress: addr} r := newTestDARetriever(t, nil, config.DefaultConfig(), gen) - // Signed data is made by goodAddr; retriever expects badAddr -> should be rejected - db, _ := makeSignedDataBytes(t, gen.ChainID, 7, goodAddr, pub, signer, 1) + _, signedData := makeSignedDataBytes(t, gen.ChainID, 7, addr, pub, signer, 1) + require.NotEmpty(t, signedData.Signature) + signedData.Signature[0] ^= 0x01 + db, err := signedData.MarshalBinary() + require.NoError(t, err) + assert.Nil(t, r.tryDecodeData(db, 55)) } @@ -304,9 +307,35 @@ func TestDARetriever_ProcessBlobs_CrossDAHeightMatching(t *testing.T) { assert.Equal(t, uint64(5), event.Data.Height()) assert.Equal(t, uint64(102), event.DaHeight, "DaHeight should be the height where data was processed") - // Verify pending maps are cleared + // Verify the header is consumed, while data remains available until the + // candidate block is accepted by the syncer. require.NotContains(t, r.pendingHeaders, uint64(5), "header should be removed from pending") - require.NotContains(t, r.pendingData, uint64(5), "data should be removed from pending") + require.Contains(t, r.pendingData, uint64(5), "data should remain pending until accepted") + + r.removePendingData(5) + require.NotContains(t, r.pendingData, uint64(5), "accepted data should be removed from pending") +} + +func TestDARetriever_ProcessBlobs_KeepsDataForLaterHeaderAfterCandidateEvent(t *testing.T) { + expectedAddr, expectedPub, expectedSigner := buildSyncTestSigner(t) + wrongAddr, wrongPub, wrongSigner := buildSyncTestSigner(t) + gen := genesis.Genesis{ChainID: "tchain", InitialHeight: 1, StartTime: time.Now().Add(-time.Second), ProposerAddress: expectedAddr} + + r := newTestDARetriever(t, nil, config.DefaultConfig(), gen) + + dataBin, data := makeSignedDataBytes(t, gen.ChainID, 5, expectedAddr, expectedPub, expectedSigner, 2) + wrongHeaderBin, wrongHeader := makeSignedHeaderBytes(t, gen.ChainID, 5, wrongAddr, wrongPub, wrongSigner, nil, &data.Data, nil) + correctHeaderBin, correctHeader := makeSignedHeaderBytes(t, gen.ChainID, 5, expectedAddr, expectedPub, expectedSigner, nil, &data.Data, nil) + + events := r.processBlobs(context.Background(), [][]byte{wrongHeaderBin, dataBin}, 100) + require.Len(t, events, 1) + require.Equal(t, wrongHeader.Hash().String(), events[0].Header.Hash().String()) + require.Contains(t, r.pendingData, uint64(5), "data should stay available until the candidate block is accepted") + + events = r.processBlobs(context.Background(), [][]byte{correctHeaderBin}, 101) + require.Len(t, events, 1) + require.Equal(t, correctHeader.Hash().String(), events[0].Header.Hash().String()) + require.Equal(t, data.Data.DACommitment().String(), events[0].Data.DACommitment().String()) } func TestDARetriever_ProcessBlobs_MultipleHeadersCrossDAHeightMatching(t *testing.T) { @@ -352,6 +381,8 @@ func TestDARetriever_ProcessBlobs_MultipleHeadersCrossDAHeightMatching(t *testin assert.Equal(t, uint64(5), events2[1].Header.Height()) assert.Equal(t, uint64(5), events2[1].Data.Height()) assert.Equal(t, uint64(203), events2[1].DaHeight) + r.removePendingData(3) + r.removePendingData(5) // Verify header 4 is still pending (no matching data yet) require.Contains(t, r.pendingHeaders, uint64(4), "header 4 should still be pending") @@ -366,6 +397,7 @@ func TestDARetriever_ProcessBlobs_MultipleHeadersCrossDAHeightMatching(t *testin assert.Equal(t, uint64(4), events3[0].Header.Height()) assert.Equal(t, uint64(4), events3[0].Data.Height()) assert.Equal(t, uint64(205), events3[0].DaHeight) + r.removePendingData(4) // Verify all pending maps are now clear require.NotContains(t, r.pendingHeaders, uint64(4), "header 4 should be removed from pending") diff --git a/block/internal/syncing/da_retriever_tracing.go b/block/internal/syncing/da_retriever_tracing.go index d41418a1d8..a3f538fa73 100644 --- a/block/internal/syncing/da_retriever_tracing.go +++ b/block/internal/syncing/da_retriever_tracing.go @@ -59,3 +59,9 @@ func (t *tracedDARetriever) RetrieveFromDA(ctx context.Context, daHeight uint64) func (t *tracedDARetriever) ProcessBlobs(ctx context.Context, blobs [][]byte, daHeight uint64) []common.DAHeightEvent { return t.inner.ProcessBlobs(ctx, blobs, daHeight) } + +func (t *tracedDARetriever) removePendingData(height uint64) { + if cleaner, ok := t.inner.(pendingDataCleaner); ok { + cleaner.removePendingData(height) + } +} diff --git a/block/internal/syncing/p2p_handler.go b/block/internal/syncing/p2p_handler.go index a3778757a1..e2aa9c6a3b 100644 --- a/block/internal/syncing/p2p_handler.go +++ b/block/internal/syncing/p2p_handler.go @@ -81,8 +81,9 @@ func (h *P2PHandler) ProcessHeight(ctx context.Context, height uint64, heightInC } return err } - if err := h.assertExpectedProposer(p2pHeader.ProposerAddress); err != nil { - h.logger.Debug().Uint64("height", height).Err(err).Msg("invalid header from P2P") + if got := p2pHeader.Height(); got != height { + err := fmt.Errorf("header height mismatch: requested %d, got %d", height, got) + h.logger.Warn().Uint64("requested_height", height).Uint64("header_height", got).Err(err).Msg("discarding mismatched header from P2P") return err } @@ -93,6 +94,11 @@ func (h *P2PHandler) ProcessHeight(ctx context.Context, height uint64, heightInC } return err } + if got := p2pData.Height(); got != height { + err := fmt.Errorf("data height mismatch: requested %d, got %d", height, got) + h.logger.Warn().Uint64("requested_height", height).Uint64("data_height", got).Err(err).Msg("discarding mismatched data from P2P") + return err + } dataCommitment := p2pData.DACommitment() if !bytes.Equal(p2pHeader.DataHash[:], dataCommitment[:]) { err := fmt.Errorf("data hash mismatch: header %x, data %x", p2pHeader.DataHash, dataCommitment) @@ -124,12 +130,3 @@ func (h *P2PHandler) ProcessHeight(ctx context.Context, height uint64, heightInC h.logger.Debug().Uint64("height", height).Msg("processed event from P2P") return nil } - -// assertExpectedProposer validates the proposer address. -func (h *P2PHandler) assertExpectedProposer(proposerAddr []byte) error { - if !bytes.Equal(h.genesis.ProposerAddress, proposerAddr) { - return fmt.Errorf("proposer address mismatch: got %x, expected %x", - proposerAddr, h.genesis.ProposerAddress) - } - return nil -} diff --git a/block/internal/syncing/p2p_handler_test.go b/block/internal/syncing/p2p_handler_test.go index 8bffc31ede..e92a996550 100644 --- a/block/internal/syncing/p2p_handler_test.go +++ b/block/internal/syncing/p2p_handler_test.go @@ -194,7 +194,7 @@ func TestP2PHandler_ProcessHeight_SkipsWhenHeaderMissing(t *testing.T) { p.DataStore.AssertNotCalled(t, "GetByHeight", mock.Anything, uint64(9)) } -func TestP2PHandler_ProcessHeight_SkipsOnProposerMismatch(t *testing.T) { +func TestP2PHandler_ProcessHeight_AcceptsNonGenesisProposer(t *testing.T) { p := setupP2P(t) ctx := context.Background() var err error @@ -203,16 +203,24 @@ func TestP2PHandler_ProcessHeight_SkipsOnProposerMismatch(t *testing.T) { require.NotEqual(t, string(p.Genesis.ProposerAddress), string(badAddr)) header := p2pMakeSignedHeader(t, p.Genesis.ChainID, 11, badAddr, pub, signer) - header.DataHash = common.DataHashForEmptyTxs + data := &types.P2PData{Data: makeData(p.Genesis.ChainID, 11, 1)} + header.DataHash = data.DACommitment() + bz, err := types.DefaultAggregatorNodeSignatureBytesProvider(&header.Header) + require.NoError(t, err) + sig, err := signer.Sign(t.Context(), bz) + require.NoError(t, err) + header.Signature = sig p.HeaderStore.EXPECT().GetByHeight(mock.Anything, uint64(11)).Return(header, nil).Once() + p.DataStore.EXPECT().GetByHeight(mock.Anything, uint64(11)).Return(data, nil).Once() ch := make(chan common.DAHeightEvent, 1) err = p.Handler.ProcessHeight(ctx, 11, ch) - require.Error(t, err) + require.NoError(t, err) - require.Empty(t, collectEvents(t, ch, 50*time.Millisecond)) - p.DataStore.AssertNotCalled(t, "GetByHeight", mock.Anything, uint64(11)) + events := collectEvents(t, ch, 50*time.Millisecond) + require.Len(t, events, 1) + require.Equal(t, badAddr, events[0].Header.ProposerAddress) } func TestP2PHandler_ProcessedHeightSkipsPreviouslyHandledBlocks(t *testing.T) { diff --git a/block/internal/syncing/raft_retriever.go b/block/internal/syncing/raft_retriever.go index aaebb7a458..a0a527f208 100644 --- a/block/internal/syncing/raft_retriever.go +++ b/block/internal/syncing/raft_retriever.go @@ -125,10 +125,6 @@ func (r *raftRetriever) consumeRaftBlock(ctx context.Context, state *raft.RaftBl r.logger.Debug().Err(err).Msg("invalid header structure") return nil } - if err := assertExpectedProposer(r.genesis, header.ProposerAddress); err != nil { - r.logger.Debug().Err(err).Msg("unexpected proposer") - return nil - } var data types.Data if err := data.UnmarshalBinary(state.Data); err != nil { diff --git a/block/internal/syncing/syncer.go b/block/internal/syncing/syncer.go index 40e3c9523f..e968f012bd 100644 --- a/block/internal/syncing/syncer.go +++ b/block/internal/syncing/syncer.go @@ -320,12 +320,21 @@ func (s *Syncer) initializeState() error { } state = types.State{ - ChainID: s.genesis.ChainID, - InitialHeight: s.genesis.InitialHeight, - LastBlockHeight: s.genesis.InitialHeight - 1, - LastBlockTime: s.genesis.StartTime, - DAHeight: s.genesis.DAStartHeight, - AppHash: stateRoot, + ChainID: s.genesis.ChainID, + InitialHeight: s.genesis.InitialHeight, + LastBlockHeight: s.genesis.InitialHeight - 1, + LastBlockTime: s.genesis.StartTime, + DAHeight: s.genesis.DAStartHeight, + AppHash: stateRoot, + NextProposerAddress: s.initialProposerAddress(s.ctx), + } + } + if len(state.NextProposerAddress) == 0 { + state.NextProposerAddress = s.initialProposerAddress(s.ctx) + if state.LastBlockHeight > s.genesis.InitialHeight-1 { + s.logger.Warn(). + Uint64("height", state.LastBlockHeight). + Msg("loaded state without NextProposerAddress; repaired from execution/genesis. Verify chain has not rotated proposer before this upgrade") } } if state.DAHeight != 0 && state.DAHeight < s.genesis.DAStartHeight { @@ -398,6 +407,18 @@ func (s *Syncer) initializeState() error { return nil } +func (s *Syncer) initialProposerAddress(ctx context.Context) []byte { + if s.exec != nil { + info, err := s.exec.GetExecutionInfo(ctx) + if err != nil { + s.logger.Warn().Err(err).Msg("failed to get execution info for proposer, falling back to genesis proposer") + } else if len(info.NextProposerAddress) > 0 { + return append([]byte(nil), info.NextProposerAddress...) + } + } + return append([]byte(nil), s.genesis.ProposerAddress...) +} + // processLoop is the main coordination loop for processing events func (s *Syncer) processLoop(ctx context.Context) { s.logger.Info().Msg("starting process loop") @@ -725,9 +746,22 @@ func (s *Syncer) trySyncNextBlockWithState(ctx context.Context, event *common.DA // here only the previous block needs to be applied to proceed to the verification. // The header validation must be done before applying the block to avoid executing gibberish if err := s.ValidateBlock(ctx, currentState, data, header); err != nil { - // remove header as da included from cache - s.cache.RemoveHeaderDAIncluded(headerHash) - s.cache.RemoveDataDAIncluded(data.DACommitment().String()) + var vErr *BlockValidationError + switch { + case errors.As(err, &vErr): + switch vErr.Fault { + case FaultHeader: + s.cache.RemoveHeaderDAIncluded(headerHash) + case FaultData: + s.cache.RemoveDataDAIncluded(data.DACommitment().String()) + } + case errors.Is(err, errInvalidState): + // State divergence does not point at a specific side of the pair; + // the cached entries stay so an honest counterpart can still pair up. + default: + s.cache.RemoveHeaderDAIncluded(headerHash) + s.cache.RemoveDataDAIncluded(data.DACommitment().String()) + } if !errors.Is(err, errInvalidState) && !errors.Is(err, errInvalidBlock) { return errors.Join(errInvalidBlock, err) @@ -803,6 +837,12 @@ func (s *Syncer) trySyncNextBlockWithState(ctx context.Context, event *common.DA s.p2pHandler.SetProcessedHeight(newState.LastBlockHeight) } + if event.Source == common.SourceDA { + if cleaner, ok := s.daRetriever.(pendingDataCleaner); ok { + cleaner.removePendingData(nextHeight) + } + } + return nil } @@ -816,14 +856,14 @@ func (s *Syncer) ApplyBlock(ctx context.Context, header types.Header, data *type // Execute transactions ctx = context.WithValue(ctx, types.HeaderContextKey, header) - newAppHash, err := s.executeTxsWithRetry(ctx, rawTxs, header, currentState) + result, err := s.executeTxsWithRetry(ctx, rawTxs, header, currentState) if err != nil { s.sendCriticalError(fmt.Errorf("failed to execute transactions: %w", err)) return types.State{}, fmt.Errorf("failed to execute transactions: %w", err) } // Create new state - newState, err := currentState.NextState(header, newAppHash) + newState, err := currentState.NextState(header, result.UpdatedStateRoot, result.NextProposerAddress) if err != nil { return types.State{}, fmt.Errorf("failed to create next state: %w", err) } @@ -833,12 +873,12 @@ func (s *Syncer) ApplyBlock(ctx context.Context, header types.Header, data *type // executeTxsWithRetry executes transactions with retry logic. // NOTE: the function retries the execution client call regardless of the error. Some execution clients errors are irrecoverable, and will eventually halt the node, as expected. -func (s *Syncer) executeTxsWithRetry(ctx context.Context, rawTxs [][]byte, header types.Header, currentState types.State) ([]byte, error) { +func (s *Syncer) executeTxsWithRetry(ctx context.Context, rawTxs [][]byte, header types.Header, currentState types.State) (coreexecutor.ExecuteResult, error) { for attempt := 1; attempt <= common.MaxRetriesBeforeHalt; attempt++ { - newAppHash, err := s.exec.ExecuteTxs(ctx, rawTxs, header.Height(), header.Time(), currentState.AppHash) + result, err := s.exec.ExecuteTxs(ctx, rawTxs, header.Height(), header.Time(), currentState.AppHash) if err != nil { if attempt == common.MaxRetriesBeforeHalt { - return nil, fmt.Errorf("failed to execute transactions: %w", err) + return coreexecutor.ExecuteResult{}, fmt.Errorf("failed to execute transactions: %w", err) } s.logger.Error().Err(err). @@ -851,34 +891,66 @@ func (s *Syncer) executeTxsWithRetry(ctx context.Context, rawTxs [][]byte, heade case <-time.After(common.MaxRetriesTimeout): continue case <-ctx.Done(): - return nil, fmt.Errorf("context cancelled during retry: %w", ctx.Err()) + return coreexecutor.ExecuteResult{}, fmt.Errorf("context cancelled during retry: %w", ctx.Err()) } } - return newAppHash, nil + return result, nil } - return nil, nil + return coreexecutor.ExecuteResult{}, nil } -// ValidateBlock validates a synced block -// NOTE: if the header was gibberish and somehow passed all validation prior but the data was correct -// or if the data was gibberish and somehow passed all validation prior but the header was correct -// we are still losing both in the pending event. This should never happen. +// ValidateBlock validates a synced block. It runs header-only checks first +// (signature, proposer, sequence) and only then the pair checks between the +// header and the attached data. Failures are wrapped in BlockValidationError so +// callers can drop the right side of the pair from caches without discarding a +// potentially legitimate counterpart. func (s *Syncer) ValidateBlock(_ context.Context, currState types.State, data *types.Data, header *types.SignedHeader) error { // Set custom verifier for aggregator node signature header.SetCustomVerifierForSyncNode(s.options.SyncNodeSignatureBytesProvider) if err := header.ValidateBasicWithData(data); err != nil { //nolint:contextcheck // validation API does not accept context - return fmt.Errorf("invalid header: %w", err) + return classifyValidationError(fmt.Errorf("invalid header: %w", err)) + } + + if err := currState.AssertExpectedProposer(header); err != nil { + return errors.Join(errInvalidBlock, &BlockValidationError{Fault: FaultHeader, Err: err}) } if err := currState.AssertValidForNextState(header, data); err != nil { + if vErr := classifyValidationError(err); vErr != nil { + return errors.Join(errInvalidBlock, vErr) + } return errors.Join(errInvalidState, err) } return nil } +// classifyValidationError tags a known external validation error with the side +// at fault. It returns nil for errors that signal state divergence (the caller +// must then classify them as errInvalidState). +func classifyValidationError(err error) *BlockValidationError { + switch { + case errors.Is(err, types.ErrHeaderDataMismatch), + errors.Is(err, types.ErrDataHashMismatch): + return &BlockValidationError{Fault: FaultData, Err: err} + case errors.Is(err, types.ErrUnexpectedProposer), + errors.Is(err, types.ErrInvalidChainID), + errors.Is(err, types.ErrInvalidBlockHeight), + errors.Is(err, types.ErrInvalidBlockTime), + errors.Is(err, types.ErrSignerPubKeyMissing), + errors.Is(err, types.ErrSignerAddressMismatch), + errors.Is(err, types.ErrSignatureEmpty), + errors.Is(err, types.ErrSignatureVerificationFailed), + errors.Is(err, types.ErrProposerAddressMismatch), + errors.Is(err, types.ErrNoProposerAddress): + return &BlockValidationError{Fault: FaultHeader, Err: err} + default: + return nil + } +} + var errMaliciousProposer = errors.New("malicious proposer detected") // hashTx returns a hex-encoded SHA256 hash of the transaction. diff --git a/block/internal/syncing/syncer_test.go b/block/internal/syncing/syncer_test.go index 67c87e06ed..f347a4965b 100644 --- a/block/internal/syncing/syncer_test.go +++ b/block/internal/syncing/syncer_test.go @@ -146,7 +146,7 @@ func TestSyncer_validateBlock_DataHashMismatch(t *testing.T) { addr, pub, signer := buildSyncTestSigner(t) cfg := config.DefaultConfig() - gen := genesis.Genesis{ChainID: "tchain", InitialHeight: 1, StartTime: time.Now().Add(-time.Second), ProposerAddress: addr} + gen := genesis.Genesis{ChainID: "tchain", InitialHeight: 1, StartTime: time.Now().Add(-time.Second)} mockExec := testmocks.NewMockExecutor(t) mockExec.EXPECT().InitChain(mock.Anything, mock.Anything, uint64(1), "tchain").Return([]byte("app0"), nil).Once() @@ -191,6 +191,356 @@ func TestSyncer_validateBlock_DataHashMismatch(t *testing.T) { require.Error(t, err) } +func TestSyncer_ValidateBlock_UsesStateNextProposer(t *testing.T) { + addr, _, _ := buildSyncTestSigner(t) + badAddr, badPub, badSigner := buildSyncTestSigner(t) + + gen := genesis.Genesis{ChainID: "tchain", InitialHeight: 1, StartTime: time.Now().Add(-time.Second)} + data := makeData(gen.ChainID, 1, 1) + _, header := makeSignedHeaderBytes(t, gen.ChainID, 1, badAddr, badPub, badSigner, []byte("app0"), data, nil) + + s := &Syncer{logger: zerolog.Nop()} + state := types.State{ + ChainID: gen.ChainID, + InitialHeight: gen.InitialHeight, + LastBlockHeight: gen.InitialHeight - 1, + LastBlockTime: gen.StartTime, + AppHash: []byte("app0"), + NextProposerAddress: addr, + } + + err := s.ValidateBlock(t.Context(), state, data, header) + require.Error(t, err) + require.Contains(t, err.Error(), "unexpected proposer") +} + +func TestSyncer_TrySyncNextBlock_ClassifiesExternalValidationFailures(t *testing.T) { + expectedAddr, expectedPub, expectedSigner := buildSyncTestSigner(t) + wrongAddr, wrongPub, wrongSigner := buildSyncTestSigner(t) + + now := time.Now() + baseState := types.State{ + ChainID: "tchain", + InitialHeight: 1, + LastBlockHeight: 1, + LastBlockTime: now, + LastHeaderHash: []byte("last-header-hash"), + AppHash: []byte("app0"), + NextProposerAddress: expectedAddr, + } + + makeSyncer := func(tb testing.TB) *Syncer { + tb.Helper() + ds := dssync.MutexWrap(datastore.NewMapDatastore()) + st := store.New(ds) + cm, err := cache.NewManager(config.DefaultConfig(), st, zerolog.Nop()) + require.NoError(tb, err) + + return &Syncer{ + cache: cm, + logger: zerolog.Nop(), + options: common.DefaultBlockOptions(), + } + } + + makeEvent := func(tb testing.TB, chainID string, proposer []byte, pub crypto.PubKey, signer signerpkg.Signer, appHash []byte, data *types.Data) common.DAHeightEvent { + tb.Helper() + _, header := makeSignedHeaderBytes(tb, chainID, 2, proposer, pub, signer, appHash, data, baseState.LastHeaderHash) + return common.DAHeightEvent{ + Header: header, + Data: data, + Source: common.SourceDA, + } + } + + tests := map[string]struct { + event func(testing.TB) common.DAHeightEvent + wantState bool + wantInvalid bool + }{ + "wrong proposer with bad app hash is an invalid external block": { + event: func(tb testing.TB) common.DAHeightEvent { + data := makeData(baseState.ChainID, 2, 1) + data.Metadata.Time = uint64(now.Add(time.Second).UnixNano()) + return makeEvent(tb, baseState.ChainID, wrongAddr, wrongPub, wrongSigner, []byte("forged-app"), data) + }, + wantInvalid: true, + }, + "wrong chain ID is an invalid external block": { + event: func(tb testing.TB) common.DAHeightEvent { + data := makeData("other-chain", 2, 1) + data.Metadata.Time = uint64(now.Add(time.Second).UnixNano()) + return makeEvent(tb, "other-chain", expectedAddr, expectedPub, expectedSigner, baseState.AppHash, data) + }, + wantInvalid: true, + }, + "data hash mismatch is an invalid external block": { + event: func(tb testing.TB) common.DAHeightEvent { + headerData := makeData(baseState.ChainID, 2, 1) + headerData.Metadata.Time = uint64(now.Add(time.Second).UnixNano()) + event := makeEvent(tb, baseState.ChainID, expectedAddr, expectedPub, expectedSigner, baseState.AppHash, headerData) + event.Data = makeData(baseState.ChainID, 2, 2) + event.Data.Metadata.Time = headerData.Metadata.Time + return event + }, + wantInvalid: true, + }, + "header data metadata mismatch is an invalid external block": { + event: func(tb testing.TB) common.DAHeightEvent { + headerData := makeData(baseState.ChainID, 2, 1) + headerData.Metadata.Time = uint64(now.Add(time.Second).UnixNano()) + event := makeEvent(tb, baseState.ChainID, expectedAddr, expectedPub, expectedSigner, baseState.AppHash, headerData) + event.Data = &types.Data{ + Metadata: &types.Metadata{ + ChainID: baseState.ChainID, + Height: 3, + Time: headerData.Metadata.Time, + }, + Txs: headerData.Txs, + } + return event + }, + wantInvalid: true, + }, + "expected proposer with bad app hash is invalid state": { + event: func(tb testing.TB) common.DAHeightEvent { + data := makeData(baseState.ChainID, 2, 1) + data.Metadata.Time = uint64(now.Add(time.Second).UnixNano()) + return makeEvent(tb, baseState.ChainID, expectedAddr, expectedPub, expectedSigner, []byte("wrong-app"), data) + }, + wantState: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + event := tc.event(t) + err := makeSyncer(t).trySyncNextBlockWithState(t.Context(), &event, baseState) + require.Error(t, err) + assert.Equal(t, tc.wantInvalid, errors.Is(err, errInvalidBlock), "invalid block classification") + assert.Equal(t, tc.wantState, errors.Is(err, errInvalidState), "invalid state classification") + }) + } +} + +func TestSyncer_ValidateBlock_RejectsSignerAddressNotDerivedFromPubKey(t *testing.T) { + expectedAddr, _, _ := buildSyncTestSigner(t) + _, attackerPub, attackerSigner := buildSyncTestSigner(t) + + now := time.Now() + state := types.State{ + ChainID: "tchain", + InitialHeight: 1, + LastBlockHeight: 1, + LastBlockTime: now, + LastHeaderHash: []byte("last-header-hash"), + AppHash: []byte("app0"), + NextProposerAddress: expectedAddr, + } + + data := makeData(state.ChainID, 2, 1) + data.Metadata.Time = uint64(now.Add(time.Second).UnixNano()) + _, header := makeSignedHeaderBytes(t, state.ChainID, 2, expectedAddr, attackerPub, attackerSigner, state.AppHash, data, state.LastHeaderHash) + + s := &Syncer{ + logger: zerolog.Nop(), + options: common.DefaultBlockOptions(), + } + + err := s.ValidateBlock(t.Context(), state, data, header) + require.Error(t, err) + require.Contains(t, err.Error(), "signer address") +} + +func TestSyncer_ValidateBlock_ClassifiesFault(t *testing.T) { + expectedAddr, expectedPub, expectedSigner := buildSyncTestSigner(t) + wrongAddr, wrongPub, wrongSigner := buildSyncTestSigner(t) + + now := time.Now() + baseState := types.State{ + ChainID: "tchain", + InitialHeight: 1, + LastBlockHeight: 1, + LastBlockTime: now, + LastHeaderHash: []byte("last-header-hash"), + AppHash: []byte("app0"), + NextProposerAddress: expectedAddr, + } + + makeHeader := func(tb testing.TB, chainID string, proposer []byte, pub crypto.PubKey, signer signerpkg.Signer, appHash []byte, data *types.Data) *types.SignedHeader { + tb.Helper() + _, header := makeSignedHeaderBytes(tb, chainID, 2, proposer, pub, signer, appHash, data, baseState.LastHeaderHash) + return header + } + + tests := map[string]struct { + setup func(testing.TB) (*types.SignedHeader, *types.Data) + wantFault ValidationFault + }{ + "wrong proposer -> header fault": { + setup: func(tb testing.TB) (*types.SignedHeader, *types.Data) { + data := makeData(baseState.ChainID, 2, 1) + data.Metadata.Time = uint64(now.Add(time.Second).UnixNano()) + return makeHeader(tb, baseState.ChainID, wrongAddr, wrongPub, wrongSigner, baseState.AppHash, data), data + }, + wantFault: FaultHeader, + }, + "wrong chain id -> header fault": { + setup: func(tb testing.TB) (*types.SignedHeader, *types.Data) { + data := makeData("other-chain", 2, 1) + data.Metadata.Time = uint64(now.Add(time.Second).UnixNano()) + return makeHeader(tb, "other-chain", expectedAddr, expectedPub, expectedSigner, baseState.AppHash, data), data + }, + wantFault: FaultHeader, + }, + "data hash mismatch -> data fault": { + setup: func(tb testing.TB) (*types.SignedHeader, *types.Data) { + headerData := makeData(baseState.ChainID, 2, 1) + headerData.Metadata.Time = uint64(now.Add(time.Second).UnixNano()) + header := makeHeader(tb, baseState.ChainID, expectedAddr, expectedPub, expectedSigner, baseState.AppHash, headerData) + attached := makeData(baseState.ChainID, 2, 2) + attached.Metadata.Time = headerData.Metadata.Time + return header, attached + }, + wantFault: FaultData, + }, + "header data metadata mismatch -> data fault": { + setup: func(tb testing.TB) (*types.SignedHeader, *types.Data) { + headerData := makeData(baseState.ChainID, 2, 1) + headerData.Metadata.Time = uint64(now.Add(time.Second).UnixNano()) + header := makeHeader(tb, baseState.ChainID, expectedAddr, expectedPub, expectedSigner, baseState.AppHash, headerData) + attached := &types.Data{ + Metadata: &types.Metadata{ + ChainID: baseState.ChainID, + Height: 3, + Time: headerData.Metadata.Time, + }, + Txs: headerData.Txs, + } + return header, attached + }, + wantFault: FaultData, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + header, data := tc.setup(t) + s := &Syncer{logger: zerolog.Nop(), options: common.DefaultBlockOptions()} + err := s.ValidateBlock(t.Context(), baseState, data, header) + require.Error(t, err) + + var vErr *BlockValidationError + require.ErrorAs(t, err, &vErr, "expected *BlockValidationError") + assert.Equal(t, tc.wantFault, vErr.Fault, "fault classification") + }) + } +} + +func TestSyncer_TrySyncNextBlock_SelectiveCacheCleanup(t *testing.T) { + expectedAddr, expectedPub, expectedSigner := buildSyncTestSigner(t) + wrongAddr, wrongPub, wrongSigner := buildSyncTestSigner(t) + + now := time.Now() + baseState := types.State{ + ChainID: "tchain", + InitialHeight: 1, + LastBlockHeight: 1, + LastBlockTime: now, + LastHeaderHash: []byte("last-header-hash"), + AppHash: []byte("app0"), + NextProposerAddress: expectedAddr, + } + + makeSyncer := func(tb testing.TB) (*Syncer, cache.Manager) { + tb.Helper() + ds := dssync.MutexWrap(datastore.NewMapDatastore()) + st := store.New(ds) + cm, err := cache.NewManager(config.DefaultConfig(), st, zerolog.Nop()) + require.NoError(tb, err) + return &Syncer{cache: cm, logger: zerolog.Nop(), options: common.DefaultBlockOptions()}, cm + } + + tests := map[string]struct { + event func(testing.TB) common.DAHeightEvent + wantHeaderInCache bool + wantDataInCache bool + }{ + "header fault keeps data in cache": { + event: func(tb testing.TB) common.DAHeightEvent { + data := makeData(baseState.ChainID, 2, 1) + data.Metadata.Time = uint64(now.Add(time.Second).UnixNano()) + _, header := makeSignedHeaderBytes(tb, baseState.ChainID, 2, wrongAddr, wrongPub, wrongSigner, baseState.AppHash, data, baseState.LastHeaderHash) + return common.DAHeightEvent{Header: header, Data: data, Source: common.SourceDA} + }, + wantHeaderInCache: false, + wantDataInCache: true, + }, + "data fault keeps header in cache": { + event: func(tb testing.TB) common.DAHeightEvent { + headerData := makeData(baseState.ChainID, 2, 1) + headerData.Metadata.Time = uint64(now.Add(time.Second).UnixNano()) + _, header := makeSignedHeaderBytes(tb, baseState.ChainID, 2, expectedAddr, expectedPub, expectedSigner, baseState.AppHash, headerData, baseState.LastHeaderHash) + attached := makeData(baseState.ChainID, 2, 2) + attached.Metadata.Time = headerData.Metadata.Time + return common.DAHeightEvent{Header: header, Data: attached, Source: common.SourceDA} + }, + wantHeaderInCache: true, + wantDataInCache: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + s, cm := makeSyncer(t) + event := tc.event(t) + headerHash := event.Header.Hash().String() + dataHash := event.Data.DACommitment().String() + + // Seed the cache so we can observe what gets removed. + cm.SetHeaderDAIncluded(headerHash, 1, event.Header.Height()) + cm.SetDataDAIncluded(dataHash, 1, event.Header.Height()) + + err := s.trySyncNextBlockWithState(t.Context(), &event, baseState) + require.Error(t, err) + + _, headerStillIncluded := cm.GetHeaderDAIncludedByHash(headerHash) + _, dataStillIncluded := cm.GetDataDAIncludedByHash(dataHash) + assert.Equal(t, tc.wantHeaderInCache, headerStillIncluded, "header cache presence") + assert.Equal(t, tc.wantDataInCache, dataStillIncluded, "data cache presence") + }) + } +} + +func TestSyncer_ApplyBlockPersistsExecutionNextProposer(t *testing.T) { + addr, _, _ := buildSyncTestSigner(t) + execNext := []byte("execution-next-proposer") + + mockExec := testmocks.NewMockExecutor(t) + data := makeData("tchain", 1, 1) + header := types.Header{ + BaseHeader: types.BaseHeader{ChainID: "tchain", Height: 1, Time: uint64(time.Now().UnixNano())}, + ProposerAddress: addr, + } + currentState := types.State{AppHash: []byte("app0"), NextProposerAddress: addr} + + mockExec.EXPECT().ExecuteTxs(mock.Anything, mock.Anything, uint64(1), mock.Anything, currentState.AppHash). + Return(execution.ExecuteResult{ + UpdatedStateRoot: []byte("app1"), + NextProposerAddress: execNext, + }, nil).Once() + + s := &Syncer{ + exec: mockExec, + ctx: t.Context(), + logger: zerolog.Nop(), + } + + newState, err := s.ApplyBlock(t.Context(), header, data, currentState) + require.NoError(t, err) + require.Equal(t, execNext, newState.NextProposerAddress) +} + func TestProcessHeightEvent_SyncsAndUpdatesState(t *testing.T) { ds := dssync.MutexWrap(datastore.NewMapDatastore()) st := store.New(ds) @@ -235,15 +585,18 @@ func TestProcessHeightEvent_SyncsAndUpdatesState(t *testing.T) { lastState := s.getLastState() data := makeData(gen.ChainID, 1, 0) _, hdr := makeSignedHeaderBytes(t, gen.ChainID, 1, addr, pub, signer, lastState.AppHash, data, nil) + daRetriever := &daRetriever{pendingData: map[uint64]*types.Data{1: data}} + s.daRetriever = daRetriever // Expect ExecuteTxs call for height 1 mockExec.EXPECT().ExecuteTxs(mock.Anything, mock.Anything, uint64(1), mock.Anything, lastState.AppHash). Return([]byte("app1"), nil).Once() - evt := common.DAHeightEvent{Header: hdr, Data: data, DaHeight: 1} + evt := common.DAHeightEvent{Header: hdr, Data: data, Source: common.SourceDA, DaHeight: 1} s.processHeightEvent(t.Context(), &evt) requireEmptyChan(t, errChan) + require.NotContains(t, daRetriever.pendingData, uint64(1), "accepted DA data should be removed from the retriever pending data") h, err := st.Height(t.Context()) require.NoError(t, err) assert.Equal(t, uint64(1), h) @@ -252,6 +605,62 @@ func TestProcessHeightEvent_SyncsAndUpdatesState(t *testing.T) { assert.Equal(t, uint64(1), st1.LastBlockHeight) } +func TestProcessHeightEvent_UnexpectedProposerFromDAIsNotCriticalStateError(t *testing.T) { + ds := dssync.MutexWrap(datastore.NewMapDatastore()) + st := store.New(ds) + + cm, err := cache.NewManager(config.DefaultConfig(), st, zerolog.Nop()) + require.NoError(t, err) + + expectedAddr, _, _ := buildSyncTestSigner(t) + wrongAddr, wrongPub, wrongSigner := buildSyncTestSigner(t) + + cfg := config.DefaultConfig() + gen := genesis.Genesis{ChainID: "tchain", InitialHeight: 1, StartTime: time.Now().Add(-time.Second), ProposerAddress: expectedAddr} + + mockExec := testmocks.NewMockExecutor(t) + mockExec.EXPECT().InitChain(mock.Anything, mock.Anything, uint64(1), "tchain").Return([]byte("app0"), nil).Once() + + mockHeaderStore := extmocks.NewMockStore[*types.P2PSignedHeader](t) + mockHeaderStore.EXPECT().Height().Return(uint64(0)).Maybe() + mockDataStore := extmocks.NewMockStore[*types.P2PData](t) + mockDataStore.EXPECT().Height().Return(uint64(0)).Maybe() + + errChan := make(chan error, 1) + s := NewSyncer( + st, + mockExec, + nil, + cm, + common.NopMetrics(), + cfg, + gen, + mockHeaderStore, + mockDataStore, + zerolog.Nop(), + common.DefaultBlockOptions(), + errChan, + nil, + ) + + require.NoError(t, s.initializeState()) + s.ctx = t.Context() + + lastState := s.getLastState() + data := makeData(gen.ChainID, 1, 0) + _, hdr := makeSignedHeaderBytes(t, gen.ChainID, 1, wrongAddr, wrongPub, wrongSigner, lastState.AppHash, data, nil) + + evt := common.DAHeightEvent{Header: hdr, Data: data, Source: common.SourceDA, DaHeight: 1} + s.processHeightEvent(t.Context(), &evt) + + requireEmptyChan(t, errChan) + assert.False(t, s.hasCriticalError.Load(), "unexpected proposer from DA should be treated as an invalid external block") + + h, err := st.Height(t.Context()) + require.NoError(t, err) + assert.Equal(t, uint64(0), h) +} + func TestSequentialBlockSync(t *testing.T) { ds := dssync.MutexWrap(datastore.NewMapDatastore()) st := store.New(ds) @@ -772,6 +1181,7 @@ func TestSyncLoopPersistState(t *testing.T) { eventCh <- datypes.SubscriptionEvent{Height: myFutureDAHeight} syncerInst1.startSyncWorkers(ctx) syncerInst1.wg.Wait() + follower1.Stop() requireEmptyChan(t, errorCh) t.Log("sync workers on instance1 completed") @@ -936,7 +1346,7 @@ func TestSyncer_executeTxsWithRetry(t *testing.T) { if tt.expectSuccess { require.NoError(t, err) - assert.Equal(t, tt.expectHash, result) + assert.Equal(t, tt.expectHash, result.UpdatedStateRoot) } else { require.Error(t, err) if tt.expectError != "" { diff --git a/block/internal/syncing/validation_errors.go b/block/internal/syncing/validation_errors.go new file mode 100644 index 0000000000..e2b382d083 --- /dev/null +++ b/block/internal/syncing/validation_errors.go @@ -0,0 +1,43 @@ +package syncing + +import "fmt" + +// ValidationFault identifies which side of a (header, data) pair is responsible +// for a block validation failure. Callers use it to decide what to evict from +// caches so a legitimate counterpart is not discarded together with the bad one. +type ValidationFault int + +const ( + // FaultHeader marks the header as invalid on its own (signature, proposer, + // chain id, height, sequence). The data attached to the event may still be + // legitimate and pair with a different valid header. + FaultHeader ValidationFault = iota + + // FaultData marks the data as the suspect. It is used when the header + // passed all header-only checks but the data does not match the header + // (mismatched DataHash or metadata). + FaultData +) + +// BlockValidationError wraps the underlying validation error with a fault tag. +type BlockValidationError struct { + Fault ValidationFault + Err error +} + +func (e *BlockValidationError) Error() string { + return fmt.Sprintf("block validation failed (%s): %v", e.Fault, e.Err) +} + +func (e *BlockValidationError) Unwrap() error { return e.Err } + +func (f ValidationFault) String() string { + switch f { + case FaultHeader: + return "header" + case FaultData: + return "data" + default: + return "unknown" + } +} diff --git a/client/crates/types/src/proto/evnode.v1.messages.rs b/client/crates/types/src/proto/evnode.v1.messages.rs index c26ec4f2f1..2e3d53e0af 100644 --- a/client/crates/types/src/proto/evnode.v1.messages.rs +++ b/client/crates/types/src/proto/evnode.v1.messages.rs @@ -193,6 +193,8 @@ pub struct State { pub app_hash: ::prost::alloc::vec::Vec, #[prost(bytes = "vec", tag = "9")] pub last_header_hash: ::prost::alloc::vec::Vec, + #[prost(bytes = "vec", tag = "10")] + pub next_proposer_address: ::prost::alloc::vec::Vec, } /// RaftBlockState represents a replicated block state #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] @@ -343,6 +345,10 @@ pub struct ExecuteTxsResponse { /// Maximum allowed transaction size (may change with protocol updates) #[prost(uint64, tag = "2")] pub max_bytes: u64, + /// Proposer address that should sign the next block. + /// Empty means the current proposer remains active. + #[prost(bytes = "vec", tag = "3")] + pub next_proposer_address: ::prost::alloc::vec::Vec, } /// SetFinalRequest marks a block as finalized #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] @@ -360,12 +366,16 @@ pub struct SetFinalResponse {} #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct GetExecutionInfoRequest {} /// GetExecutionInfoResponse contains execution layer parameters -#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct GetExecutionInfoResponse { /// Maximum gas allowed for transactions in a block /// For non-gas-based execution layers, this should be 0 #[prost(uint64, tag = "1")] pub max_gas: u64, + /// Proposer address that should sign the next block from the execution + /// layer's current view. Empty means unchanged or unavailable. + #[prost(bytes = "vec", tag = "2")] + pub next_proposer_address: ::prost::alloc::vec::Vec, } /// FilterTxsRequest contains transactions to validate and filter #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] diff --git a/client/crates/types/src/proto/evnode.v1.services.rs b/client/crates/types/src/proto/evnode.v1.services.rs index c2bfa6f7c6..c6fc86dd4a 100644 --- a/client/crates/types/src/proto/evnode.v1.services.rs +++ b/client/crates/types/src/proto/evnode.v1.services.rs @@ -567,6 +567,8 @@ pub struct State { pub app_hash: ::prost::alloc::vec::Vec, #[prost(bytes = "vec", tag = "9")] pub last_header_hash: ::prost::alloc::vec::Vec, + #[prost(bytes = "vec", tag = "10")] + pub next_proposer_address: ::prost::alloc::vec::Vec, } /// RaftBlockState represents a replicated block state #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] @@ -1088,6 +1090,10 @@ pub struct ExecuteTxsResponse { /// Maximum allowed transaction size (may change with protocol updates) #[prost(uint64, tag = "2")] pub max_bytes: u64, + /// Proposer address that should sign the next block. + /// Empty means the current proposer remains active. + #[prost(bytes = "vec", tag = "3")] + pub next_proposer_address: ::prost::alloc::vec::Vec, } /// SetFinalRequest marks a block as finalized #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] @@ -1105,12 +1111,16 @@ pub struct SetFinalResponse {} #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct GetExecutionInfoRequest {} /// GetExecutionInfoResponse contains execution layer parameters -#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct GetExecutionInfoResponse { /// Maximum gas allowed for transactions in a block /// For non-gas-based execution layers, this should be 0 #[prost(uint64, tag = "1")] pub max_gas: u64, + /// Proposer address that should sign the next block from the execution + /// layer's current view. Empty means unchanged or unavailable. + #[prost(bytes = "vec", tag = "2")] + pub next_proposer_address: ::prost::alloc::vec::Vec, } /// FilterTxsRequest contains transactions to validate and filter #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] diff --git a/core/README.md b/core/README.md index 8f30a3a20f..0138cc004b 100644 --- a/core/README.md +++ b/core/README.md @@ -20,13 +20,20 @@ The `Executor` interface defines how the execution layer processes transactions // Executor defines the interface for the execution layer. type Executor interface { // InitChain initializes the chain based on the genesis information. - InitChain(ctx context.Context, genesisTime time.Time, initialHeight uint64, chainID string) (stateRoot []byte, maxBytes uint64, err error) + InitChain(ctx context.Context, genesisTime time.Time, initialHeight uint64, chainID string) (stateRoot []byte, err error) // GetTxs retrieves transactions from the mempool. GetTxs(ctx context.Context) ([][]byte, error) // ExecuteTxs executes a block of transactions against the current state. - ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) (updatedStateRoot []byte, maxBytes uint64, err error) + ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) (result ExecuteResult, err error) // SetFinal marks a block height as final. SetFinal(ctx context.Context, blockHeight uint64) error + // GetExecutionInfo returns execution parameters used by ev-node. + GetExecutionInfo(ctx context.Context) (ExecutionInfo, error) +} + +type ExecuteResult struct { + UpdatedStateRoot []byte + NextProposerAddress []byte } ``` diff --git a/core/execution/dummy.go b/core/execution/dummy.go index d6fb38959e..8953ded2a7 100644 --- a/core/execution/dummy.go +++ b/core/execution/dummy.go @@ -61,7 +61,7 @@ func (e *DummyExecutor) InjectTx(tx []byte) { } // ExecuteTxs simulate execution of transactions. -func (e *DummyExecutor) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) ([]byte, error) { +func (e *DummyExecutor) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) (ExecuteResult, error) { e.mu.Lock() defer e.mu.Unlock() @@ -73,7 +73,7 @@ func (e *DummyExecutor) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeigh pending := hash.Sum(nil) e.pendingRoots[blockHeight] = pending e.removeExecutedTxs(txs) - return pending, nil + return ExecuteResult{UpdatedStateRoot: pending}, nil } // SetFinal marks block at given height as finalized. diff --git a/core/execution/dummy_test.go b/core/execution/dummy_test.go index f6be3d400b..e77f1a39c6 100644 --- a/core/execution/dummy_test.go +++ b/core/execution/dummy_test.go @@ -131,13 +131,13 @@ func TestExecuteTxs(t *testing.T) { prevStateRoot := executor.GetStateRoot() txsToExecute := [][]byte{tx1, tx3} - newStateRoot, err := executor.ExecuteTxs(ctx, txsToExecute, blockHeight, timestamp, prevStateRoot) + result, err := executor.ExecuteTxs(ctx, txsToExecute, blockHeight, timestamp, prevStateRoot) if err != nil { t.Fatalf("ExecuteTxs returned error: %v", err) } - if bytes.Equal(newStateRoot, prevStateRoot) { + if bytes.Equal(result.UpdatedStateRoot, prevStateRoot) { t.Error("stateRoot should have changed after ExecuteTxs") } @@ -167,7 +167,7 @@ func TestSetFinal(t *testing.T) { prevStateRoot := executor.GetStateRoot() txs := [][]byte{[]byte("tx1"), []byte("tx2")} - pendingRoot, _ := executor.ExecuteTxs(ctx, txs, blockHeight, timestamp, prevStateRoot) + pendingResult, _ := executor.ExecuteTxs(ctx, txs, blockHeight, timestamp, prevStateRoot) // Set the block as final err := executor.SetFinal(ctx, blockHeight) @@ -177,8 +177,8 @@ func TestSetFinal(t *testing.T) { // Verify that the state root was updated newStateRoot := executor.GetStateRoot() - if !bytes.Equal(newStateRoot, pendingRoot) { - t.Errorf("Expected state root to be updated to pending root %v, got %v", pendingRoot, newStateRoot) + if !bytes.Equal(newStateRoot, pendingResult.UpdatedStateRoot) { + t.Errorf("Expected state root to be updated to pending root %v, got %v", pendingResult.UpdatedStateRoot, newStateRoot) } // Verify that the pending root was removed @@ -398,7 +398,7 @@ func TestExecuteTxsWithInvalidPrevStateRoot(t *testing.T) { timestamp := time.Now() txs := [][]byte{[]byte("tx1"), []byte("tx2")} - newStateRoot, err := executor.ExecuteTxs(ctx, txs, blockHeight, timestamp, invalidPrevStateRoot) + result, err := executor.ExecuteTxs(ctx, txs, blockHeight, timestamp, invalidPrevStateRoot) // The dummy executor doesn't validate the previous state root, so it should still work // This is a characteristic of the dummy implementation @@ -406,7 +406,7 @@ func TestExecuteTxsWithInvalidPrevStateRoot(t *testing.T) { t.Fatalf("ExecuteTxs with invalid prevStateRoot returned error: %v", err) } - if len(newStateRoot) == 0 { + if len(result.UpdatedStateRoot) == 0 { t.Error("Expected non-empty state root even with invalid prevStateRoot") } diff --git a/core/execution/execution.go b/core/execution/execution.go index 78ecf374f8..8c79ca1922 100644 --- a/core/execution/execution.go +++ b/core/execution/execution.go @@ -63,9 +63,9 @@ type Executor interface { // - prevStateRoot: Previous block's state root hash // // Returns: - // - updatedStateRoot: New state root after executing transactions + // - result: New execution result after executing transactions // - err: Any execution errors - ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) (updatedStateRoot []byte, err error) + ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) (result ExecuteResult, err error) // SetFinal marks a block as finalized at the specified height. // Requirements: @@ -132,6 +132,27 @@ type ExecutionInfo struct { // MaxGas is the maximum gas allowed for transactions in a block. // For non-gas-based execution layers, this should be 0. MaxGas uint64 + + // NextProposerAddress is the execution layer's best-effort view of the + // proposer address for the next block. It is advisory and is consulted + // only at startup/replay seeding when no prior consensus state is + // available; the authoritative source for the next proposer is + // ExecuteResult.NextProposerAddress. Empty means unchanged/unavailable, + // and callers must fall back to the genesis proposer. + NextProposerAddress []byte +} + +// ExecuteResult contains execution output that consensus must persist. +type ExecuteResult = struct { + // UpdatedStateRoot is the new state root after executing transactions. + UpdatedStateRoot []byte + + // NextProposerAddress is the authoritative proposer address selected by + // the execution layer to sign block blockHeight+1 (the block immediately + // after the one just executed). An empty value means the current proposer + // remains active; execution layers that do not support proposer rotation + // MUST leave this field empty. + NextProposerAddress []byte } // HeightProvider is an optional interface that execution clients can implement diff --git a/docs/adr/adr-023-execution-owned-proposer-rotation.md b/docs/adr/adr-023-execution-owned-proposer-rotation.md new file mode 100644 index 0000000000..9f78ef27e8 --- /dev/null +++ b/docs/adr/adr-023-execution-owned-proposer-rotation.md @@ -0,0 +1,84 @@ +# ADR 023: Execution-Owned Proposer Rotation + +## Changelog + +- 2026-04-24: Initial ADR. + +## Status + +Proposed + +## Context + +ev-node originally selected the block proposer from genesis. That made proposer changes a consensus configuration concern and pushed key rotation into a static schedule. This is too rigid for EVM rollups and other execution environments where proposer selection should be governed by execution state. + +The replacement design moves proposer selection into the execution environment. ev-node remains responsible for signing, propagating, validating, and persisting blocks, but it consumes proposer updates returned by execution. + +## Decision + +`Executor.ExecuteTxs` returns an execution result containing: + +- `UpdatedStateRoot`: the state root after executing the block. +- `NextProposerAddress`: the address expected to sign the next block. + +`GetExecutionInfo` also exposes `NextProposerAddress` for startup. If execution returns an empty proposer at startup, ev-node falls back to `genesis.proposer_address`. + +An empty `NextProposerAddress` from `ExecuteTxs` means the proposer is unchanged. ev-node must not write a redundant header field in that case, preserving compatibility with existing headers and hash chains. + +When execution returns a non-empty next proposer: + +- `State.NextProposerAddress` is updated and used as the expected signer for `LastBlockHeight + 1`. +- Full nodes validate the next block signer against the previous state's `NextProposerAddress`. +- Header encoding remains unchanged. `Header.ProposerAddress` continues to identify the signer of the current block only. + +The execution result is the authority for proposer rotation. Header-only paths cannot derive proposer transitions without either replaying execution or using a future proof/certificate mechanism. This preserves header compatibility while keeping the rotation rule deterministic for full nodes. + +## EVM System Contract Model + +For ev-reth, proposer selection should be implemented as execution state, likely through a system contract. The contract stores the active next proposer address and exposes controlled update methods. + +The controlling address can be a multisig or security council. This keeps operational key rotation in execution state instead of requiring a new genesis file or node-side schedule. A future ev-reth implementation should read the contract during block execution and return the selected proposer through `ExecuteTxsResponse.next_proposer_address`. + +This ADR does not define the system contract ABI. The contract should be specified with ev-reth because access control, call routing, and predeploy/system-contract conventions are execution-environment details. + +## Security Considerations + +The security council or multisig becomes the authority for proposer updates. It must use a threshold and operational process appropriate for production signer rotation. + +The system contract must restrict writes to the configured authority. Unauthorized proposer updates are consensus-critical because they determine who can sign the next block. + +ev-node validates each block's signer against the proposer address stored in the previous state. A malicious proposer cannot rotate the next signer through node-local configuration; the rotation must be derived from execution. + +If the execution interface returns an empty proposer, ev-node treats the proposer as unchanged. At startup, empty execution info falls back to genesis so existing execution implementations remain usable. + +Compromise of the security council can still rotate the proposer to an attacker. This ADR reduces node configuration risk; it does not eliminate governance-key risk. + +## Consequences + +Positive: + +- Proposer rotation becomes deterministic execution state. +- EVM chains can use a system contract and multisig-controlled rotation. +- Existing chains keep working when execution returns an empty proposer. +- Existing header encoding remains compatible because no new header field is required. + +Negative: + +- The execution API changes and all execution adapters must return `ExecuteResult`. +- Proposer updates become consensus-critical execution outputs. +- ev-reth needs a separate system-contract design and implementation. +- Header-only/light-client paths cannot follow proposer rotation without execution replay or a later proof design. + +## Alternatives Considered + +Genesis proposer schedule: + +- Rejected. It makes rotation a static node/genesis concern and is not a good fit for security-council or multisig-controlled EVM deployments. + +Node-local proposer configuration: + +- Rejected. Nodes could disagree about the active proposer unless every operator updates configuration at the same time. + +Header commitment for next proposer: + +- Rejected for the first version. It would expose rotations to header-only paths, but it changes the signed header and hash encoding. Keeping rotation in execution/state avoids a header compatibility break. diff --git a/docs/concepts/block-lifecycle.md b/docs/concepts/block-lifecycle.md index 91e835dea8..4d76e04ece 100644 --- a/docs/concepts/block-lifecycle.md +++ b/docs/concepts/block-lifecycle.md @@ -682,13 +682,13 @@ See [tutorial] for running a multi-node network with both aggregator and non-agg [5] [Tutorial][tutorial] -[6] [Header and Data Separation ADR](../../adr/adr-014-header-and-data-separation.md) +[6] [Header and Data Separation ADR](../adr/adr-014-header-and-data-separation.md) -[7] [Evolve Minimal Header](../../adr/adr-015-rollkit-minimal-header.md) +[7] [Evolve Minimal Header](../adr/adr-015-rollkit-minimal-header.md) [8] [Data Availability](./data-availability.md) -[9] [Lazy Aggregation with DA Layer Consistency ADR](../../adr/adr-021-lazy-aggregation.md) +[9] [Lazy Aggregation with DA Layer Consistency ADR](../adr/adr-021-lazy-aggregation.md) [defaultBlockTime]: https://github.com/evstack/ev-node/blob/main/pkg/config/defaults.go#L50 [defaultDABlockTime]: https://github.com/evstack/ev-node/blob/main/pkg/config/defaults.go#L59 diff --git a/docs/getting-started/custom/implement-executor.md b/docs/getting-started/custom/implement-executor.md index 7a1d51886f..6e6bd11c14 100644 --- a/docs/getting-started/custom/implement-executor.md +++ b/docs/getting-started/custom/implement-executor.md @@ -6,10 +6,11 @@ The Executor interface is the boundary between ev-node and your execution layer. ```go type Executor interface { - InitChain(ctx context.Context, genesis Genesis) ([]byte, error) + InitChain(ctx context.Context, genesisTime time.Time, initialHeight uint64, chainID string) ([]byte, error) GetTxs(ctx context.Context) ([][]byte, error) - ExecuteTxs(ctx context.Context, txs [][]byte, height uint64, timestamp time.Time) (*ExecutionResult, error) + ExecuteTxs(ctx context.Context, txs [][]byte, height uint64, timestamp time.Time, prevStateRoot []byte) (execution.ExecuteResult, error) SetFinal(ctx context.Context, height uint64) error + GetExecutionInfo(ctx context.Context) (execution.ExecutionInfo, error) } ``` @@ -95,7 +96,8 @@ func (e *MyExecutor) ExecuteTxs( txs [][]byte, height uint64, timestamp time.Time, -) (*ExecutionResult, error) + prevStateRoot []byte, +) (execution.ExecuteResult, error) ``` **Parameters:** @@ -103,17 +105,17 @@ func (e *MyExecutor) ExecuteTxs( - `txs` — Ordered transactions to execute - `height` — Block height - `timestamp` — Block timestamp +- `prevStateRoot` — Previous block's state root **Returns:** -- `ExecutionResult` containing new state root and gas used +- `execution.ExecuteResult` containing the new state root and optional next proposer address - Error only for system failures (not tx failures) **Responsibilities:** - Execute each transaction in order - Update state -- Track gas usage - Handle transaction failures gracefully - Return new state root @@ -125,30 +127,27 @@ func (e *MyExecutor) ExecuteTxs( txs [][]byte, height uint64, timestamp time.Time, -) (*ExecutionResult, error) { - var totalGas uint64 - + prevStateRoot []byte, +) (execution.ExecuteResult, error) { for _, txBytes := range txs { tx, err := DecodeTx(txBytes) if err != nil { continue // Skip invalid tx } - gas, err := e.executeTx(tx) - if err != nil { + if err := e.executeTx(tx); err != nil { // Log but continue - tx failure != block failure continue } - - totalGas += gas } // Commit state changes stateRoot := e.db.Commit() - return &ExecutionResult{ - StateRoot: stateRoot, - GasUsed: totalGas, + return execution.ExecuteResult{ + UpdatedStateRoot: stateRoot, + // Empty keeps the current proposer. + NextProposerAddress: nil, }, nil } ``` @@ -210,9 +209,9 @@ func TestExecuteTxs(t *testing.T) { require.NoError(t, err) // Execute - result, err := exec.ExecuteTxs(ctx, txs, 1, time.Now()) + result, err := exec.ExecuteTxs(ctx, txs, 1, time.Now(), initialStateRoot) require.NoError(t, err) - require.NotEmpty(t, result.StateRoot) + require.NotEmpty(t, result.UpdatedStateRoot) } ``` diff --git a/docs/guides/advanced/based-sequencing.md b/docs/guides/advanced/based-sequencing.md index bf1f235fa2..e476896811 100644 --- a/docs/guides/advanced/based-sequencing.md +++ b/docs/guides/advanced/based-sequencing.md @@ -72,5 +72,5 @@ Based sequencing minimizes trust assumptions: ## Further Reading -- [Data Availability](../data-availability.md) - Understanding the DA layer -- [Transaction Flow](../transaction-flow.md) - How transactions move through the system +- [Data Availability](../../concepts/data-availability.md) - Understanding the DA layer +- [Transaction Flow](../../concepts/transaction-flow.md) - How transactions move through the system diff --git a/docs/guides/advanced/forced-inclusion.md b/docs/guides/advanced/forced-inclusion.md index b7c5199aa6..b6e89a0359 100644 --- a/docs/guides/advanced/forced-inclusion.md +++ b/docs/guides/advanced/forced-inclusion.md @@ -14,7 +14,7 @@ Forced inclusion is a censorship-resistance mechanism that allows users to submi - **With lazy mode:** the sequencer produces a block once either - enough transactions are collected - the lazy-mode block interval elapses - More info in the [lazy mode configuration guide](../config.md#lazy-mode-lazy-aggregator). + More info in the [lazy mode configuration guide](../../learn/config.md#lazy-mode-lazy-aggregator). - Each block contains a batch of ordered transactions and metadata. 4. **Data Availability Posting:** diff --git a/docs/guides/ha/overview.md b/docs/guides/ha/overview.md index a3f04d8850..bc6e61c4fe 100644 --- a/docs/guides/ha/overview.md +++ b/docs/guides/ha/overview.md @@ -85,7 +85,7 @@ raft: enable: true ``` -**CLI:** `--evnode.raft.enable` +**CLI:** `--evnode.raft.enable` **Default:** `false` Enables Raft consensus. Must be `true` on every cluster member. When disabled (the default), the node runs as a traditional single sequencer. Setting this to `true` also requires `node.aggregator: true`. @@ -99,7 +99,7 @@ raft: node_id: "node-1" ``` -**CLI:** `--evnode.raft.node_id` +**CLI:** `--evnode.raft.node_id` **Default:** _(none, required)_ A string that uniquely identifies this node within the cluster. Every node must have a different `node_id`. The ID is stored in the Raft log and used by other nodes to route messages — **never change it after the cluster is bootstrapped**, as doing so will break the cluster membership records. @@ -115,7 +115,7 @@ raft: raft_addr: "0.0.0.0:5001" ``` -**CLI:** `--evnode.raft.raft_addr` +**CLI:** `--evnode.raft.raft_addr` **Default:** _(none, required)_ The TCP address this node listens on for Raft transport messages from other cluster members. The `0.0.0.0` bind address accepts connections on all interfaces; bind to a specific private IP if you want to restrict which interface is used for cluster traffic. @@ -133,7 +133,7 @@ raft: raft_dir: "/var/lib/ev-node/raft" ``` -**CLI:** `--evnode.raft.raft_dir` +**CLI:** `--evnode.raft.raft_dir` **Default:** `/raft` The directory where Raft stores its persistent state: log database, stable store, and snapshots. This directory **must be on persistent storage** (not tmpfs, not ephemeral container storage). Losing this directory means the node loses its cluster identity — it cannot rejoin without being reconfigured as a new member. @@ -149,7 +149,7 @@ raft: peers: "node-2@10.0.0.2:5001,node-3@10.0.0.3:5001,node-4@10.0.0.4:5001,node-5@10.0.0.5:5001" ``` -**CLI:** `--evnode.raft.peers` +**CLI:** `--evnode.raft.peers` **Default:** _(none, required)_ A comma-separated list of the **other** cluster members (exclude the local node), in the format `nodeID@host:port`. The host and port must be the Raft address (`raft_addr`) of each peer as reachable from this node. Do not list the node's own `node_id` in its own `peers` field. @@ -170,7 +170,7 @@ raft: bootstrap: false ``` -**CLI:** `--evnode.raft.bootstrap` +**CLI:** `--evnode.raft.bootstrap` **Default:** `false` Compatibility flag retained for older deployments. **You do not need to set this.** ev-node auto-detects the correct startup mode from the state of `raft_dir`: @@ -202,7 +202,7 @@ raft: heartbeat_timeout: "92ms" ``` -**CLI:** `--evnode.raft.heartbeat_timeout` +**CLI:** `--evnode.raft.heartbeat_timeout` **Default:** `350ms` The maximum time a follower will wait without receiving a heartbeat from the leader before starting a new election. The leader sends heartbeats more frequently than this value internally; this parameter is purely a follower-side timeout that triggers a new election when crossed. @@ -228,7 +228,7 @@ raft: election_timeout: "368ms" ``` -**CLI:** `--evnode.raft.election_timeout` +**CLI:** `--evnode.raft.election_timeout` **Default:** `1000ms` How long a follower waits without receiving a heartbeat before it concludes the leader is dead and starts a new election. Must be greater than or equal to `heartbeat_timeout`. @@ -246,7 +246,7 @@ raft: leader_lease_timeout: "46ms" ``` -**CLI:** `--evnode.raft.leader_lease_timeout` +**CLI:** `--evnode.raft.leader_lease_timeout` **Default:** `175ms` The duration for which a leader considers its leadership valid after the last successful heartbeat acknowledgment. Leader lease enables local reads from the leader without a round-trip to quorum. @@ -262,7 +262,7 @@ raft: send_timeout: "50ms" ``` -**CLI:** `--evnode.raft.send_timeout` +**CLI:** `--evnode.raft.send_timeout` **Default:** `200ms` The maximum time the leader waits for a single message (log entry, heartbeat) to be delivered to a peer before marking the delivery as failed. A failed send is retried, but repeated failures trigger follower health tracking. @@ -282,7 +282,7 @@ raft: snapshot_threshold: 5000 ``` -**CLI:** `--evnode.raft.snapshot_threshold` +**CLI:** `--evnode.raft.snapshot_threshold` **Default:** `500` The number of committed log entries that must accumulate before Raft automatically takes a snapshot of the FSM state. After a snapshot, log entries older than the snapshot are compacted away. @@ -303,7 +303,7 @@ raft: trailing_logs: 18000 ``` -**CLI:** `--evnode.raft.trailing_logs` +**CLI:** `--evnode.raft.trailing_logs` **Default:** `200` The number of log entries to **retain after a snapshot** is taken. These entries act as a catch-up buffer: a node that missed fewer than `trailing_logs` entries since the last snapshot can replay from the log without needing to transfer the full snapshot. @@ -324,7 +324,7 @@ raft: snap_count: 3 ``` -**CLI:** `--evnode.raft.snap_count` +**CLI:** `--evnode.raft.snap_count` **Default:** `3` The number of snapshot files to retain on disk. Older snapshots are deleted when new ones are created. Keeping 2–3 snapshots provides a rollback option in case the latest snapshot is corrupt. diff --git a/docs/guides/operations/monitoring.md b/docs/guides/operations/monitoring.md index 6e47357703..0093b4371a 100644 --- a/docs/guides/operations/monitoring.md +++ b/docs/guides/operations/monitoring.md @@ -12,7 +12,7 @@ Metrics will be served under `/metrics` on port 26660 by default. The listening ## List of available metrics -You can find the full list of available metrics in the [Technical Specifications](../learn/specs/block-manager.md#metrics). +You can find the full list of available metrics in the [Technical Specifications](../../learn/specs/block-manager.md#metrics). ## Viewing Metrics diff --git a/docs/index.md b/docs/index.md index 04b3aff631..dbc7e4ee98 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,6 +4,8 @@ title: Evolve Documentation titleTemplate: ':title' --- + + diff --git a/docs/reference/configuration/ev-node-config.md b/docs/reference/configuration/ev-node-config.md index 56eaadef72..425b00c901 100644 --- a/docs/reference/configuration/ev-node-config.md +++ b/docs/reference/configuration/ev-node-config.md @@ -730,7 +730,7 @@ _Example:_ `--evnode.rpc.enable_da_visualization` _Default:_ `false` _Constant:_ `FlagRPCEnableDAVisualization` -See the [DA Visualizer Guide](../guides/da/visualizer.md) for detailed information on using this feature. +See the [DA Visualizer Guide](../../guides/da/visualizer.md) for detailed information on using this feature. ### Health Endpoints @@ -797,7 +797,7 @@ _Constant:_ `FlagPrometheus` **Description:** The network address (host:port) where the Prometheus metrics server will listen for scraping requests. -See [Metrics](../guides/metrics.md) for more details on what metrics are exposed. +See [Metrics](../../guides/metrics.md) for more details on what metrics are exposed. **YAML:** diff --git a/docs/reference/interfaces/executor.md b/docs/reference/interfaces/executor.md index 5cb0e9f8d8..31b425474d 100644 --- a/docs/reference/interfaces/executor.md +++ b/docs/reference/interfaces/executor.md @@ -8,7 +8,7 @@ The Executor interface defines how ev-node communicates with execution layers. I type Executor interface { InitChain(ctx context.Context, genesisTime time.Time, initialHeight uint64, chainID string) (stateRoot []byte, err error) GetTxs(ctx context.Context) ([][]byte, error) - ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) (updatedStateRoot []byte, err error) + ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) (result ExecuteResult, err error) SetFinal(ctx context.Context, blockHeight uint64) error GetExecutionInfo(ctx context.Context) (ExecutionInfo, error) FilterTxs(ctx context.Context, txs [][]byte, maxBytes, maxGas uint64, hasForceIncludedTransaction bool) ([]FilterStatus, error) @@ -64,7 +64,7 @@ GetTxs(ctx context.Context) ([][]byte, error) Processes transactions to produce a new block state. ```go -ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) (updatedStateRoot []byte, err error) +ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) (result ExecuteResult, err error) ``` **Parameters:** @@ -76,7 +76,15 @@ ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time **Returns:** -- `updatedStateRoot` - New state root after execution +- `result.UpdatedStateRoot` - New state root after execution +- `result.NextProposerAddress` - Address expected to sign the next block. Empty means the proposer is unchanged. + +```go +type ExecuteResult struct { + UpdatedStateRoot []byte + NextProposerAddress []byte +} +``` **Requirements:** @@ -115,10 +123,14 @@ GetExecutionInfo(ctx context.Context) (ExecutionInfo, error) ```go type ExecutionInfo struct { - MaxGas uint64 // Maximum gas per block (0 = no gas-based limiting) + MaxGas uint64 + NextProposerAddress []byte } ``` +- `MaxGas` - Maximum gas per block (0 = no gas-based limiting) +- `NextProposerAddress` - Execution layer's current next proposer. Empty at startup means ev-node falls back to `genesis.proposer_address`. + ### FilterTxs Validates and filters transactions for block inclusion. diff --git a/execution/evm/eth_rpc_client.go b/execution/evm/eth_rpc_client.go index d083a3d594..3f6eaa2c65 100644 --- a/execution/evm/eth_rpc_client.go +++ b/execution/evm/eth_rpc_client.go @@ -4,6 +4,8 @@ import ( "context" "math/big" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" ) @@ -30,3 +32,19 @@ func (e *ethRPCClient) GetTxs(ctx context.Context) ([]string, error) { } return result, nil } + +func (e *ethRPCClient) GetNextProposer(ctx context.Context, number *big.Int) (common.Hash, error) { + var result common.Hash + err := e.client.Client().CallContext(ctx, &result, "evolve_getNextProposer", blockNumberArg(number)) + if err != nil { + return common.Hash{}, err + } + return result, nil +} + +func blockNumberArg(number *big.Int) string { + if number == nil { + return "latest" + } + return hexutil.EncodeBig(number) +} diff --git a/execution/evm/eth_rpc_tracing.go b/execution/evm/eth_rpc_tracing.go index 1d908eddb1..483ee9625d 100644 --- a/execution/evm/eth_rpc_tracing.go +++ b/execution/evm/eth_rpc_tracing.go @@ -4,6 +4,7 @@ import ( "context" "math/big" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" @@ -82,3 +83,29 @@ func (t *tracedEthRPCClient) GetTxs(ctx context.Context) ([]string, error) { return result, nil } + +func (t *tracedEthRPCClient) GetNextProposer(ctx context.Context, number *big.Int) (common.Hash, error) { + blockNumber := "latest" + if number != nil { + blockNumber = number.String() + } + + ctx, span := t.tracer.Start(ctx, "Evolve.GetNextProposer", + trace.WithAttributes( + attribute.String("method", "evolve_getNextProposer"), + attribute.String("block_number", blockNumber), + ), + ) + defer span.End() + + result, err := t.inner.GetNextProposer(ctx, number) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return common.Hash{}, err + } + + span.SetAttributes(attribute.String("next_proposer", result.Hex())) + + return result, nil +} diff --git a/execution/evm/eth_rpc_tracing_test.go b/execution/evm/eth_rpc_tracing_test.go index 4d33e899bf..d6e367992c 100644 --- a/execution/evm/eth_rpc_tracing_test.go +++ b/execution/evm/eth_rpc_tracing_test.go @@ -6,6 +6,7 @@ import ( "math/big" "testing" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" @@ -40,8 +41,9 @@ func setupTestEthRPCTracing(t *testing.T, mockClient EthRPCClient) (EthRPCClient // mockEthRPCClient is a simple mock for testing type mockEthRPCClient struct { - headerByNumberFn func(ctx context.Context, number *big.Int) (*types.Header, error) - getTxsFn func(ctx context.Context) ([]string, error) + headerByNumberFn func(ctx context.Context, number *big.Int) (*types.Header, error) + getTxsFn func(ctx context.Context) ([]string, error) + getNextProposerFn func(ctx context.Context, number *big.Int) (common.Hash, error) } func (m *mockEthRPCClient) HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) { @@ -58,6 +60,13 @@ func (m *mockEthRPCClient) GetTxs(ctx context.Context) ([]string, error) { return nil, nil } +func (m *mockEthRPCClient) GetNextProposer(ctx context.Context, number *big.Int) (common.Hash, error) { + if m.getNextProposerFn != nil { + return m.getNextProposerFn(ctx, number) + } + return common.Hash{}, nil +} + func TestTracedEthRPCClient_HeaderByNumber_Success(t *testing.T) { expectedHeader := &types.Header{ GasLimit: 30000000, diff --git a/execution/evm/execution.go b/execution/evm/execution.go index 3067893360..fff49d294d 100644 --- a/execution/evm/execution.go +++ b/execution/evm/execution.go @@ -160,6 +160,9 @@ type EthRPCClient interface { // GetTxs retrieves pending transactions from the transaction pool. GetTxs(ctx context.Context) ([]string, error) + + // GetNextProposer retrieves the proposer selected by the execution layer. + GetNextProposer(ctx context.Context, number *big.Int) (common.Hash, error) } // EngineClient represents a client that interacts with an Ethereum execution engine @@ -344,7 +347,7 @@ func (c *EngineClient) GetTxs(ctx context.Context) ([][]byte, error) { // - Checks for already-promoted blocks to enable idempotent execution // - Saves ExecMeta with payloadID after forkchoiceUpdatedV3 for crash recovery // - Updates ExecMeta to "promoted" after successful execution -func (c *EngineClient) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) (updatedStateRoot []byte, err error) { +func (c *EngineClient) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) (execution.ExecuteResult, error) { // 1. Check for idempotent execution stateRoot, payloadID, found, idempotencyErr := c.reconcileExecutionAtHeight(ctx, blockHeight, timestamp, txs) @@ -353,22 +356,26 @@ func (c *EngineClient) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight // Continue execution on error, as it might be transient } else if found { if stateRoot != nil { - return stateRoot, nil + return c.executionResultAfterBlock(ctx, stateRoot, blockHeight) } if payloadID != nil { // Found in-progress execution, attempt to resume - return c.processPayload(ctx, *payloadID, txs) + stateRoot, err := c.processPayload(ctx, *payloadID, txs) + if err != nil { + return execution.ExecuteResult{}, err + } + return c.executionResultAfterBlock(ctx, stateRoot, blockHeight) } } prevBlockHash, prevHeaderStateRoot, prevGasLimit, _, err := c.getBlockInfo(ctx, blockHeight-1) if err != nil { - return nil, fmt.Errorf("failed to get block info: %w", err) + return execution.ExecuteResult{}, fmt.Errorf("failed to get block info: %w", err) } // It's possible that the prev state root passed in is nil if this is the first block. // If so, we can't do a comparison. Otherwise, we compare the roots. if len(prevStateRoot) > 0 && !bytes.Equal(prevStateRoot, prevHeaderStateRoot.Bytes()) { - return nil, fmt.Errorf("prevStateRoot mismatch at height %d: consensus=%x execution=%x", blockHeight-1, prevStateRoot, prevHeaderStateRoot.Bytes()) + return execution.ExecuteResult{}, fmt.Errorf("prevStateRoot mismatch at height %d: consensus=%x execution=%x", blockHeight-1, prevStateRoot, prevHeaderStateRoot.Bytes()) } // 2. Prepare payload attributes @@ -445,7 +452,7 @@ func (c *EngineClient) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight return nil }, MaxPayloadStatusRetries, InitialRetryBackoff, "ExecuteTxs forkchoice") if err != nil { - return nil, err + return execution.ExecuteResult{}, err } // Save ExecMeta with payloadID for crash recovery (Stage="started") @@ -453,7 +460,11 @@ func (c *EngineClient) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight c.saveExecMeta(ctx, blockHeight, timestamp.Unix(), newPayloadID[:], nil, nil, txs, ExecStageStarted) // 4. Process the payload (get, submit, finalize) - return c.processPayload(ctx, *newPayloadID, txs) + stateRoot, err = c.processPayload(ctx, *newPayloadID, txs) + if err != nil { + return execution.ExecuteResult{}, err + } + return c.executionResultAfterBlock(ctx, stateRoot, blockHeight) } // setHead updates the head block hash without changing safe or finalized. @@ -850,21 +861,81 @@ func (c *EngineClient) filterTransactions(txs [][]byte) []string { // GetExecutionInfo returns current execution layer parameters. func (c *EngineClient) GetExecutionInfo(ctx context.Context) (execution.ExecutionInfo, error) { + var info execution.ExecutionInfo if cached := c.cachedExecutionInfo.Load(); cached != nil { - return *cached, nil + info.MaxGas = cached.MaxGas + } else { + header, err := c.ethClient.HeaderByNumber(ctx, nil) // nil = latest + if err != nil { + return execution.ExecutionInfo{}, fmt.Errorf("failed to get latest block: %w", err) + } + + info.MaxGas = header.GasLimit + c.cachedExecutionInfo.Store(&execution.ExecutionInfo{MaxGas: info.MaxGas}) } - header, err := c.ethClient.HeaderByNumber(ctx, nil) // nil = latest + nextProposer, err := c.ethClient.GetNextProposer(ctx, nil) if err != nil { - return execution.ExecutionInfo{}, fmt.Errorf("failed to get latest block: %w", err) + if !isRPCMethodNotFound(err) { + return execution.ExecutionInfo{}, fmt.Errorf("failed to get next proposer: %w", err) + } + return info, nil + } + if nextProposer != (common.Hash{}) { + info.NextProposerAddress = nextProposer.Bytes() } - - info := execution.ExecutionInfo{MaxGas: header.GasLimit} - c.cachedExecutionInfo.Store(&info) return info, nil } +func isRPCMethodNotFound(err error) bool { + var rpcErr rpc.Error + return errors.As(err, &rpcErr) && rpcErr.ErrorCode() == -32601 +} + +func (c *EngineClient) executionResultAfterBlock(ctx context.Context, stateRoot []byte, executedBlockHeight uint64) (execution.ExecuteResult, error) { + nextProposer, err := c.nextProposerChangeAfterBlock(ctx, executedBlockHeight) + if err != nil { + return execution.ExecuteResult{}, err + } + return execution.ExecuteResult{ + UpdatedStateRoot: stateRoot, + NextProposerAddress: nextProposer, + }, nil +} + +func (c *EngineClient) nextProposerChangeAfterBlock(ctx context.Context, executedBlockHeight uint64) ([]byte, error) { + if executedBlockHeight == 0 { + return nil, nil + } + + before, supported, err := c.nextProposerAtBlock(ctx, executedBlockHeight-1) + if err != nil || !supported { + return nil, err + } + + after, supported, err := c.nextProposerAtBlock(ctx, executedBlockHeight) + if err != nil || !supported { + return nil, err + } + + if after == (common.Hash{}) || after == before { + return nil, nil + } + return after.Bytes(), nil +} + +func (c *EngineClient) nextProposerAtBlock(ctx context.Context, blockHeight uint64) (common.Hash, bool, error) { + proposer, err := c.ethClient.GetNextProposer(ctx, new(big.Int).SetUint64(blockHeight)) + if err != nil { + if isRPCMethodNotFound(err) { + return common.Hash{}, false, nil + } + return common.Hash{}, true, fmt.Errorf("failed to get next proposer at block %d: %w", blockHeight, err) + } + return proposer, true, nil +} + // FilterTxs validates force-included transactions and applies gas and size filtering for all passed txs. // If hasForceIncludedTransaction is false, skip filtering entirely - mempool batch is already filtered. // Returns a slice of FilterStatus for each transaction. diff --git a/execution/evm/execution_reconcile_test.go b/execution/evm/execution_reconcile_test.go index 7d1243f81b..7b4dea78fd 100644 --- a/execution/evm/execution_reconcile_test.go +++ b/execution/evm/execution_reconcile_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/ethereum/go-ethereum/beacon/engine" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" ds "github.com/ipfs/go-datastore" dssync "github.com/ipfs/go-datastore/sync" @@ -123,3 +124,7 @@ func (mockReconcileEthRPCClient) HeaderByNumber(_ context.Context, _ *big.Int) ( func (mockReconcileEthRPCClient) GetTxs(_ context.Context) ([]string, error) { return nil, errors.New("unexpected GetTxs call") } + +func (mockReconcileEthRPCClient) GetNextProposer(_ context.Context, _ *big.Int) (common.Hash, error) { + return common.Hash{}, errors.New("unexpected GetNextProposer call") +} diff --git a/execution/evm/go.mod b/execution/evm/go.mod index cb2cff27b0..95e4a8f114 100644 --- a/execution/evm/go.mod +++ b/execution/evm/go.mod @@ -2,6 +2,11 @@ module github.com/evstack/ev-node/execution/evm go 1.25.8 +replace ( + github.com/evstack/ev-node => ../../ + github.com/evstack/ev-node/core => ../../core +) + require ( github.com/ethereum/go-ethereum v1.17.3 github.com/evstack/ev-node v1.1.1 @@ -46,17 +51,17 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/holiman/uint256 v1.3.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/ipfs/go-cid v0.6.0 // indirect + github.com/ipfs/go-cid v0.6.1 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/minio/sha256-simd v1.0.1 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/mr-tron/base58 v1.2.0 // indirect + github.com/mr-tron/base58 v1.3.0 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect github.com/multiformats/go-multiaddr v0.16.1 // indirect - github.com/multiformats/go-multibase v0.2.0 // indirect + github.com/multiformats/go-multibase v0.3.0 // indirect github.com/multiformats/go-multihash v0.2.3 // indirect github.com/multiformats/go-varint v0.1.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect @@ -83,13 +88,13 @@ require ( go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.52.0 // indirect - golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect google.golang.org/grpc v1.81.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/execution/evm/go.sum b/execution/evm/go.sum index b2145f25f6..d32e1a8518 100644 --- a/execution/evm/go.sum +++ b/execution/evm/go.sum @@ -1,15 +1,15 @@ cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= -cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U= cloud.google.com/go/iam v1.7.0/go.mod h1:tetWZW1PD/m6vcuY2Zj/aU0eCHNPuxedbnbRTyKXvdY= -cloud.google.com/go/kms v1.29.0 h1:bAW1C5FQf+6GhPkywQzPlsULALCG7c16qpXLFGV9ivY= -cloud.google.com/go/kms v1.29.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= +cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE= +cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8grmqY= cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E= filippo.io/bigmod v0.1.1-0.20260103110540-f8a47775ebe5 h1:JA0fFr+kxpqTdxR9LOBiTWpGNchqmkcsgmdeJZRclZ0= @@ -26,36 +26,36 @@ github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDO github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= -github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg= -github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ= -github.com/aws/aws-sdk-go-v2/config v1.32.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM= -github.com/aws/aws-sdk-go-v2/config v1.32.16/go.mod h1:duCCnJEFqpt2RC6no1iK6q+8HpwOAkiUua0pY507dQc= -github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4= -github.com/aws/aws-sdk-go-v2/credentials v1.19.15/go.mod h1:gJiYyMOjNg8OEdRWOf3CrFQxM2a98qmrtjx1zuiQfB8= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 h1:IOGsJ1xVWhsi+ZO7/NW8OuZZBtMJLZbk4P5HDjJO0jQ= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22/go.mod h1:b+hYdbU+jGKfXE8kKM6g1+h+L/Go3vMvzlxBsiuGsxg= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo= -github.com/aws/aws-sdk-go-v2/service/kms v1.50.5 h1:nEzwx/ZlpUZ2Y6WztsgYmfBh5Ixd3QiECawXMzvTMeo= -github.com/aws/aws-sdk-go-v2/service/kms v1.50.5/go.mod h1:GBO/aaEi47QldDVoqw2CsM2UZQDoqDiFIMJD/ztHPs0= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.10/go.mod h1:p6+MXNxW7IA6dMgHfTAzljuwSKD0NCm/4lbS4t6+7vI= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.16/go.mod h1:CudnEVKRtLn0+3uMV0yEXZ+YZOKnAtUJ5DmDhilVnIw= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 h1:oK/njaL8GtyEihkWMD4k3VgHCT64RQKkZwh0DG5j8ak= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20/go.mod h1:JHs8/y1f3zY7U5WcuzoJ/yAYGYtNIVPKLIbp61euvmg= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo= -github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U= -github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= +github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= +github.com/aws/aws-sdk-go-v2/config v1.32.18 h1:Hcia46bxhGgF3BaSnG8nSNCWmqTK6bj9xN9/FJ3WK6Q= +github.com/aws/aws-sdk-go-v2/config v1.32.18/go.mod h1:zEjCAYmxqDadH1WX8CdBvmLKhUEUVFgKRQG38zjDmrY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.17 h1:gP2nkGsS+KMvF/jfFz2Vv2qiiOqWKyPACSzPsqHgoW8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.17/go.mod h1:Bsew3S/moG5iT77giPj1q8wb/s0RE5/QfH+ASjYtuQc= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= +github.com/aws/aws-sdk-go-v2/service/kms v1.52.0 h1:QNtg+Mtj1zmepk568+UKBD5DFfqh+ESTUUqQT27JkQc= +github.com/aws/aws-sdk-go-v2/service/kms v1.52.0/go.mod h1:Y0+uxvxz6ib4KktRdK0V4X45Vcs/JyYoz8H71pO8xeI= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 h1:nDARhv/oF55bcxF7rCI/4PDxOKnVXVWwDuDwCs2I2SQ= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= +github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= +github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -121,10 +121,6 @@ github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJ github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= github.com/ethereum/go-ethereum v1.17.3 h1:Ev/sQHH+UdKZHWjuVzhu2pxhi/sXaPZl23Q+Q5LDd4Q= github.com/ethereum/go-ethereum v1.17.3/go.mod h1:f2EhRwqewIZkGoQekywI2Y2RZAMTSavLNkD9qItFy1A= -github.com/evstack/ev-node v1.1.1 h1:J9h5PKx177XdvNWLZCDOkWJEGRIrPzYxkCFhbGkVUm8= -github.com/evstack/ev-node v1.1.1/go.mod h1:/d/i+SSTDFnxffoijcrwmlt0LgfUU8D4S3HQqucwtu8= -github.com/evstack/ev-node/core v1.0.0 h1:s0Tx0uWHme7SJn/ZNEtee4qNM8UO6PIxXnHhPbbKTz8= -github.com/evstack/ev-node/core v1.0.0/go.mod h1:n2w/LhYQTPsi48m6lMj16YiIqsaQw6gxwjyJvR+B3sY= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= @@ -196,10 +192,10 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= -github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI= -github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= +github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= +github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac= @@ -226,20 +222,20 @@ github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFck github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/ipfs/boxo v0.37.0 h1:2E3mZvydMI2t5IkAgtkmZ3sGsld0oS7o3I+xyzDk6uI= -github.com/ipfs/boxo v0.37.0/go.mod h1:8yyiRn54F2CsW13n0zwXEPrVsZix/gFj9SYIRYMZ6KE= -github.com/ipfs/go-cid v0.6.0 h1:DlOReBV1xhHBhhfy/gBNNTSyfOM6rLiIx9J7A4DGf30= -github.com/ipfs/go-cid v0.6.0/go.mod h1:NC4kS1LZjzfhK40UGmpXv5/qD2kcMzACYJNntCUiDhQ= +github.com/ipfs/boxo v0.39.0 h1:u9jLf5pLx5SWROXjHtj8VMvv+iDlMbiTyZ/vVTQ4VhI= +github.com/ipfs/boxo v0.39.0/go.mod h1:k9YCvMjytFguMHndEiGdCGMMj4b7CkdOT44vtgAxOdk= +github.com/ipfs/go-cid v0.6.1 h1:T5TnNb08+ueovG76Z5gx1L4Y7QOaGTXHg1F6raWFxIc= +github.com/ipfs/go-cid v0.6.1/go.mod h1:zrY0SwOhjrrIdfPQ/kf+k1sXyJ0QE7cMxfCployLBs0= github.com/ipfs/go-datastore v0.9.1 h1:67Po2epre/o0UxrmkzdS9ZTe2GFGODgTd2odx8Wh6Yo= github.com/ipfs/go-datastore v0.9.1/go.mod h1:zi07Nvrpq1bQwSkEnx3bfjz+SQZbdbWyCNvyxMh9pN0= github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk= github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps= github.com/ipfs/go-ds-badger4 v0.1.8 h1:frNczf5CjCVm62RJ5mW5tD/oLQY/9IKAUpKviRV9QAI= github.com/ipfs/go-ds-badger4 v0.1.8/go.mod h1:FdqSLA5TMsyqooENB/Hf4xzYE/iH0z/ErLD6ogtfMrA= -github.com/ipfs/go-log/v2 v2.9.1 h1:3JXwHWU31dsCpvQ+7asz6/QsFJHqFr4gLgQ0FWteujk= -github.com/ipfs/go-log/v2 v2.9.1/go.mod h1:evFx7sBiohUN3AG12mXlZBw5hacBQld3ZPHrowlJYoo= -github.com/ipld/go-ipld-prime v0.22.0 h1:YJhDhjEOvOYaqshd3b4atIWUoRg/rKrgmwCyUHwlbuY= -github.com/ipld/go-ipld-prime v0.22.0/go.mod h1:ol7vKxOOVgEh0iAPuiDalM+0gScXVMA5ZZa4DVrTnEA= +github.com/ipfs/go-log/v2 v2.9.2 h1:O/5BB0elpkRILvT24rCJ5976wWd7u0nJ436T3rdYdc4= +github.com/ipfs/go-log/v2 v2.9.2/go.mod h1:RziRwwXWhndlk8L75RnEe0zeAYaq2heKtEMc3jqUov0= +github.com/ipld/go-ipld-prime v0.23.0 h1:csqdPZH60BsTC+AZrv7fpa27v+09I/oTqyHYYYE27eE= +github.com/ipld/go-ipld-prime v0.23.0/go.mod h1:46YCFSFNFBJHPjB0pfMuv7Ly7df2eChpkpyPo5SE0bA= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/jbenet/go-temp-err-catcher v0.1.0 h1:zpb3ZH6wIE8Shj2sKS+khgRvf7T7RABoLk/+KKHggpk= @@ -268,12 +264,12 @@ github.com/libp2p/go-libp2p v0.48.0 h1:h2BrLAgrj7X8bEN05K7qmrjpNHYA+6tnsGRdprjTn github.com/libp2p/go-libp2p v0.48.0/go.mod h1:Q1fBZNdmC2Hf82husCTfkKJVfHm2we5zk+NWmOGEmWk= github.com/libp2p/go-libp2p-asn-util v0.4.1 h1:xqL7++IKD9TBFMgnLPZR6/6iYhawHKHl950SO9L6n94= github.com/libp2p/go-libp2p-asn-util v0.4.1/go.mod h1:d/NI6XZ9qxw67b4e+NgpQexCIiFYJjErASrYW4PFDN8= -github.com/libp2p/go-libp2p-kad-dht v0.39.1 h1:9RzUBc7zywT4ZNamRSgEvPZmVlK3Y6xdlCYfXXvlR/Q= -github.com/libp2p/go-libp2p-kad-dht v0.39.1/go.mod h1:Po2JugFEkDq9Vig/JXtc153ntOi0q58o4j7IuITCOVs= +github.com/libp2p/go-libp2p-kad-dht v0.40.0 h1:as8U7Y1RX9CTKCBiFBHWKZ6tSS+rE+6WNz+H1+M+wbo= +github.com/libp2p/go-libp2p-kad-dht v0.40.0/go.mod h1:iLUjII47u3/HjxyhucI2lhsl29lrzlAs/ym16+H40jE= github.com/libp2p/go-libp2p-kbucket v0.8.0 h1:QAK7RzKJpYe+EuSEATAaaHYMYLkPDGC18m9jxPLnU8s= github.com/libp2p/go-libp2p-kbucket v0.8.0/go.mod h1:JMlxqcEyKwO6ox716eyC0hmiduSWZZl6JY93mGaaqc4= -github.com/libp2p/go-libp2p-pubsub v0.15.0 h1:cG7Cng2BT82WttmPFMi50gDNV+58K626m/wR00vGL1o= -github.com/libp2p/go-libp2p-pubsub v0.15.0/go.mod h1:lr4oE8bFgQaifRcoc2uWhWWiK6tPdOEKpUuR408GFN4= +github.com/libp2p/go-libp2p-pubsub v0.16.0 h1:j7G2C8kJwkcAQqYR7Wmq3d75d3Sgw/N0Hhiv0dVx7OY= +github.com/libp2p/go-libp2p-pubsub v0.16.0/go.mod h1:lr4oE8bFgQaifRcoc2uWhWWiK6tPdOEKpUuR408GFN4= github.com/libp2p/go-libp2p-record v0.3.1 h1:cly48Xi5GjNw5Wq+7gmjfBiG9HCzQVkiZOUZ8kUl+Fg= github.com/libp2p/go-libp2p-record v0.3.1/go.mod h1:T8itUkLcWQLCYMqtX7Th6r7SexyUJpIyPgks757td/E= github.com/libp2p/go-libp2p-routing-helpers v0.7.5 h1:HdwZj9NKovMx0vqq6YNPTh6aaNzey5zHD7HeLJtq6fI= @@ -290,8 +286,8 @@ github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd h1:br0buuQ854V8 github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd/go.mod h1:QuCEs1Nt24+FYQEqAAncTDPJIuGs+LxK1MCiFL25pMU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b h1:z78hV3sbSMAUoyUMM0I83AUIT6Hu17AWfgjzIbtrYFc= github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b/go.mod h1:lxPUiZwKoFL8DUUmalo2yJJUCxbPKtm8OKfqr2/FTNU= github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc h1:PTfri+PuQmWDqERdnNMiD9ZejrlswWrCpBEZgWOiTrc= @@ -302,8 +298,8 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A= github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= -github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= -github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI= +github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8= github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= @@ -314,8 +310,8 @@ github.com/multiformats/go-multiaddr-dns v0.5.0 h1:p/FTyHKX0nl59f+S+dEUe8HRK+i5O github.com/multiformats/go-multiaddr-dns v0.5.0/go.mod h1:yJ349b8TPIAANUyuOzn1oz9o22tV9f+06L+cCeMxC14= github.com/multiformats/go-multiaddr-fmt v0.1.0 h1:WLEFClPycPkp4fnIzoFoV9FVd49/eQsuaL3/CWe167E= github.com/multiformats/go-multiaddr-fmt v0.1.0/go.mod h1:hGtDIW4PU4BqJ50gW2quDuPVjyWNZxToGUh/HwTZYJo= -github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= -github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= +github.com/multiformats/go-multibase v0.3.0 h1:8helZD2+4Db7NNWFiktk2NePbF0boolBe6bDQvM4r68= +github.com/multiformats/go-multibase v0.3.0/go.mod h1:MoBLQPCkRTOL3eveIPO81860j2AQY8JwcnNlRkGRUfI= github.com/multiformats/go-multicodec v0.10.0 h1:UpP223cig/Cx8J76jWt91njpK3GTAO1w02sdcjZDSuc= github.com/multiformats/go-multicodec v0.10.0/go.mod h1:wg88pM+s2kZJEQfRCKBNU+g32F5aWBEjyFHXvZLTcLI= github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= @@ -461,10 +457,10 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= @@ -491,8 +487,8 @@ go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= -go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= @@ -502,8 +498,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= @@ -542,13 +538,12 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc= -golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -571,14 +566,14 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA= -google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= +google.golang.org/api v0.280.0 h1:F4OfEHZhZh6a7uTufJAXXVd/2TQ8EjM4vZH+jX/vFYk= +google.golang.org/api v0.280.0/go.mod h1:oGKmPZRDoD3vdkf6MA7F4VNkR1rxCiuaPSkhsf3EolU= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= diff --git a/execution/evm/proposer_test.go b/execution/evm/proposer_test.go new file mode 100644 index 0000000000..6d510a70b6 --- /dev/null +++ b/execution/evm/proposer_test.go @@ -0,0 +1,132 @@ +package evm + +import ( + "context" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/beacon/engine" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + ds "github.com/ipfs/go-datastore" + dssync "github.com/ipfs/go-datastore/sync" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +type proposerEthRPCClient struct { + headerByNumberFn func(ctx context.Context, number *big.Int) (*types.Header, error) + getTxsFn func(ctx context.Context) ([]string, error) + getNextProposerFn func(ctx context.Context, number *big.Int) (common.Hash, error) + nextProposerBlocks []*big.Int +} + +func (m *proposerEthRPCClient) HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) { + if m.headerByNumberFn != nil { + return m.headerByNumberFn(ctx, number) + } + return &types.Header{GasLimit: 30_000_000}, nil +} + +func (m *proposerEthRPCClient) GetTxs(ctx context.Context) ([]string, error) { + if m.getTxsFn != nil { + return m.getTxsFn(ctx) + } + return nil, nil +} + +func (m *proposerEthRPCClient) GetNextProposer(ctx context.Context, number *big.Int) (common.Hash, error) { + m.nextProposerBlocks = append(m.nextProposerBlocks, number) + if m.getNextProposerFn != nil { + return m.getNextProposerFn(ctx, number) + } + return common.Hash{}, nil +} + +func TestGetExecutionInfoIncludesNextProposer(t *testing.T) { + nextProposer := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + ethClient := &proposerEthRPCClient{ + getNextProposerFn: func(ctx context.Context, number *big.Int) (common.Hash, error) { + require.Nil(t, number) + return nextProposer, nil + }, + } + client := &EngineClient{ethClient: ethClient} + + info, err := client.GetExecutionInfo(t.Context()) + + require.NoError(t, err) + require.Equal(t, uint64(30_000_000), info.MaxGas) + require.Equal(t, nextProposer.Bytes(), info.NextProposerAddress) + require.Len(t, ethClient.nextProposerBlocks, 1) +} + +func TestExecuteTxsReturnsNextProposerWhenChanged(t *testing.T) { + timestamp := time.Unix(1_700_000_000, 0) + stateRoot := common.HexToHash("0x01") + prevProposer := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + nextProposer := common.HexToHash("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + + store := NewEVMStore(dssync.MutexWrap(ds.NewMapDatastore())) + require.NoError(t, store.SaveExecMeta(t.Context(), &ExecMeta{ + Height: 2, + StateRoot: stateRoot.Bytes(), + Timestamp: timestamp.Unix(), + Stage: ExecStagePromoted, + })) + + ethClient := &proposerEthRPCClient{ + headerByNumberFn: func(ctx context.Context, number *big.Int) (*types.Header, error) { + require.Equal(t, int64(2), number.Int64()) + return &types.Header{ + Number: big.NewInt(2), + Time: uint64(timestamp.Unix()), + Root: stateRoot, + GasLimit: 30_000_000, + }, nil + }, + getNextProposerFn: func(ctx context.Context, number *big.Int) (common.Hash, error) { + switch number.Uint64() { + case 1: + return prevProposer, nil + case 2: + return nextProposer, nil + default: + t.Fatalf("unexpected proposer block %v", number) + return common.Hash{}, nil + } + }, + } + client := &EngineClient{ + engineClient: proposerEngineRPCClient{}, + ethClient: ethClient, + store: store, + currentSafeBlockHash: common.HexToHash("0x10"), + currentFinalizedBlockHash: common.HexToHash("0x10"), + logger: zerolog.Nop(), + } + + result, err := client.ExecuteTxs(t.Context(), nil, 2, timestamp, nil) + + require.NoError(t, err) + require.Equal(t, stateRoot.Bytes(), result.UpdatedStateRoot) + require.Equal(t, nextProposer.Bytes(), result.NextProposerAddress) + require.Len(t, ethClient.nextProposerBlocks, 2) +} + +type proposerEngineRPCClient struct{} + +func (proposerEngineRPCClient) ForkchoiceUpdated(context.Context, engine.ForkchoiceStateV1, map[string]any) (*engine.ForkChoiceResponse, error) { + return &engine.ForkChoiceResponse{ + PayloadStatus: engine.PayloadStatusV1{Status: engine.VALID}, + }, nil +} + +func (proposerEngineRPCClient) GetPayload(context.Context, engine.PayloadID) (*engine.ExecutionPayloadEnvelope, error) { + return nil, nil +} + +func (proposerEngineRPCClient) NewPayload(context.Context, *engine.ExecutableData, []string, string, [][]byte) (*engine.PayloadStatusV1, error) { + return nil, nil +} diff --git a/execution/evm/test/execution_test.go b/execution/evm/test/execution_test.go index 4b49136f41..e556a718b4 100644 --- a/execution/evm/test/execution_test.go +++ b/execution/evm/test/execution_test.go @@ -129,8 +129,9 @@ func TestEngineExecution(t *testing.T) { allTimestamps = append(allTimestamps, blockTimestamp) // Execute transactions and get the new state root - newStateRoot, err := executionClient.ExecuteTxs(ctx, payload, blockHeight, blockTimestamp, prevStateRoot) + executeResult, err := executionClient.ExecuteTxs(ctx, payload, blockHeight, blockTimestamp, prevStateRoot) require.NoError(tt, err) + newStateRoot := executeResult.UpdatedStateRoot err = executionClient.SetFinal(ctx, blockHeight) require.NoError(tt, err) @@ -201,8 +202,9 @@ func TestEngineExecution(t *testing.T) { // Use timestamp from build phase for each block to ensure proper ordering blockTimestamp := allTimestamps[blockHeight-1] - newStateRoot, err := executionClient.ExecuteTxs(ctx, payload, blockHeight, blockTimestamp, prevStateRoot) + executeResult, err := executionClient.ExecuteTxs(ctx, payload, blockHeight, blockTimestamp, prevStateRoot) require.NoError(t, err) + newStateRoot := executeResult.UpdatedStateRoot if len(payload) == 0 { require.Equal(tt, prevStateRoot, newStateRoot) } else { diff --git a/execution/evm/test/go.mod b/execution/evm/test/go.mod index 22fe43f56a..15e1d90f8f 100644 --- a/execution/evm/test/go.mod +++ b/execution/evm/test/go.mod @@ -208,5 +208,6 @@ require ( replace ( github.com/evstack/ev-node => ../../../ + github.com/evstack/ev-node/core => ../../../core github.com/evstack/ev-node/execution/evm => ../ ) diff --git a/execution/evm/test/go.sum b/execution/evm/test/go.sum index 19e4c173a2..554c3a824c 100644 --- a/execution/evm/test/go.sum +++ b/execution/evm/test/go.sum @@ -253,8 +253,6 @@ github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJ github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= github.com/ethereum/go-ethereum v1.17.3 h1:Ev/sQHH+UdKZHWjuVzhu2pxhi/sXaPZl23Q+Q5LDd4Q= github.com/ethereum/go-ethereum v1.17.3/go.mod h1:f2EhRwqewIZkGoQekywI2Y2RZAMTSavLNkD9qItFy1A= -github.com/evstack/ev-node/core v1.0.0 h1:s0Tx0uWHme7SJn/ZNEtee4qNM8UO6PIxXnHhPbbKTz8= -github.com/evstack/ev-node/core v1.0.0/go.mod h1:n2w/LhYQTPsi48m6lMj16YiIqsaQw6gxwjyJvR+B3sY= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= diff --git a/execution/evm/test/proposer_rotation_test.go b/execution/evm/test/proposer_rotation_test.go new file mode 100644 index 0000000000..e2e376e281 --- /dev/null +++ b/execution/evm/test/proposer_rotation_test.go @@ -0,0 +1,310 @@ +//go:build evm + +package test + +import ( + "context" + "encoding/json" + "errors" + "math/big" + "os" + "strings" + "testing" + "time" + + tastoradocker "github.com/celestiaorg/tastora/framework/docker" + "github.com/celestiaorg/tastora/framework/docker/container" + "github.com/celestiaorg/tastora/framework/docker/evstack/reth" + "github.com/celestiaorg/tastora/framework/types" + "github.com/ethereum/go-ethereum/common" + ethTypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + ds "github.com/ipfs/go-datastore" + dssync "github.com/ipfs/go-datastore/sync" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" + + "github.com/evstack/ev-node/execution/evm" +) + +const proposerControlPrecompile = "0x000000000000000000000000000000000000F101" + +func TestEngineExecutionReturnsNextProposerFromEvolveRPC(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + admin := privateKeyAddress(t, TEST_PRIVATE_KEY) + initialProposer := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + nextProposer := common.HexToHash("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + genesis := proposerControlGenesis(t, admin, initialProposer) + + dockerClient, dockerNetworkID := tastoradocker.Setup(t) + rethNode := SetupTestRethNode(t, dockerClient, dockerNetworkID, + withProposerControlImage(t), + func(b *reth.NodeBuilder) { + b.WithGenesis(genesis) + }, + ) + + ni, err := rethNode.GetNetworkInfo(ctx) + require.NoError(t, err) + ethURL := "http://127.0.0.1:" + ni.External.Ports.RPC + engineURL := "http://127.0.0.1:" + ni.External.Ports.Engine + genesisHash, err := rethNode.GenesisHash(ctx) + require.NoError(t, err) + requireRawNextProposer(t, ctx, ethURL, initialProposer) + + executionClient, err := evm.NewEngineExecutionClient( + ethURL, + engineURL, + rethNode.JWTSecretHex(), + common.HexToHash(genesisHash), + common.Address{}, + dssync.MutexWrap(ds.NewMapDatastore()), + false, + zerolog.Nop(), + ) + require.NoError(t, err) + + info, err := executionClient.GetExecutionInfo(ctx) + requireProposerControlSupported(t, err) + require.Equal(t, initialProposer.Bytes(), info.NextProposerAddress) + + genesisStateRoot, err := executionClient.InitChain(ctx, time.Now().UTC().Truncate(time.Second), 1, CHAIN_ID) + require.NoError(t, err) + + tx := setNextProposerTx(t, TEST_PRIVATE_KEY, nextProposer) + ethClient := createEthClient(t, ethURL) + defer ethClient.Close() + require.NoError(t, ethClient.SendTransaction(ctx, tx)) + + payload, err := executionClient.GetTxs(ctx) + require.NoError(t, err) + require.Len(t, payload, 1) + + result, err := executionClient.ExecuteTxs(ctx, payload, 1, time.Now().UTC().Truncate(time.Second).Add(time.Second), genesisStateRoot) + require.NoError(t, err) + require.Equal(t, nextProposer.Bytes(), result.NextProposerAddress) + + info, err = executionClient.GetExecutionInfo(ctx) + require.NoError(t, err) + require.Equal(t, nextProposer.Bytes(), info.NextProposerAddress) +} + +func TestTwoEngineNodesObserveRepeatedProposerRotation(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + admin := privateKeyAddress(t, TEST_PRIVATE_KEY) + proposerA := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + proposerB := common.HexToHash("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + genesis := proposerControlGenesis(t, admin, proposerA) + + dockerClient, dockerNetworkID := tastoradocker.Setup(t) + nodeA := setupProposerControlNode(t, dockerClient, dockerNetworkID, genesis) + nodeB := setupProposerControlNode(t, dockerClient, dockerNetworkID, genesis) + + clientA, ethURLA := newEngineClientForRethNode(t, ctx, nodeA) + clientB, ethURLB := newEngineClientForRethNode(t, ctx, nodeB) + + requireRawNextProposer(t, ctx, ethURLA, proposerA) + requireRawNextProposer(t, ctx, ethURLB, proposerA) + + genesisTime := time.Now().UTC().Truncate(time.Second) + prevStateRootA, err := clientA.InitChain(ctx, genesisTime, 1, CHAIN_ID) + require.NoError(t, err) + prevStateRootB, err := clientB.InitChain(ctx, genesisTime, 1, CHAIN_ID) + require.NoError(t, err) + require.Equal(t, prevStateRootA, prevStateRootB) + + type executionNode struct { + client *evm.EngineClient + ethURL string + } + nodes := map[common.Hash]executionNode{ + proposerA: {client: clientA, ethURL: ethURLA}, + proposerB: {client: clientB, ethURL: ethURLB}, + } + + currentProposer := proposerA + rotations := []common.Hash{proposerB, proposerA, proposerB, proposerA} + baseTimestamp := genesisTime.Add(time.Second) + for i, nextProposer := range rotations { + blockHeight := uint64(i + 1) + proposerNode := nodes[currentProposer] + + tx := setNextProposerTxWithNonce(t, TEST_PRIVATE_KEY, uint64(i), nextProposer) + ethClient := createEthClient(t, proposerNode.ethURL) + require.NoError(t, ethClient.SendTransaction(ctx, tx)) + ethClient.Close() + + payload, err := proposerNode.client.GetTxs(ctx) + require.NoError(t, err) + require.Len(t, payload, 1) + + blockTimestamp := baseTimestamp.Add(time.Duration(i) * time.Second) + resultA, err := clientA.ExecuteTxs(ctx, payload, blockHeight, blockTimestamp, prevStateRootA) + require.NoError(t, err) + resultB, err := clientB.ExecuteTxs(ctx, payload, blockHeight, blockTimestamp, prevStateRootB) + require.NoError(t, err) + + require.Equal(t, resultA.UpdatedStateRoot, resultB.UpdatedStateRoot) + require.Equal(t, nextProposer.Bytes(), resultA.NextProposerAddress) + require.Equal(t, nextProposer.Bytes(), resultB.NextProposerAddress) + + require.NoError(t, clientA.SetFinal(ctx, blockHeight)) + require.NoError(t, clientB.SetFinal(ctx, blockHeight)) + requireRawNextProposer(t, ctx, ethURLA, nextProposer) + requireRawNextProposer(t, ctx, ethURLB, nextProposer) + + prevStateRootA = resultA.UpdatedStateRoot + prevStateRootB = resultB.UpdatedStateRoot + currentProposer = nextProposer + } +} + +func proposerControlGenesis(t *testing.T, admin common.Address, initialNextProposer common.Hash) []byte { + t.Helper() + + var genesis map[string]any + require.NoError(t, json.Unmarshal([]byte(reth.DefaultEvolveGenesisJSON()), &genesis)) + config, ok := genesis["config"].(map[string]any) + require.True(t, ok) + evolve, ok := config["evolve"].(map[string]any) + if !ok { + evolve = make(map[string]any) + config["evolve"] = evolve + } + evolve["proposerControlAdmin"] = admin.Hex() + evolve["proposerControlActivationHeight"] = float64(0) + evolve["initialNextProposer"] = initialNextProposer.Hex() + + bz, err := json.MarshalIndent(genesis, "", " ") + require.NoError(t, err) + return bz +} + +func setupProposerControlNode(t *testing.T, dockerClient types.TastoraDockerClient, dockerNetworkID string, genesis []byte) *reth.Node { + t.Helper() + + return SetupTestRethNode(t, dockerClient, dockerNetworkID, + withProposerControlImage(t), + func(b *reth.NodeBuilder) { + b.WithGenesis(genesis) + }, + ) +} + +func newEngineClientForRethNode(t *testing.T, ctx context.Context, node *reth.Node) (*evm.EngineClient, string) { + t.Helper() + + ni, err := node.GetNetworkInfo(ctx) + require.NoError(t, err) + ethURL := "http://127.0.0.1:" + ni.External.Ports.RPC + engineURL := "http://127.0.0.1:" + ni.External.Ports.Engine + genesisHash, err := node.GenesisHash(ctx) + require.NoError(t, err) + + client, err := evm.NewEngineExecutionClient( + ethURL, + engineURL, + node.JWTSecretHex(), + common.HexToHash(genesisHash), + common.Address{}, + dssync.MutexWrap(ds.NewMapDatastore()), + false, + zerolog.Nop(), + ) + require.NoError(t, err) + return client, ethURL +} + +func withProposerControlImage(t *testing.T) RethNodeOpt { + t.Helper() + + repo := os.Getenv("EV_RETH_PROPOSER_IMAGE_REPO") + tag := os.Getenv("EV_RETH_PROPOSER_IMAGE_TAG") + if repo == "" && tag == "" { + return nil + } + if repo == "" { + repo = reth.DefaultImage().Repository + } + if tag == "" { + t.Fatal("EV_RETH_PROPOSER_IMAGE_TAG must be set when overriding the ev-reth image") + } + return func(b *reth.NodeBuilder) { + b.WithImage(container.NewImage(repo, tag, "")) + } +} + +func setNextProposerTx(t *testing.T, privateKeyHex string, nextProposer common.Hash) *ethTypes.Transaction { + t.Helper() + + return setNextProposerTxWithNonce(t, privateKeyHex, 0, nextProposer) +} + +func setNextProposerTxWithNonce(t *testing.T, privateKeyHex string, nonce uint64, nextProposer common.Hash) *ethTypes.Transaction { + t.Helper() + + privateKey, err := crypto.HexToECDSA(privateKeyHex) + require.NoError(t, err) + chainID, ok := new(big.Int).SetString(CHAIN_ID, 10) + require.True(t, ok) + + to := common.HexToAddress(proposerControlPrecompile) + tx := ethTypes.NewTx(ðTypes.LegacyTx{ + Nonce: nonce, + To: &to, + Value: big.NewInt(0), + Gas: 100_000, + GasPrice: big.NewInt(30_000_000_000), + Data: setNextProposerCalldata(nextProposer), + }) + signed, err := ethTypes.SignTx(tx, ethTypes.NewEIP155Signer(chainID), privateKey) + require.NoError(t, err) + return signed +} + +func setNextProposerCalldata(nextProposer common.Hash) []byte { + selector := crypto.Keccak256([]byte("setNextProposer(bytes32)"))[:4] + return append(selector, nextProposer.Bytes()...) +} + +func privateKeyAddress(t *testing.T, privateKeyHex string) common.Address { + t.Helper() + + privateKey, err := crypto.HexToECDSA(privateKeyHex) + require.NoError(t, err) + return crypto.PubkeyToAddress(privateKey.PublicKey) +} + +func requireProposerControlSupported(t *testing.T, err error) { + t.Helper() + if err == nil { + return + } + + var rpcErr rpc.Error + if errors.As(err, &rpcErr) && rpcErr.ErrorCode() == -32601 { + t.Skip("ev-reth image does not expose evolve_getNextProposer; set EV_RETH_PROPOSER_IMAGE_TAG to an image built from the proposer-control branch") + } + if strings.Contains(err.Error(), "proposerControl") { + t.Skipf("ev-reth image does not support proposer-control chainspec fields: %v", err) + } + require.NoError(t, err) +} + +func requireRawNextProposer(t *testing.T, ctx context.Context, ethURL string, want common.Hash) { + t.Helper() + + client, err := rpc.Dial(ethURL) + require.NoError(t, err) + defer client.Close() + + var got common.Hash + err = client.CallContext(ctx, &got, "evolve_getNextProposer", "latest") + requireProposerControlSupported(t, err) + require.Equal(t, want, got) +} diff --git a/execution/grpc/client.go b/execution/grpc/client.go index f5640c4950..2fadabd83f 100644 --- a/execution/grpc/client.go +++ b/execution/grpc/client.go @@ -152,10 +152,10 @@ func (c *Client) GetTxs(ctx context.Context) ([][]byte, error) { // This method sends transactions to the execution service for processing and // returns the updated state root after execution. The execution service ensures // deterministic execution and validates the state transition. -func (c *Client) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) (updatedStateRoot []byte, err error) { +func (c *Client) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) (execution.ExecuteResult, error) { txBatch, err := encodeTxBatch(txs) if err != nil { - return nil, fmt.Errorf("grpc client: failed to encode tx batch: %w", err) + return execution.ExecuteResult{}, fmt.Errorf("grpc client: failed to encode tx batch: %w", err) } req := connect.NewRequest(&pb.ExecuteTxsRequest{ @@ -167,10 +167,13 @@ func (c *Client) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint6 resp, err := c.client.ExecuteTxs(ctx, req) if err != nil { - return nil, fmt.Errorf("grpc client: failed to execute txs: %w", err) + return execution.ExecuteResult{}, fmt.Errorf("grpc client: failed to execute txs: %w", err) } - return resp.Msg.UpdatedStateRoot, nil + return execution.ExecuteResult{ + UpdatedStateRoot: resp.Msg.UpdatedStateRoot, + NextProposerAddress: resp.Msg.NextProposerAddress, + }, nil } // SetFinal marks a block as finalized at the specified height. @@ -203,7 +206,8 @@ func (c *Client) GetExecutionInfo(ctx context.Context) (execution.ExecutionInfo, } return execution.ExecutionInfo{ - MaxGas: resp.Msg.MaxGas, + MaxGas: resp.Msg.MaxGas, + NextProposerAddress: resp.Msg.NextProposerAddress, }, nil } diff --git a/execution/grpc/client_test.go b/execution/grpc/client_test.go index 4c0da44f1b..aad53ef0fd 100644 --- a/execution/grpc/client_test.go +++ b/execution/grpc/client_test.go @@ -17,7 +17,7 @@ import ( type mockExecutor struct { initChainFunc func(ctx context.Context, genesisTime time.Time, initialHeight uint64, chainID string) ([]byte, error) getTxsFunc func(ctx context.Context) ([][]byte, error) - executeTxsFunc func(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) ([]byte, error) + executeTxsFunc func(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) (execution.ExecuteResult, error) setFinalFunc func(ctx context.Context, blockHeight uint64) error getExecutionInfoFunc func(ctx context.Context) (execution.ExecutionInfo, error) filterTxsFunc func(ctx context.Context, txs [][]byte, maxBytes, maxGas uint64, hasForceIncludedTransaction bool) ([]execution.FilterStatus, error) @@ -37,11 +37,11 @@ func (m *mockExecutor) GetTxs(ctx context.Context) ([][]byte, error) { return [][]byte{[]byte("tx1"), []byte("tx2")}, nil } -func (m *mockExecutor) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) ([]byte, error) { +func (m *mockExecutor) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) (execution.ExecuteResult, error) { if m.executeTxsFunc != nil { return m.executeTxsFunc(ctx, txs, blockHeight, timestamp, prevStateRoot) } - return []byte("updated_state_root"), nil + return execution.ExecuteResult{UpdatedStateRoot: []byte("updated_state_root")}, nil } func (m *mockExecutor) SetFinal(ctx context.Context, blockHeight uint64) error { @@ -165,7 +165,7 @@ func TestClient_ExecuteTxs(t *testing.T) { expectedStateRoot := []byte("new_state_root") mockExec := &mockExecutor{ - executeTxsFunc: func(ctx context.Context, txsIn [][]byte, bh uint64, ts time.Time, psr []byte) ([]byte, error) { + executeTxsFunc: func(ctx context.Context, txsIn [][]byte, bh uint64, ts time.Time, psr []byte) (execution.ExecuteResult, error) { if len(txsIn) != len(txs) { t.Errorf("expected %d txs, got %d", len(txs), len(txsIn)) } @@ -178,7 +178,7 @@ func TestClient_ExecuteTxs(t *testing.T) { if string(psr) != string(prevStateRoot) { t.Errorf("expected prev state root %s, got %s", prevStateRoot, psr) } - return expectedStateRoot, nil + return execution.ExecuteResult{UpdatedStateRoot: expectedStateRoot}, nil }, } @@ -191,13 +191,13 @@ func TestClient_ExecuteTxs(t *testing.T) { client := newTestClient(t, server.URL) // Test ExecuteTxs - stateRoot, err := client.ExecuteTxs(ctx, txs, blockHeight, timestamp, prevStateRoot) + result, err := client.ExecuteTxs(ctx, txs, blockHeight, timestamp, prevStateRoot) if err != nil { t.Fatalf("unexpected error: %v", err) } - if string(stateRoot) != string(expectedStateRoot) { - t.Errorf("expected state root %s, got %s", expectedStateRoot, stateRoot) + if string(result.UpdatedStateRoot) != string(expectedStateRoot) { + t.Errorf("expected state root %s, got %s", expectedStateRoot, result.UpdatedStateRoot) } } diff --git a/execution/grpc/go.mod b/execution/grpc/go.mod index 12cbc7d0b3..e2d5f02cdd 100644 --- a/execution/grpc/go.mod +++ b/execution/grpc/go.mod @@ -2,6 +2,11 @@ module github.com/evstack/ev-node/execution/grpc go 1.25.7 +replace ( + github.com/evstack/ev-node => ../../ + github.com/evstack/ev-node/core => ../../core +) + require ( connectrpc.com/connect v1.20.0 connectrpc.com/grpcreflect v1.3.0 diff --git a/execution/grpc/go.sum b/execution/grpc/go.sum index 2a1e40079b..8074bd8d3c 100644 --- a/execution/grpc/go.sum +++ b/execution/grpc/go.sum @@ -6,8 +6,6 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/evstack/ev-node/core v1.0.0 h1:s0Tx0uWHme7SJn/ZNEtee4qNM8UO6PIxXnHhPbbKTz8= -github.com/evstack/ev-node/core v1.0.0/go.mod h1:n2w/LhYQTPsi48m6lMj16YiIqsaQw6gxwjyJvR+B3sY= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= diff --git a/execution/grpc/proto/evnode/v1/execution.proto b/execution/grpc/proto/evnode/v1/execution.proto index 7300c00929..c982f2f6af 100644 --- a/execution/grpc/proto/evnode/v1/execution.proto +++ b/execution/grpc/proto/evnode/v1/execution.proto @@ -92,6 +92,10 @@ message ExecuteTxsResponse { // Maximum allowed transaction size (may change with protocol updates) uint64 max_bytes = 2; + + // Proposer address that should sign the next block. + // Empty means the current proposer remains active. + bytes next_proposer_address = 3; } // SetFinalRequest marks a block as finalized @@ -113,6 +117,10 @@ message GetExecutionInfoResponse { // Maximum gas allowed for transactions in a block // For non-gas-based execution layers, this should be 0 uint64 max_gas = 1; + + // Proposer address that should sign the next block from the execution + // layer's current view. Empty means unchanged or unavailable. + bytes next_proposer_address = 2; } // FilterStatus represents the result of filtering a transaction diff --git a/execution/grpc/server.go b/execution/grpc/server.go index 48a6b673ea..18b5558b3a 100644 --- a/execution/grpc/server.go +++ b/execution/grpc/server.go @@ -112,7 +112,7 @@ func (s *Server) ExecuteTxs( return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid tx_batch: %w", err)) } - updatedStateRoot, err := s.executor.ExecuteTxs( + result, err := s.executor.ExecuteTxs( ctx, txs, req.Msg.BlockHeight, @@ -124,7 +124,8 @@ func (s *Server) ExecuteTxs( } return connect.NewResponse(&pb.ExecuteTxsResponse{ - UpdatedStateRoot: updatedStateRoot, + UpdatedStateRoot: result.UpdatedStateRoot, + NextProposerAddress: result.NextProposerAddress, }), nil } @@ -160,7 +161,8 @@ func (s *Server) GetExecutionInfo( } return connect.NewResponse(&pb.GetExecutionInfoResponse{ - MaxGas: info.MaxGas, + MaxGas: info.MaxGas, + NextProposerAddress: info.NextProposerAddress, }), nil } diff --git a/execution/grpc/server_test.go b/execution/grpc/server_test.go index 7d879e69b5..0fde2d2800 100644 --- a/execution/grpc/server_test.go +++ b/execution/grpc/server_test.go @@ -200,7 +200,7 @@ func TestServer_ExecuteTxs(t *testing.T) { tests := []struct { name string req *pb.ExecuteTxsRequest - mockFunc func(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) ([]byte, error) + mockFunc func(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) (execution.ExecuteResult, error) wantErr bool wantCode connect.Code }{ @@ -212,8 +212,8 @@ func TestServer_ExecuteTxs(t *testing.T) { Timestamp: timestamppb.New(timestamp), PrevStateRoot: prevStateRoot, }, - mockFunc: func(ctx context.Context, t [][]byte, bh uint64, ts time.Time, psr []byte) ([]byte, error) { - return expectedStateRoot, nil + mockFunc: func(ctx context.Context, t [][]byte, bh uint64, ts time.Time, psr []byte) (execution.ExecuteResult, error) { + return execution.ExecuteResult{UpdatedStateRoot: expectedStateRoot}, nil }, wantErr: false, }, @@ -266,8 +266,8 @@ func TestServer_ExecuteTxs(t *testing.T) { Timestamp: timestamppb.New(timestamp), PrevStateRoot: prevStateRoot, }, - mockFunc: func(ctx context.Context, t [][]byte, bh uint64, ts time.Time, psr []byte) ([]byte, error) { - return nil, errors.New("execute txs failed") + mockFunc: func(ctx context.Context, t [][]byte, bh uint64, ts time.Time, psr []byte) (execution.ExecuteResult, error) { + return execution.ExecuteResult{}, errors.New("execute txs failed") }, wantErr: true, wantCode: connect.CodeInternal, diff --git a/execution/grpc/types/pb/evnode/v1/execution.pb.go b/execution/grpc/types/pb/evnode/v1/execution.pb.go index 6dfc0d5d3e..49d8f53b45 100644 --- a/execution/grpc/types/pb/evnode/v1/execution.pb.go +++ b/execution/grpc/types/pb/evnode/v1/execution.pb.go @@ -402,9 +402,12 @@ type ExecuteTxsResponse struct { // New state root after executing transactions UpdatedStateRoot []byte `protobuf:"bytes,1,opt,name=updated_state_root,json=updatedStateRoot,proto3" json:"updated_state_root,omitempty"` // Maximum allowed transaction size (may change with protocol updates) - MaxBytes uint64 `protobuf:"varint,2,opt,name=max_bytes,json=maxBytes,proto3" json:"max_bytes,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + MaxBytes uint64 `protobuf:"varint,2,opt,name=max_bytes,json=maxBytes,proto3" json:"max_bytes,omitempty"` + // Proposer address that should sign the next block. + // Empty means the current proposer remains active. + NextProposerAddress []byte `protobuf:"bytes,3,opt,name=next_proposer_address,json=nextProposerAddress,proto3" json:"next_proposer_address,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ExecuteTxsResponse) Reset() { @@ -451,6 +454,13 @@ func (x *ExecuteTxsResponse) GetMaxBytes() uint64 { return 0 } +func (x *ExecuteTxsResponse) GetNextProposerAddress() []byte { + if x != nil { + return x.NextProposerAddress + } + return nil +} + // SetFinalRequest marks a block as finalized type SetFinalRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -576,9 +586,12 @@ type GetExecutionInfoResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Maximum gas allowed for transactions in a block // For non-gas-based execution layers, this should be 0 - MaxGas uint64 `protobuf:"varint,1,opt,name=max_gas,json=maxGas,proto3" json:"max_gas,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + MaxGas uint64 `protobuf:"varint,1,opt,name=max_gas,json=maxGas,proto3" json:"max_gas,omitempty"` + // Proposer address that should sign the next block from the execution + // layer's current view. Empty means unchanged or unavailable. + NextProposerAddress []byte `protobuf:"bytes,2,opt,name=next_proposer_address,json=nextProposerAddress,proto3" json:"next_proposer_address,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetExecutionInfoResponse) Reset() { @@ -618,6 +631,13 @@ func (x *GetExecutionInfoResponse) GetMaxGas() uint64 { return 0 } +func (x *GetExecutionInfoResponse) GetNextProposerAddress() []byte { + if x != nil { + return x.NextProposerAddress + } + return nil +} + // FilterTxsRequest contains transactions to validate and filter type FilterTxsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -759,16 +779,18 @@ const file_evnode_v1_execution_proto_rawDesc = "" + "\fblock_height\x18\x02 \x01(\x04R\vblockHeight\x128\n" + "\ttimestamp\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\x12&\n" + "\x0fprev_state_root\x18\x04 \x01(\fR\rprevStateRoot\x12-\n" + - "\btx_batch\x18\x05 \x01(\v2\x12.evnode.v1.TxBatchR\atxBatchJ\x04\b\x01\x10\x02R\x03txs\"_\n" + + "\btx_batch\x18\x05 \x01(\v2\x12.evnode.v1.TxBatchR\atxBatchJ\x04\b\x01\x10\x02R\x03txs\"\x93\x01\n" + "\x12ExecuteTxsResponse\x12,\n" + "\x12updated_state_root\x18\x01 \x01(\fR\x10updatedStateRoot\x12\x1b\n" + - "\tmax_bytes\x18\x02 \x01(\x04R\bmaxBytes\"4\n" + + "\tmax_bytes\x18\x02 \x01(\x04R\bmaxBytes\x122\n" + + "\x15next_proposer_address\x18\x03 \x01(\fR\x13nextProposerAddress\"4\n" + "\x0fSetFinalRequest\x12!\n" + "\fblock_height\x18\x01 \x01(\x04R\vblockHeight\"\x12\n" + "\x10SetFinalResponse\"\x19\n" + - "\x17GetExecutionInfoRequest\"3\n" + + "\x17GetExecutionInfoRequest\"g\n" + "\x18GetExecutionInfoResponse\x12\x17\n" + - "\amax_gas\x18\x01 \x01(\x04R\x06maxGas\"\xc7\x01\n" + + "\amax_gas\x18\x01 \x01(\x04R\x06maxGas\x122\n" + + "\x15next_proposer_address\x18\x02 \x01(\fR\x13nextProposerAddress\"\xc7\x01\n" + "\x10FilterTxsRequest\x12\x1b\n" + "\tmax_bytes\x18\x02 \x01(\x04R\bmaxBytes\x12\x17\n" + "\amax_gas\x18\x03 \x01(\x04R\x06maxGas\x12C\n" + diff --git a/go.mod b/go.mod index 779acfcf8c..d130db17f8 100644 --- a/go.mod +++ b/go.mod @@ -51,6 +51,8 @@ require ( gotest.tools/v3 v3.5.2 ) +replace github.com/evstack/ev-node/core => ./core + require ( cloud.google.com/go v0.123.0 // indirect cloud.google.com/go/auth v0.20.0 // indirect diff --git a/go.sum b/go.sum index 1aa14866c1..670f159dd1 100644 --- a/go.sum +++ b/go.sum @@ -188,8 +188,6 @@ github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= -github.com/evstack/ev-node/core v1.0.0 h1:s0Tx0uWHme7SJn/ZNEtee4qNM8UO6PIxXnHhPbbKTz8= -github.com/evstack/ev-node/core v1.0.0/go.mod h1:n2w/LhYQTPsi48m6lMj16YiIqsaQw6gxwjyJvR+B3sY= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= diff --git a/node/execution_test.go b/node/execution_test.go index 91133a218d..8f75cf87b6 100644 --- a/node/execution_test.go +++ b/node/execution_test.go @@ -98,7 +98,7 @@ func executeTransactions(t *testing.T, executor coreexecutor.Executor, ctx conte timestamp := time.Now() newStateRoot, err := executor.ExecuteTxs(ctx, txs, blockHeight, timestamp, stateRoot) require.NoError(t, err) - return newStateRoot + return newStateRoot.UpdatedStateRoot } func finalizeExecution(t *testing.T, executor coreexecutor.Executor, ctx context.Context) { diff --git a/node/full.go b/node/full.go index bd44f9ef42..b77e3ffad9 100644 --- a/node/full.go +++ b/node/full.go @@ -118,6 +118,15 @@ func newFullNode( // Initialize raft node if enabled (for both aggregator and sync nodes) var leaderElection leaderElection switch { + case nodeConfig.Node.Promotable && !nodeConfig.Raft.Enable: + if signer == nil { + return nil, fmt.Errorf("promotable mode requires a signer") + } + localProposer, err := signer.GetAddress() + if err != nil { + return nil, fmt.Errorf("get promotable signer address: %w", err) + } + leaderElection = newDynamicProposerElection(logger, localProposer, genesis.ProposerAddress, evstore, leaderFactory, followerFactory, 300*time.Millisecond) case nodeConfig.Node.Aggregator && nodeConfig.Raft.Enable: leaderElection = raftpkg.NewDynamicLeaderElection(logger, leaderFactory, followerFactory, raftNode) case nodeConfig.Node.Aggregator && !nodeConfig.Raft.Enable: diff --git a/node/proposer_election.go b/node/proposer_election.go new file mode 100644 index 0000000000..4324e1af5c --- /dev/null +++ b/node/proposer_election.go @@ -0,0 +1,168 @@ +package node + +import ( + "bytes" + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/rs/zerolog" + + "github.com/evstack/ev-node/pkg/raft" + "github.com/evstack/ev-node/pkg/store" + "github.com/evstack/ev-node/types" +) + +type proposerStateReader interface { + GetState(ctx context.Context) (types.State, error) +} + +type proposerRole uint8 + +const ( + proposerRoleFollower proposerRole = iota + proposerRoleLeader +) + +type dynamicProposerElection struct { + logger zerolog.Logger + localProposer []byte + initialProposer []byte + stateReader proposerStateReader + leaderFactory, followerFactory func() (raft.Runnable, error) + pollInterval time.Duration + running atomic.Bool +} + +func newDynamicProposerElection( + logger zerolog.Logger, + localProposer []byte, + initialProposer []byte, + stateReader proposerStateReader, + leaderFactory func() (raft.Runnable, error), + followerFactory func() (raft.Runnable, error), + pollInterval time.Duration, +) *dynamicProposerElection { + if pollInterval <= 0 { + pollInterval = 300 * time.Millisecond + } + return &dynamicProposerElection{ + logger: logger, + localProposer: append([]byte(nil), localProposer...), + initialProposer: append([]byte(nil), initialProposer...), + stateReader: stateReader, + leaderFactory: leaderFactory, + followerFactory: followerFactory, + pollInterval: pollInterval, + } +} + +func (d *dynamicProposerElection) Run(ctx context.Context) error { + var wg sync.WaitGroup + var workerCancel context.CancelFunc = func() {} + errCh := make(chan error, 1) + currentRole := proposerRoleFollower + + defer func() { + workerCancel() + wg.Wait() + close(errCh) + }() + d.running.Store(true) + defer d.running.Store(false) + + startRole := func(role proposerRole) error { + workerCancel() + wg.Wait() + + var ( + runnable raft.Runnable + err error + name string + ) + switch role { + case proposerRoleLeader: + name = "leader" + runnable, err = d.leaderFactory() + case proposerRoleFollower: + name = "follower" + runnable, err = d.followerFactory() + default: + return fmt.Errorf("unknown proposer role: %d", role) + } + if err != nil { + return err + } + + workerCtx, cancel := context.WithCancel(ctx) + workerCancel = cancel + currentRole = role + wg.Add(1) + go func() { + defer wg.Done() + if err := runnable.Run(workerCtx); err != nil && !errors.Is(err, context.Canceled) { + select { + case errCh <- fmt.Errorf("%s worker exited unexpectedly: %w", name, err): + default: + } + } + }() + return nil + } + + if err := startRole(proposerRoleFollower); err != nil { + return err + } + + ticker := time.NewTicker(d.pollInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + shouldLead, err := d.shouldLead(ctx) + if err != nil { + d.logger.Debug().Err(err).Msg("could not read local proposer state") + continue + } + if shouldLead && currentRole != proposerRoleLeader { + d.logger.Info().Msg("local signer is next proposer, promoting to aggregator") + if err := startRole(proposerRoleLeader); err != nil { + return err + } + } + if !shouldLead && currentRole != proposerRoleFollower { + d.logger.Info().Msg("local signer is not next proposer, returning to sync mode") + if err := startRole(proposerRoleFollower); err != nil { + return err + } + } + case err := <-errCh: + return err + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func (d *dynamicProposerElection) IsRunning() bool { + return d.running.Load() +} + +func (d *dynamicProposerElection) shouldLead(ctx context.Context) (bool, error) { + state, err := d.stateReader.GetState(ctx) + if err != nil { + if store.IsNotFound(err) { + return len(d.initialProposer) > 0 && bytes.Equal(d.initialProposer, d.localProposer), nil + } + return false, err + } + expectedProposer := state.NextProposerAddress + if len(expectedProposer) == 0 { + expectedProposer = d.initialProposer + } + return len(expectedProposer) > 0 && bytes.Equal(expectedProposer, d.localProposer), nil +} diff --git a/node/proposer_election_test.go b/node/proposer_election_test.go new file mode 100644 index 0000000000..e65d2079ba --- /dev/null +++ b/node/proposer_election_test.go @@ -0,0 +1,199 @@ +package node + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" + + "github.com/evstack/ev-node/pkg/raft" + "github.com/evstack/ev-node/pkg/store" + "github.com/evstack/ev-node/types" +) + +type proposerElectionStateReader struct { + mu sync.Mutex + state types.State + err error +} + +func (r *proposerElectionStateReader) setNextProposer(addr []byte) { + r.mu.Lock() + defer r.mu.Unlock() + r.state.NextProposerAddress = append([]byte(nil), addr...) + r.err = nil +} + +func (r *proposerElectionStateReader) setErr(err error) { + r.mu.Lock() + defer r.mu.Unlock() + r.err = err +} + +func (r *proposerElectionStateReader) GetState(context.Context) (types.State, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.err != nil { + return types.State{}, r.err + } + return r.state, nil +} + +type proposerElectionRunnable struct { + name string + started chan<- string + stopped chan<- string +} + +func (r proposerElectionRunnable) Run(ctx context.Context) error { + r.started <- r.name + <-ctx.Done() + r.stopped <- r.name + return ctx.Err() +} + +func (r proposerElectionRunnable) IsSynced(*raft.RaftBlockState) (int, error) { + return 0, nil +} + +func (r proposerElectionRunnable) Recover(context.Context, *raft.RaftBlockState) error { + return nil +} + +func TestDynamicProposerElectionPromotesAndDemotesFromLocalState(t *testing.T) { + localProposer := []byte{1, 2, 3} + otherProposer := []byte{9, 8, 7} + stateReader := &proposerElectionStateReader{} + stateReader.setNextProposer(otherProposer) + + started := make(chan string, 8) + stopped := make(chan string, 8) + election := newDynamicProposerElection( + zerolog.Nop(), + localProposer, + localProposer, + stateReader, + func() (raft.Runnable, error) { + return proposerElectionRunnable{name: "leader", started: started, stopped: stopped}, nil + }, + func() (raft.Runnable, error) { + return proposerElectionRunnable{name: "follower", started: started, stopped: stopped}, nil + }, + time.Millisecond, + ) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + errCh := make(chan error, 1) + go func() { + errCh <- election.Run(ctx) + }() + + require.Equal(t, "follower", receiveProposerElectionEvent(t, started)) + require.True(t, election.IsRunning()) + + stateReader.setNextProposer(localProposer) + require.Equal(t, "follower", receiveProposerElectionEvent(t, stopped)) + require.Equal(t, "leader", receiveProposerElectionEvent(t, started)) + + stateReader.setNextProposer(otherProposer) + require.Equal(t, "leader", receiveProposerElectionEvent(t, stopped)) + require.Equal(t, "follower", receiveProposerElectionEvent(t, started)) + + cancel() + require.Equal(t, "follower", receiveProposerElectionEvent(t, stopped)) + require.ErrorIs(t, receiveProposerElectionError(t, errCh), context.Canceled) +} + +func TestDynamicProposerElectionUsesInitialProposerBeforeStateExists(t *testing.T) { + localProposer := []byte{1, 2, 3} + otherProposer := []byte{9, 8, 7} + stateReader := &proposerElectionStateReader{} + stateReader.setErr(store.ErrNotFound) + + started := make(chan string, 8) + stopped := make(chan string, 8) + election := newDynamicProposerElection( + zerolog.Nop(), + localProposer, + localProposer, + stateReader, + func() (raft.Runnable, error) { + return proposerElectionRunnable{name: "leader", started: started, stopped: stopped}, nil + }, + func() (raft.Runnable, error) { + return proposerElectionRunnable{name: "follower", started: started, stopped: stopped}, nil + }, + time.Millisecond, + ) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + errCh := make(chan error, 1) + go func() { + errCh <- election.Run(ctx) + }() + + require.Equal(t, "follower", receiveProposerElectionEvent(t, started)) + require.Equal(t, "follower", receiveProposerElectionEvent(t, stopped)) + require.Equal(t, "leader", receiveProposerElectionEvent(t, started)) + + stateReader.setNextProposer(otherProposer) + require.Equal(t, "leader", receiveProposerElectionEvent(t, stopped)) + require.Equal(t, "follower", receiveProposerElectionEvent(t, started)) + + cancel() + require.Equal(t, "follower", receiveProposerElectionEvent(t, stopped)) + require.ErrorIs(t, receiveProposerElectionError(t, errCh), context.Canceled) +} + +func TestDynamicProposerElectionDoesNotUseInitialProposerForUnexpectedStateErrors(t *testing.T) { + localProposer := []byte{1, 2, 3} + stateReader := &proposerElectionStateReader{} + stateReader.setErr(errors.New("state read failed")) + + election := newDynamicProposerElection( + zerolog.Nop(), + localProposer, + localProposer, + stateReader, + func() (raft.Runnable, error) { + t.Fatal("leader factory should not be called on unexpected state read errors") + return nil, nil + }, + func() (raft.Runnable, error) { + return proposerElectionRunnable{}, nil + }, + time.Millisecond, + ) + + shouldLead, err := election.shouldLead(context.Background()) + require.ErrorContains(t, err, "state read failed") + require.False(t, shouldLead) +} + +func receiveProposerElectionEvent(t *testing.T, ch <-chan string) string { + t.Helper() + select { + case event := <-ch: + return event + case <-time.After(time.Second): + t.Fatal("timed out waiting for proposer election event") + return "" + } +} + +func receiveProposerElectionError(t *testing.T, ch <-chan error) error { + t.Helper() + select { + case err := <-ch: + return err + case <-time.After(time.Second): + t.Fatal("timed out waiting for proposer election error") + return nil + } +} diff --git a/pkg/cmd/run_node.go b/pkg/cmd/run_node.go index 8b241c98db..89bd43fa9c 100644 --- a/pkg/cmd/run_node.go +++ b/pkg/cmd/run_node.go @@ -108,7 +108,8 @@ func StartNode( // Validate and load pkgsigner first (before attempting DA connection, which may fail // eagerly over WebSocket if no DA server is running). var signer pkgsigner.Signer - if nodeConfig.Node.Aggregator && !nodeConfig.Node.BasedSequencer { + needsSigner := nodeConfig.Node.Aggregator || nodeConfig.Node.Promotable + if needsSigner && !nodeConfig.Node.BasedSequencer { passphrase := "" if nodeConfig.Signer.SignerType == "file" { passphraseFile, err := cmd.Flags().GetString(rollconf.FlagSignerPassphraseFile) diff --git a/pkg/cmd/run_node_test.go b/pkg/cmd/run_node_test.go index 1ed1a7e189..49e06447d5 100644 --- a/pkg/cmd/run_node_test.go +++ b/pkg/cmd/run_node_test.go @@ -173,6 +173,44 @@ func TestAggregatorFlagInvariants(t *testing.T) { } } +func TestPromotableFlagInvariants(t *testing.T) { + flagVariants := [][]string{{ + "--rollkit.node.promotable=false", + }, { + "--rollkit.node.promotable=true", + }, { + "--rollkit.node.promotable", + }} + + validValues := []bool{false, true, true} + + for i, flags := range flagVariants { + args := append([]string{"start"}, flags...) + + executor, sequencer, keyProvider, nodeKey, ds, stopDAHeightTicker := createTestComponents(context.Background(), t) + defer stopDAHeightTicker() + + nodeConfig := rollconf.DefaultConfig() + nodeConfig.RootDir = t.TempDir() + + newRunNodeCmd := newRunNodeCmd(t.Context(), executor, sequencer, keyProvider, nodeKey, ds, nodeConfig) + _ = newRunNodeCmd.Flags().Set(rollconf.FlagRootDir, "custom/root/dir") + + if err := newRunNodeCmd.ParseFlags(args); err != nil { + t.Errorf("Error: %v", err) + } + + nodeConfig, err := ParseConfig(newRunNodeCmd) + if err != nil { + t.Errorf("Error: %v", err) + } + + if nodeConfig.Node.Promotable != validValues[i] { + t.Errorf("Expected %v, got %v", validValues[i], nodeConfig.Node.Promotable) + } + } +} + // TestDefaultAggregatorValue verifies that the default value of Aggregator is true // when no flag is specified func TestDefaultAggregatorValue(t *testing.T) { @@ -576,6 +614,15 @@ func TestStartNodeErrors(t *testing.T) { }, expectedError: "unknown signer type", }, + { + name: "PromotableLoadsSigner", + configModifier: func(cfg *rollconf.Config) { + cfg.Signer.SignerType = "unknown" + cfg.Node.Aggregator = false + cfg.Node.Promotable = true + }, + expectedError: "unknown signer type", + }, { name: "LoadFileSystemSignerError", configModifier: func(cfg *rollconf.Config) { diff --git a/pkg/config/config.go b/pkg/config/config.go index 87e12f5597..a3613384a6 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -34,6 +34,8 @@ const ( // FlagAggregator is a flag for running node in aggregator mode FlagAggregator = FlagPrefixEvnode + "node.aggregator" + // FlagPromotable is a flag for running a full node with aggregator dependencies available for future promotion + FlagPromotable = FlagPrefixEvnode + "node.promotable" // FlagBasedSequencer is a flag for enabling based sequencer mode (requires aggregator mode) FlagBasedSequencer = FlagPrefixEvnode + "node.based_sequencer" // FlagLight is a flag for running the node in light mode @@ -291,6 +293,7 @@ func (d *DAConfig) GetForcedInclusionNamespace() string { type NodeConfig struct { // Node mode configuration Aggregator bool `mapstructure:"aggregator" yaml:"aggregator" comment:"Run node in aggregator mode"` + Promotable bool `mapstructure:"promotable" yaml:"promotable" comment:"Run full node with aggregator dependencies available for future promotion"` BasedSequencer bool `mapstructure:"based_sequencer" yaml:"based_sequencer" comment:"Run node with based sequencer (fetches transactions only from DA forced inclusion namespace). Requires aggregator mode to be enabled."` Light bool `mapstructure:"light" yaml:"light" comment:"Run node in light mode"` @@ -494,6 +497,18 @@ func (c *Config) Validate() error { return fmt.Errorf("could not create directory %q: %w", fullDir, err) } + if c.Node.Promotable && c.Node.BasedSequencer { + return fmt.Errorf("promotable mode cannot be combined with based sequencer mode") + } + + if c.Node.Promotable && c.Node.Light { + return fmt.Errorf("promotable mode cannot be combined with light node mode") + } + + if c.Node.Promotable && c.Raft.Enable { + return fmt.Errorf("promotable mode cannot be combined with Raft consensus") + } + // Validate based sequencer requires aggregator mode if c.Node.BasedSequencer && !c.Node.Aggregator { return fmt.Errorf("based sequencer mode requires aggregator mode to be enabled") @@ -590,6 +605,7 @@ func AddFlags(cmd *cobra.Command) { // Node configuration flags cmd.Flags().Bool(FlagAggregator, def.Node.Aggregator, "run node as an aggregator") + cmd.Flags().Bool(FlagPromotable, def.Node.Promotable, "run full node with aggregator dependencies available for future promotion") cmd.Flags().Bool(FlagBasedSequencer, def.Node.BasedSequencer, "run node with based sequencer (requires aggregator mode)") cmd.Flags().Bool(FlagLight, def.Node.Light, "run node in light mode") cmd.Flags().Duration(FlagBlockTime, def.Node.BlockTime.Duration, "block time (for aggregator mode)") @@ -656,9 +672,11 @@ func AddFlags(cmd *cobra.Command) { cmd.Flags().String(FlagSignerKmsGcpCredentialsFile, def.Signer.KMS.GCP.CredentialsFile, "Path to Google credentials JSON for signer.kms.provider=gcp (optional)") cmd.Flags().Duration(FlagSignerKmsGcpTimeout, def.Signer.KMS.GCP.Timeout.Duration, "Timeout for individual GCP KMS Sign requests") cmd.Flags().Int(FlagSignerKmsGcpMaxRetries, def.Signer.KMS.GCP.MaxRetries, "Maximum number of retries for transient GCP KMS failures") - cmd.Flags().String(FlagSignerPassphraseFile, "", "path to file containing the signer passphrase (required for file signer and if aggregator is enabled)") + cmd.Flags().String(FlagSignerPassphraseFile, "", "path to file containing the signer passphrase (required for file signer when aggregator or promotable mode is enabled)") cmd.MarkFlagsMutuallyExclusive(FlagLight, FlagAggregator) + cmd.MarkFlagsMutuallyExclusive(FlagBasedSequencer, FlagPromotable) + cmd.MarkFlagsMutuallyExclusive(FlagLight, FlagPromotable) // Raft configuration flags cmd.Flags().Bool(FlagRaftEnable, def.Raft.Enable, "enable Raft consensus for leader election and state replication") diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 1f140e5656..eb473f4294 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -22,6 +22,7 @@ func TestDefaultConfig(t *testing.T) { assert.Equal(t, "data", def.DBPath) assert.Equal(t, false, def.Node.Aggregator) + assert.Equal(t, false, def.Node.Promotable) assert.Equal(t, false, def.Node.Light) assert.Equal(t, DefaultConfig().DA.Address, def.DA.Address) assert.Equal(t, "", def.DA.AuthToken) @@ -60,6 +61,7 @@ func TestAddFlags(t *testing.T) { // Node flags assertFlagValue(t, flags, FlagAggregator, DefaultConfig().Node.Aggregator) + assertFlagValue(t, flags, FlagPromotable, DefaultConfig().Node.Promotable) assertFlagValue(t, flags, FlagBasedSequencer, DefaultConfig().Node.BasedSequencer) assertFlagValue(t, flags, FlagLight, DefaultConfig().Node.Light) assertFlagValue(t, flags, FlagBlockTime, DefaultConfig().Node.BlockTime.Duration) @@ -148,7 +150,7 @@ func TestAddFlags(t *testing.T) { assertFlagValue(t, flags, FlagPruningInterval, DefaultConfig().Pruning.Interval.Duration) // Count the number of flags we're explicitly checking - expectedFlagCount := 82 // Update this number if you add more flag checks above + expectedFlagCount := 83 // Update this number if you add more flag checks above // Get the actual number of flags (both regular and persistent) actualFlagCount := 0 @@ -563,6 +565,67 @@ func TestBasedSequencerValidation(t *testing.T) { } } +func TestPromotableValidation(t *testing.T) { + tests := []struct { + name string + aggregator bool + promotable bool + basedSeq bool + light bool + raft bool + expectError string + }{ + { + name: "promotable full node should pass", + promotable: true, + }, + { + name: "promotable aggregator should pass", + aggregator: true, + promotable: true, + }, + { + name: "promotable with based sequencer should fail", + promotable: true, + basedSeq: true, + expectError: "promotable mode cannot be combined with based sequencer mode", + }, + { + name: "promotable with light node should fail", + promotable: true, + light: true, + expectError: "promotable mode cannot be combined with light node mode", + }, + { + name: "promotable with raft should fail", + promotable: true, + raft: true, + expectError: "promotable mode cannot be combined with Raft consensus", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := DefaultConfig() + cfg.RootDir = t.TempDir() + cfg.Node.Aggregator = tt.aggregator + cfg.Node.Promotable = tt.promotable + cfg.Node.BasedSequencer = tt.basedSeq + cfg.Node.Light = tt.light + cfg.Raft.Enable = tt.raft + + err := cfg.Validate() + + if tt.expectError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectError) + return + } + require.NoError(t, err) + }) + } +} + func TestSignerValidation(t *testing.T) { tests := []struct { name string diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 233585e0c5..150335c0bb 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -63,6 +63,7 @@ func DefaultConfig() Config { }, Node: NodeConfig{ Aggregator: false, + Promotable: false, BlockTime: defaultBlockTime, LazyMode: false, LazyBlockInterval: DurationWrapper{60 * time.Second}, diff --git a/pkg/rpc/server/server.go b/pkg/rpc/server/server.go index f442fbb3ce..52a9628644 100644 --- a/pkg/rpc/server/server.go +++ b/pkg/rpc/server/server.go @@ -139,11 +139,12 @@ func (s *StoreServer) GetState( // Convert state to protobuf type pbState := &pb.State{ - AppHash: state.AppHash, - LastBlockHeight: state.LastBlockHeight, - LastBlockTime: timestamppb.New(state.LastBlockTime), - DaHeight: state.DAHeight, - ChainId: state.ChainID, + AppHash: state.AppHash, + LastBlockHeight: state.LastBlockHeight, + LastBlockTime: timestamppb.New(state.LastBlockTime), + DaHeight: state.DAHeight, + ChainId: state.ChainID, + NextProposerAddress: state.NextProposerAddress, Version: &pb.Version{ Block: state.Version.Block, App: state.Version.App, diff --git a/pkg/telemetry/executor_tracing.go b/pkg/telemetry/executor_tracing.go index 0f5507e04f..365ae7dd14 100644 --- a/pkg/telemetry/executor_tracing.go +++ b/pkg/telemetry/executor_tracing.go @@ -2,6 +2,7 @@ package telemetry import ( "context" + "encoding/hex" "time" "go.opentelemetry.io/otel" @@ -58,7 +59,7 @@ func (t *tracedExecutor) GetTxs(ctx context.Context) ([][]byte, error) { return txs, err } -func (t *tracedExecutor) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) ([]byte, error) { +func (t *tracedExecutor) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) (execution.ExecuteResult, error) { ctx, span := t.tracer.Start(ctx, "Executor.ExecuteTxs", trace.WithAttributes( attribute.Int("tx.count", len(txs)), @@ -68,12 +69,14 @@ func (t *tracedExecutor) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeig ) defer span.End() - stateRoot, err := t.inner.ExecuteTxs(ctx, txs, blockHeight, timestamp, prevStateRoot) + result, err := t.inner.ExecuteTxs(ctx, txs, blockHeight, timestamp, prevStateRoot) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) + } else if len(result.NextProposerAddress) > 0 { + span.SetAttributes(attribute.String("next_proposer_address", hex.EncodeToString(result.NextProposerAddress))) } - return stateRoot, err + return result, err } func (t *tracedExecutor) SetFinal(ctx context.Context, blockHeight uint64) error { diff --git a/pkg/telemetry/executor_tracing_test.go b/pkg/telemetry/executor_tracing_test.go index e53c8919af..a1715c928f 100644 --- a/pkg/telemetry/executor_tracing_test.go +++ b/pkg/telemetry/executor_tracing_test.go @@ -183,10 +183,10 @@ func TestWithTracingExecutor_ExecuteTxs_Success(t *testing.T) { ExecuteTxs(mock.Anything, txs, blockHeight, timestamp, prevStateRoot). Return(expectedStateRoot, nil) - stateRoot, err := traced.ExecuteTxs(ctx, txs, blockHeight, timestamp, prevStateRoot) + result, err := traced.ExecuteTxs(ctx, txs, blockHeight, timestamp, prevStateRoot) require.NoError(t, err) - require.Equal(t, expectedStateRoot, stateRoot) + require.Equal(t, expectedStateRoot, result.UpdatedStateRoot) // verify span spans := sr.Ended() diff --git a/proto/evnode/v1/evnode.proto b/proto/evnode/v1/evnode.proto index e60bd56e0d..8d7ec8cf4c 100644 --- a/proto/evnode/v1/evnode.proto +++ b/proto/evnode/v1/evnode.proto @@ -38,7 +38,6 @@ message Header { bytes validator_hash = 11; // Chain ID the block belongs to string chain_id = 12; - reserved 5, 7, 9; } diff --git a/proto/evnode/v1/state.proto b/proto/evnode/v1/state.proto index 1e8f35422d..7788c0123e 100644 --- a/proto/evnode/v1/state.proto +++ b/proto/evnode/v1/state.proto @@ -16,6 +16,7 @@ message State { uint64 da_height = 6; bytes app_hash = 8; bytes last_header_hash = 9; + bytes next_proposer_address = 10; reserved 7; } diff --git a/test/e2e/evm_proposer_rotation_e2e_test.go b/test/e2e/evm_proposer_rotation_e2e_test.go new file mode 100644 index 0000000000..9240307fa7 --- /dev/null +++ b/test/e2e/evm_proposer_rotation_e2e_test.go @@ -0,0 +1,313 @@ +//go:build evm + +package e2e + +import ( + "bytes" + "context" + "encoding/json" + "math/big" + "os" + "path/filepath" + "syscall" + "testing" + "time" + + tastoradocker "github.com/celestiaorg/tastora/framework/docker" + "github.com/celestiaorg/tastora/framework/docker/container" + "github.com/celestiaorg/tastora/framework/docker/evstack/reth" + "github.com/ethereum/go-ethereum/common" + ethTypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + evmtest "github.com/evstack/ev-node/execution/evm/test" + "github.com/evstack/ev-node/pkg/rpc/client" + filesigner "github.com/evstack/ev-node/pkg/signer/file" +) + +const proposerControlPrecompileAddress = "0x000000000000000000000000000000000000F101" + +func TestEvmFullNodeCanBecomeProposerAfterExecutionRotation(t *testing.T) { + if os.Getenv("EV_RETH_PROPOSER_IMAGE_TAG") == "" { + t.Skip("set EV_RETH_PROPOSER_IMAGE_TAG to an ev-reth image built with proposer-control support") + } + + workDir := t.TempDir() + sequencerHome := filepath.Join(workDir, "sequencer") + fullNodeHome := filepath.Join(workDir, "fullnode") + sut := NewSystemUnderTest(t) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + dockerClient, networkID := tastoradocker.Setup(t) + admin := privateKeyAddress(t, TestPrivateKey) + evmGenesis := proposerControlGenesis(t, admin, common.Hash{}) + env := SetupCommonEVMEnv(t, sut, dockerClient, networkID, + WithFullNode(), + WithRethOpts( + withProposerControlGenesis(evmGenesis), + withProposerControlImage(t), + ), + ) + + SetupSequencerNode(t, sut, sequencerHome, env.SequencerJWT, env.GenesisHash, env.Endpoints, "--evnode.node.promotable=true") + sequencerAddress := evNodeSignerAddress(t, sequencerHome) + + fullNodePassphraseFile := initNodeWithSigner(t, sut, fullNodeHome) + MustCopyFile(t, + filepath.Join(sequencerHome, "config", "genesis.json"), + filepath.Join(fullNodeHome, "config", "genesis.json"), + ) + fullNodeAddress := evNodeSignerAddress(t, fullNodeHome) + require.NotEqual(t, sequencerAddress, fullNodeAddress) + + sequencerP2PAddress := getNodeP2PAddress(t, sut, sequencerHome, env.Endpoints.RollkitRPCPort) + fullNodeJWTSecretFile := createJWTSecretFile(t, fullNodeHome, env.FullNodeJWT) + fullNodeProcess := startFullNodeProcess(t, sut, fullNodeHome, fullNodeJWTSecretFile, fullNodePassphraseFile, env.GenesisHash, sequencerP2PAddress, env.Endpoints) + t.Cleanup(func() { + _ = fullNodeProcess.Signal(syscall.SIGTERM) + }) + + seqClient, err := ethclient.Dial(env.Endpoints.GetSequencerEthURL()) + require.NoError(t, err) + defer seqClient.Close() + + fnClient, err := ethclient.Dial(env.Endpoints.GetFullNodeEthURL()) + require.NoError(t, err) + defer fnClient.Close() + + waitForEvNodeNextProposer(t, ctx, env.Endpoints.GetRollkitRPCAddress(), sequencerAddress, 20*time.Second) + waitForEvNodeNextProposer(t, ctx, env.Endpoints.GetFullNodeRPCAddress(), sequencerAddress, 20*time.Second) + + fullNodeProposer := common.BytesToHash(fullNodeAddress) + tx := setNextProposerTx(t, TestPrivateKey, fullNodeProposer, 0) + require.NoError(t, seqClient.SendTransaction(ctx, tx)) + waitForEVMTransaction(t, ctx, seqClient, tx.Hash(), 30*time.Second) + + waitForEvNodeNextProposer(t, ctx, env.Endpoints.GetRollkitRPCAddress(), fullNodeAddress, 30*time.Second) + waitForEvNodeNextProposer(t, ctx, env.Endpoints.GetFullNodeRPCAddress(), fullNodeAddress, 30*time.Second) + requireRawNextProposer(t, ctx, env.Endpoints.GetSequencerEthURL(), fullNodeProposer) + requireRawNextProposer(t, ctx, env.Endpoints.GetFullNodeEthURL(), fullNodeProposer) + + fullNodeHeightBeforePromotion := currentEvNodeHeight(t, ctx, env.Endpoints.GetFullNodeRPCAddress()) + waitForEvNodeHeightAbove(t, ctx, env.Endpoints.GetFullNodeRPCAddress(), fullNodeHeightBeforePromotion, 30*time.Second) + fullNodeProducedHeight := currentEvNodeHeight(t, ctx, env.Endpoints.GetFullNodeRPCAddress()) + fullNodeProducedBlock, err := client.NewClient(env.Endpoints.GetFullNodeRPCAddress()).GetBlockByHeight(ctx, fullNodeProducedHeight) + require.NoError(t, err) + require.Equal(t, fullNodeAddress, fullNodeProducedBlock.Block.Header.Header.ProposerAddress) + + sequencerProposer := common.BytesToHash(sequencerAddress) + tx = setNextProposerTx(t, TestPrivateKey, sequencerProposer, 1) + require.NoError(t, fnClient.SendTransaction(ctx, tx)) + waitForEVMTransaction(t, ctx, fnClient, tx.Hash(), 30*time.Second) + + waitForEvNodeNextProposer(t, ctx, env.Endpoints.GetRollkitRPCAddress(), sequencerAddress, 30*time.Second) + waitForEvNodeNextProposer(t, ctx, env.Endpoints.GetFullNodeRPCAddress(), sequencerAddress, 30*time.Second) + requireRawNextProposer(t, ctx, env.Endpoints.GetSequencerEthURL(), sequencerProposer) + requireRawNextProposer(t, ctx, env.Endpoints.GetFullNodeEthURL(), sequencerProposer) + + sequencerHeightBeforePromotion := currentEvNodeHeight(t, ctx, env.Endpoints.GetRollkitRPCAddress()) + waitForEvNodeHeightAbove(t, ctx, env.Endpoints.GetRollkitRPCAddress(), sequencerHeightBeforePromotion, 30*time.Second) + sequencerProducedHeight := currentEvNodeHeight(t, ctx, env.Endpoints.GetRollkitRPCAddress()) + sequencerProducedBlock, err := client.NewClient(env.Endpoints.GetRollkitRPCAddress()).GetBlockByHeight(ctx, sequencerProducedHeight) + require.NoError(t, err) + require.Equal(t, sequencerAddress, sequencerProducedBlock.Block.Header.Header.ProposerAddress) +} + +func initNodeWithSigner(t *testing.T, sut *SystemUnderTest, home string) string { + t.Helper() + + passphraseFile := createPassphraseFile(t, home) + output, err := sut.RunCmd(evmSingleBinaryPath, + "init", + "--evnode.node.aggregator=true", + "--evnode.signer.passphrase_file", passphraseFile, + "--home", home, + ) + require.NoError(t, err, "failed to init node with signer", output) + return passphraseFile +} + +func evNodeSignerAddress(t *testing.T, home string) []byte { + t.Helper() + + signer, err := filesigner.LoadFileSystemSigner(filepath.Join(home, "config"), []byte(TestPassphrase)) + require.NoError(t, err) + addr, err := signer.GetAddress() + require.NoError(t, err) + require.Len(t, addr, 32) + return append([]byte(nil), addr...) +} + +func startFullNodeProcess( + t *testing.T, + sut *SystemUnderTest, + fullNodeHome string, + fullNodeJWTSecretFile string, + passphraseFile string, + genesisHash string, + sequencerP2PAddress string, + endpoints *TestEndpoints, +) *os.Process { + t.Helper() + + process := sut.ExecCmd(evmSingleBinaryPath, + "start", + "--evnode.log.level", "debug", + "--evnode.log.format", "json", + "--home", fullNodeHome, + "--evm.jwt-secret-file", fullNodeJWTSecretFile, + "--evnode.node.aggregator=false", + "--evnode.node.promotable=true", + "--evnode.signer.passphrase_file", passphraseFile, + "--evm.genesis-hash", genesisHash, + "--evnode.p2p.peers", sequencerP2PAddress, + "--evm.engine-url", endpoints.GetFullNodeEngineURL(), + "--evm.eth-url", endpoints.GetFullNodeEthURL(), + "--evnode.da.block_time", DefaultDABlockTime, + "--evnode.da.address", endpoints.GetDAAddress(), + "--evnode.da.namespace", DefaultDANamespace, + "--evnode.da.batching_strategy", "immediate", + "--evnode.rpc.address", endpoints.GetFullNodeRPCListen(), + "--evnode.p2p.listen_address", endpoints.GetFullNodeP2PAddress(), + ) + sut.AwaitNodeLive(t, endpoints.GetFullNodeRPCAddress(), NodeStartupTimeout) + return process +} + +func waitForEvNodeNextProposer(t *testing.T, ctx context.Context, rpcAddr string, want []byte, timeout time.Duration) { + t.Helper() + + evClient := client.NewClient(rpcAddr) + require.EventuallyWithT(t, func(t *assert.CollectT) { + state, err := evClient.GetState(ctx) + require.NoError(t, err) + require.True(t, bytes.Equal(want, state.NextProposerAddress), "got %x, want %x", state.NextProposerAddress, want) + }, timeout, 500*time.Millisecond) +} + +func currentEvNodeHeight(t *testing.T, ctx context.Context, rpcAddr string) uint64 { + t.Helper() + + state, err := client.NewClient(rpcAddr).GetState(ctx) + require.NoError(t, err) + return state.LastBlockHeight +} + +func waitForEvNodeHeightAbove(t *testing.T, ctx context.Context, rpcAddr string, height uint64, timeout time.Duration) { + t.Helper() + + evClient := client.NewClient(rpcAddr) + require.EventuallyWithT(t, func(t *assert.CollectT) { + state, err := evClient.GetState(ctx) + require.NoError(t, err) + require.Greater(t, state.LastBlockHeight, height) + }, timeout, 500*time.Millisecond) +} + +func waitForEVMTransaction(t *testing.T, ctx context.Context, ethClient *ethclient.Client, hash common.Hash, timeout time.Duration) { + t.Helper() + + require.Eventually(t, func() bool { + receipt, err := ethClient.TransactionReceipt(ctx, hash) + return err == nil && receipt != nil && receipt.Status == ethTypes.ReceiptStatusSuccessful + }, timeout, 500*time.Millisecond) +} + +func proposerControlGenesis(t *testing.T, admin common.Address, initialNextProposer common.Hash) []byte { + t.Helper() + + var genesis map[string]any + require.NoError(t, json.Unmarshal([]byte(reth.DefaultEvolveGenesisJSON()), &genesis)) + config, ok := genesis["config"].(map[string]any) + require.True(t, ok) + evolve, ok := config["evolve"].(map[string]any) + if !ok { + evolve = make(map[string]any) + config["evolve"] = evolve + } + evolve["proposerControlAdmin"] = admin.Hex() + evolve["proposerControlActivationHeight"] = float64(0) + evolve["initialNextProposer"] = initialNextProposer.Hex() + + bz, err := json.MarshalIndent(genesis, "", " ") + require.NoError(t, err) + return bz +} + +func withProposerControlGenesis(genesis []byte) evmtest.RethNodeOpt { + return func(b *reth.NodeBuilder) { + b.WithGenesis(genesis) + } +} + +func withProposerControlImage(t *testing.T) evmtest.RethNodeOpt { + t.Helper() + + repo := os.Getenv("EV_RETH_PROPOSER_IMAGE_REPO") + tag := os.Getenv("EV_RETH_PROPOSER_IMAGE_TAG") + if repo == "" && tag == "" { + return nil + } + if repo == "" { + repo = reth.DefaultImage().Repository + } + if tag == "" { + t.Fatal("EV_RETH_PROPOSER_IMAGE_TAG must be set when overriding the ev-reth image") + } + return func(b *reth.NodeBuilder) { + b.WithImage(container.NewImage(repo, tag, "")) + } +} + +func setNextProposerTx(t *testing.T, privateKeyHex string, nextProposer common.Hash, nonce uint64) *ethTypes.Transaction { + t.Helper() + + privateKey, err := crypto.HexToECDSA(privateKeyHex) + require.NoError(t, err) + chainID, ok := new(big.Int).SetString(DefaultChainID, 10) + require.True(t, ok) + + to := common.HexToAddress(proposerControlPrecompileAddress) + tx := ethTypes.NewTx(ðTypes.LegacyTx{ + Nonce: nonce, + To: &to, + Value: big.NewInt(0), + Gas: 100_000, + GasPrice: big.NewInt(30_000_000_000), + Data: setNextProposerCalldata(nextProposer), + }) + signed, err := ethTypes.SignTx(tx, ethTypes.NewEIP155Signer(chainID), privateKey) + require.NoError(t, err) + return signed +} + +func setNextProposerCalldata(nextProposer common.Hash) []byte { + selector := crypto.Keccak256([]byte("setNextProposer(bytes32)"))[:4] + return append(selector, nextProposer.Bytes()...) +} + +func privateKeyAddress(t *testing.T, privateKeyHex string) common.Address { + t.Helper() + + privateKey, err := crypto.HexToECDSA(privateKeyHex) + require.NoError(t, err) + return crypto.PubkeyToAddress(privateKey.PublicKey) +} + +func requireRawNextProposer(t *testing.T, ctx context.Context, ethURL string, want common.Hash) { + t.Helper() + + rpcClient, err := rpc.Dial(ethURL) + require.NoError(t, err) + defer rpcClient.Close() + + var got common.Hash + require.NoError(t, rpcClient.CallContext(ctx, &got, "evolve_getNextProposer", "latest")) + require.Equal(t, want, got) +} diff --git a/test/e2e/go.mod b/test/e2e/go.mod index f570db7d9e..f00627d32c 100644 --- a/test/e2e/go.mod +++ b/test/e2e/go.mod @@ -23,6 +23,7 @@ require ( replace ( github.com/evstack/ev-node => ../../ + github.com/evstack/ev-node/core => ../../core github.com/evstack/ev-node/execution/evm => ../../execution/evm github.com/evstack/ev-node/execution/evm/test => ../../execution/evm/test ) diff --git a/test/e2e/go.sum b/test/e2e/go.sum index 0790a6adee..d8358c5c23 100644 --- a/test/e2e/go.sum +++ b/test/e2e/go.sum @@ -411,8 +411,6 @@ github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJ github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= github.com/ethereum/go-ethereum v1.17.3 h1:Ev/sQHH+UdKZHWjuVzhu2pxhi/sXaPZl23Q+Q5LDd4Q= github.com/ethereum/go-ethereum v1.17.3/go.mod h1:f2EhRwqewIZkGoQekywI2Y2RZAMTSavLNkD9qItFy1A= -github.com/evstack/ev-node/core v1.0.0 h1:s0Tx0uWHme7SJn/ZNEtee4qNM8UO6PIxXnHhPbbKTz8= -github.com/evstack/ev-node/core v1.0.0/go.mod h1:n2w/LhYQTPsi48m6lMj16YiIqsaQw6gxwjyJvR+B3sY= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= diff --git a/test/mocks/execution.go b/test/mocks/execution.go index 706e556291..8c973524e7 100644 --- a/test/mocks/execution.go +++ b/test/mocks/execution.go @@ -40,23 +40,29 @@ func (_m *MockExecutor) EXPECT() *MockExecutor_Expecter { } // ExecuteTxs provides a mock function for the type MockExecutor -func (_mock *MockExecutor) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) ([]byte, error) { +func (_mock *MockExecutor) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) (execution.ExecuteResult, error) { ret := _mock.Called(ctx, txs, blockHeight, timestamp, prevStateRoot) if len(ret) == 0 { panic("no return value specified for ExecuteTxs") } - var r0 []byte + var r0 execution.ExecuteResult var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, [][]byte, uint64, time.Time, []byte) ([]byte, error)); ok { + if returnFunc, ok := ret.Get(0).(func(context.Context, [][]byte, uint64, time.Time, []byte) (execution.ExecuteResult, error)); ok { return returnFunc(ctx, txs, blockHeight, timestamp, prevStateRoot) } - if returnFunc, ok := ret.Get(0).(func(context.Context, [][]byte, uint64, time.Time, []byte) []byte); ok { + if returnFunc, ok := ret.Get(0).(func(context.Context, [][]byte, uint64, time.Time, []byte) execution.ExecuteResult); ok { r0 = returnFunc(ctx, txs, blockHeight, timestamp, prevStateRoot) } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]byte) + switch result := ret.Get(0).(type) { + case nil: + case execution.ExecuteResult: + r0 = result + case []byte: + r0 = execution.ExecuteResult{UpdatedStateRoot: result} + default: + r0 = ret.Get(0).(execution.ExecuteResult) } } if returnFunc, ok := ret.Get(1).(func(context.Context, [][]byte, uint64, time.Time, []byte) error); ok { @@ -115,12 +121,12 @@ func (_c *MockExecutor_ExecuteTxs_Call) Run(run func(ctx context.Context, txs [] return _c } -func (_c *MockExecutor_ExecuteTxs_Call) Return(updatedStateRoot []byte, err error) *MockExecutor_ExecuteTxs_Call { - _c.Call.Return(updatedStateRoot, err) +func (_c *MockExecutor_ExecuteTxs_Call) Return(result interface{}, err error) *MockExecutor_ExecuteTxs_Call { + _c.Call.Return(result, err) return _c } -func (_c *MockExecutor_ExecuteTxs_Call) RunAndReturn(run func(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) ([]byte, error)) *MockExecutor_ExecuteTxs_Call { +func (_c *MockExecutor_ExecuteTxs_Call) RunAndReturn(run func(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) (execution.ExecuteResult, error)) *MockExecutor_ExecuteTxs_Call { _c.Call.Return(run) return _c } @@ -213,6 +219,20 @@ func (_c *MockExecutor_FilterTxs_Call) RunAndReturn(run func(ctx context.Context // GetExecutionInfo provides a mock function for the type MockExecutor func (_mock *MockExecutor) GetExecutionInfo(ctx context.Context) (execution.ExecutionInfo, error) { + if len(_mock.ExpectedCalls) == 0 { + return execution.ExecutionInfo{}, nil + } + hasExpectation := false + for _, call := range _mock.ExpectedCalls { + if call.Method == "GetExecutionInfo" { + hasExpectation = true + break + } + } + if !hasExpectation { + return execution.ExecutionInfo{}, nil + } + ret := _mock.Called(ctx) if len(ret) == 0 { diff --git a/test/mocks/height_aware_executor.go b/test/mocks/height_aware_executor.go index 354534c484..9e512d291b 100644 --- a/test/mocks/height_aware_executor.go +++ b/test/mocks/height_aware_executor.go @@ -44,9 +44,18 @@ func (m *MockHeightAwareExecutor) GetTxs(ctx context.Context) ([][]byte, error) } // ExecuteTxs implements the Executor interface. -func (m *MockHeightAwareExecutor) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) ([]byte, error) { +func (m *MockHeightAwareExecutor) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) (execution.ExecuteResult, error) { args := m.Called(ctx, txs, blockHeight, timestamp, prevStateRoot) - return args.Get(0).([]byte), args.Error(1) + switch result := args.Get(0).(type) { + case nil: + return execution.ExecuteResult{}, args.Error(1) + case execution.ExecuteResult: + return result, args.Error(1) + case []byte: + return execution.ExecuteResult{UpdatedStateRoot: result}, args.Error(1) + default: + return args.Get(0).(execution.ExecuteResult), args.Error(1) + } } // SetFinal implements the Executor interface. @@ -63,6 +72,20 @@ func (m *MockHeightAwareExecutor) GetLatestHeight(ctx context.Context) (uint64, // GetExecutionInfo implements the Executor interface. func (m *MockHeightAwareExecutor) GetExecutionInfo(ctx context.Context) (execution.ExecutionInfo, error) { + if len(m.ExpectedCalls) == 0 { + return execution.ExecutionInfo{}, nil + } + hasExpectation := false + for _, call := range m.ExpectedCalls { + if call.Method == "GetExecutionInfo" { + hasExpectation = true + break + } + } + if !hasExpectation { + return execution.ExecutionInfo{}, nil + } + args := m.Called(ctx) return args.Get(0).(execution.ExecutionInfo), args.Error(1) } diff --git a/types/data.go b/types/data.go index 1345e4e829..91d17109bf 100644 --- a/types/data.go +++ b/types/data.go @@ -9,6 +9,14 @@ import ( "google.golang.org/protobuf/proto" ) +var ( + // ErrHeaderDataMismatch is returned when header and data metadata do not describe the same block. + ErrHeaderDataMismatch = errors.New("header and data do not match") + + // ErrDataHashMismatch is returned when a header's data hash does not match its block data. + ErrDataHashMismatch = errors.New("dataHash from the header does not match with hash of the block's data") +) + // Version captures the consensus rules for processing a block in the blockchain, // including all blockchain data structures and the rules of the application's // state transition machine. @@ -60,14 +68,14 @@ func Validate(header *SignedHeader, data *Data) error { if header.ChainID() != data.ChainID() || header.Height() != data.Height() || header.Time() != data.Time() { // skipping LastDataHash comparison as it needs access to previous header - return errors.New("header and data do not match") + return ErrHeaderDataMismatch } } // exclude Metadata while computing the data hash for comparison d := Data{Txs: data.Txs} dataHash := d.DACommitment() if !bytes.Equal(dataHash[:], header.DataHash[:]) { - return errors.New("dataHash from the header does not match with hash of the block's data") + return ErrDataHashMismatch } return nil } diff --git a/types/header.go b/types/header.go index 2b5e2881b9..3049425ebe 100644 --- a/types/header.go +++ b/types/header.go @@ -1,7 +1,6 @@ package types import ( - "bytes" "context" "encoding" "errors" @@ -43,7 +42,8 @@ var ( // ErrNoProposerAddress is returned when the proposer address is not set. ErrNoProposerAddress = errors.New("no proposer address") - // ErrProposerVerificationFailed is returned when the proposer verification fails. + // ErrProposerVerificationFailed is deprecated. Proposer authorization is + // enforced through State validation because proposer rotation is execution-owned. ErrProposerVerificationFailed = errors.New("proposer verification failed") // ErrInvalidTimestamp is returned when the timestamp is invalid. @@ -124,15 +124,9 @@ func (h *Header) Time() time.Time { // Verify verifies the header. func (h *Header) Verify(untrstH *Header) error { - if !bytes.Equal(untrstH.ProposerAddress, h.ProposerAddress) { - return &header.VerifyError{ - Reason: fmt.Errorf("%w: expected proposer (%X) got (%X)", - ErrProposerVerificationFailed, - h.ProposerAddress, - untrstH.ProposerAddress, - ), - } - } + // Proposer rotation is execution/state-owned. The trusted header alone no + // longer contains enough information to authorize the signer of the next + // header, so full nodes enforce proposer validity through State validation. return nil } diff --git a/types/pb/evnode/v1/state.pb.go b/types/pb/evnode/v1/state.pb.go index a76c7efb28..868164e407 100644 --- a/types/pb/evnode/v1/state.pb.go +++ b/types/pb/evnode/v1/state.pb.go @@ -24,17 +24,18 @@ const ( // State is the state of the blockchain. type State struct { - state protoimpl.MessageState `protogen:"open.v1"` - Version *Version `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` - ChainId string `protobuf:"bytes,2,opt,name=chain_id,json=chainId,proto3" json:"chain_id,omitempty"` - InitialHeight uint64 `protobuf:"varint,3,opt,name=initial_height,json=initialHeight,proto3" json:"initial_height,omitempty"` - LastBlockHeight uint64 `protobuf:"varint,4,opt,name=last_block_height,json=lastBlockHeight,proto3" json:"last_block_height,omitempty"` - LastBlockTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=last_block_time,json=lastBlockTime,proto3" json:"last_block_time,omitempty"` - DaHeight uint64 `protobuf:"varint,6,opt,name=da_height,json=daHeight,proto3" json:"da_height,omitempty"` - AppHash []byte `protobuf:"bytes,8,opt,name=app_hash,json=appHash,proto3" json:"app_hash,omitempty"` - LastHeaderHash []byte `protobuf:"bytes,9,opt,name=last_header_hash,json=lastHeaderHash,proto3" json:"last_header_hash,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Version *Version `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` + ChainId string `protobuf:"bytes,2,opt,name=chain_id,json=chainId,proto3" json:"chain_id,omitempty"` + InitialHeight uint64 `protobuf:"varint,3,opt,name=initial_height,json=initialHeight,proto3" json:"initial_height,omitempty"` + LastBlockHeight uint64 `protobuf:"varint,4,opt,name=last_block_height,json=lastBlockHeight,proto3" json:"last_block_height,omitempty"` + LastBlockTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=last_block_time,json=lastBlockTime,proto3" json:"last_block_time,omitempty"` + DaHeight uint64 `protobuf:"varint,6,opt,name=da_height,json=daHeight,proto3" json:"da_height,omitempty"` + AppHash []byte `protobuf:"bytes,8,opt,name=app_hash,json=appHash,proto3" json:"app_hash,omitempty"` + LastHeaderHash []byte `protobuf:"bytes,9,opt,name=last_header_hash,json=lastHeaderHash,proto3" json:"last_header_hash,omitempty"` + NextProposerAddress []byte `protobuf:"bytes,10,opt,name=next_proposer_address,json=nextProposerAddress,proto3" json:"next_proposer_address,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *State) Reset() { @@ -123,6 +124,13 @@ func (x *State) GetLastHeaderHash() []byte { return nil } +func (x *State) GetNextProposerAddress() []byte { + if x != nil { + return x.NextProposerAddress + } + return nil +} + // RaftBlockState represents a replicated block state type RaftBlockState struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -275,7 +283,7 @@ var File_evnode_v1_state_proto protoreflect.FileDescriptor const file_evnode_v1_state_proto_rawDesc = "" + "\n" + - "\x15evnode/v1/state.proto\x12\tevnode.v1\x1a\x16evnode/v1/evnode.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xcf\x02\n" + + "\x15evnode/v1/state.proto\x12\tevnode.v1\x1a\x16evnode/v1/evnode.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x83\x03\n" + "\x05State\x12,\n" + "\aversion\x18\x01 \x01(\v2\x12.evnode.v1.VersionR\aversion\x12\x19\n" + "\bchain_id\x18\x02 \x01(\tR\achainId\x12%\n" + @@ -284,7 +292,9 @@ const file_evnode_v1_state_proto_rawDesc = "" + "\x0flast_block_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\rlastBlockTime\x12\x1b\n" + "\tda_height\x18\x06 \x01(\x04R\bdaHeight\x12\x19\n" + "\bapp_hash\x18\b \x01(\fR\aappHash\x12(\n" + - "\x10last_header_hash\x18\t \x01(\fR\x0elastHeaderHashJ\x04\b\a\x10\b\"\x8e\x02\n" + + "\x10last_header_hash\x18\t \x01(\fR\x0elastHeaderHash\x122\n" + + "\x15next_proposer_address\x18\n" + + " \x01(\fR\x13nextProposerAddressJ\x04\b\a\x10\b\"\x8e\x02\n" + "\x0eRaftBlockState\x12\x16\n" + "\x06height\x18\x01 \x01(\x04R\x06height\x12D\n" + "\x1flast_submitted_da_header_height\x18\x02 \x01(\x04R\x1blastSubmittedDaHeaderHeight\x12@\n" + diff --git a/types/serialization.go b/types/serialization.go index dd131dd3bd..b16e7d549d 100644 --- a/types/serialization.go +++ b/types/serialization.go @@ -436,7 +436,6 @@ func (h *Header) FromProto(other *pb.Header) error { } else { h.ValidatorHash = nil } - legacy, err := decodeLegacyHeaderFields(other) if err != nil { return err @@ -533,6 +532,7 @@ func (s *State) MarshalBinary() ([]byte, error) { ps.DaHeight = s.DAHeight ps.AppHash = s.AppHash ps.LastHeaderHash = s.LastHeaderHash + ps.NextProposerAddress = s.NextProposerAddress bz, err := proto.Marshal(ps) @@ -554,13 +554,14 @@ func (s *State) ToProto() (*pb.State, error) { Block: s.Version.Block, App: s.Version.App, }, - ChainId: s.ChainID, - InitialHeight: s.InitialHeight, - LastBlockHeight: s.LastBlockHeight, - LastBlockTime: ×tamppb.Timestamp{Seconds: secs, Nanos: nanos}, - DaHeight: s.DAHeight, - AppHash: s.AppHash[:], - LastHeaderHash: s.LastHeaderHash[:], + ChainId: s.ChainID, + InitialHeight: s.InitialHeight, + LastBlockHeight: s.LastBlockHeight, + LastBlockTime: ×tamppb.Timestamp{Seconds: secs, Nanos: nanos}, + DaHeight: s.DAHeight, + AppHash: s.AppHash[:], + LastHeaderHash: s.LastHeaderHash[:], + NextProposerAddress: s.NextProposerAddress, }, nil } @@ -596,6 +597,11 @@ func (s *State) FromProto(other *pb.State) error { s.LastHeaderHash = nil } s.DAHeight = other.GetDaHeight() + if other.NextProposerAddress != nil { + s.NextProposerAddress = append([]byte(nil), other.NextProposerAddress...) + } else { + s.NextProposerAddress = nil + } return nil } diff --git a/types/signed_header.go b/types/signed_header.go index e0dba33359..b1ccb27974 100644 --- a/types/signed_header.go +++ b/types/signed_header.go @@ -102,6 +102,12 @@ var ( // ErrProposerAddressMismatch is returned when the proposer address in the signed header does not match the proposer address in the validator set ErrProposerAddressMismatch = errors.New("proposer address in SignedHeader does not match the proposer address in the validator set") + // ErrSignerPubKeyMissing is returned when the signed header does not include the signer public key. + ErrSignerPubKeyMissing = errors.New("signer public key is missing") + + // ErrSignerAddressMismatch is returned when the signer address does not derive from the signer public key. + ErrSignerAddressMismatch = errors.New("signer address does not match signer public key") + // ErrSignatureEmpty is returned when signature is empty ErrSignatureEmpty = errors.New("signature is empty") ) @@ -116,6 +122,10 @@ func (sh *SignedHeader) ValidateBasic() error { return err } + if err := sh.validateSignerIdentity(); err != nil { + return err + } + // Check that the proposer address in the signed header matches the proposer address in the validator set if !bytes.Equal(sh.ProposerAddress, sh.Signer.Address) { return ErrProposerAddressMismatch @@ -195,6 +205,10 @@ func (sh *SignedHeader) ValidateBasicWithData(data *Data) error { return err } + if err := sh.validateSignerIdentity(); err != nil { + return err + } + // Check that the proposer address in the signed header matches the proposer address in the validator set if !bytes.Equal(sh.ProposerAddress, sh.Signer.Address) { return ErrProposerAddressMismatch @@ -263,3 +277,13 @@ func (sh *SignedHeader) ValidateBasicWithData(data *Data) error { return ErrSignatureVerificationFailed } + +func (sh *SignedHeader) validateSignerIdentity() error { + if sh.Signer.PubKey == nil { + return ErrSignerPubKeyMissing + } + if !bytes.Equal(KeyAddress(sh.Signer.PubKey), sh.Signer.Address) { + return ErrSignerAddressMismatch + } + return nil +} diff --git a/types/signed_header_test.go b/types/signed_header_test.go index c159e674cc..c89299964f 100644 --- a/types/signed_header_test.go +++ b/types/signed_header_test.go @@ -70,32 +70,16 @@ func testVerify(t *testing.T, trusted *SignedHeader, untrustedAdj *SignedHeader, }, err: nil, }, - // 4. Test proposer verification - // changes the proposed address to a random address - // Expect failure + // 4. Test proposer rotation at the header layer. + // Proposer authorization is state-owned, so header verification only + // checks the chain link and allows a different proposer address. { prepare: func() (*SignedHeader, bool) { untrusted := *untrustedAdj untrusted.ProposerAddress = GetRandomBytes(32) return &untrusted, true }, - err: &header.VerifyError{ - Reason: ErrProposerVerificationFailed, - }, - }, - // 5. Test proposer verification for non-adjacent headers - // changes the proposed address to a random address and updates height - // Expect failure - { - prepare: func() (*SignedHeader, bool) { - untrusted := *untrustedAdj - untrusted.ProposerAddress = GetRandomBytes(32) - untrusted.BaseHeader.Height++ - return &untrusted, true - }, - err: &header.VerifyError{ - Reason: ErrProposerVerificationFailed, - }, + err: nil, }, } diff --git a/types/state.go b/types/state.go index ccc383d79b..36ac2649cc 100644 --- a/types/state.go +++ b/types/state.go @@ -2,6 +2,7 @@ package types import ( "bytes" + "errors" "fmt" "sync" "time" @@ -16,6 +17,27 @@ var InitStateVersion = Version{ App: 0, } +// ErrUnexpectedProposer is returned when a block was signed by a proposer +// different from the proposer expected by the current state. +var ErrUnexpectedProposer = errors.New("unexpected proposer") + +var ( + // ErrInvalidChainID is returned when a header belongs to a different chain than the current state. + ErrInvalidChainID = errors.New("invalid chain ID") + + // ErrInvalidBlockHeight is returned when a header is not the next height after the current state. + ErrInvalidBlockHeight = errors.New("invalid block height") + + // ErrInvalidBlockTime is returned when a header time is earlier than the current state's block time. + ErrInvalidBlockTime = errors.New("invalid block time") + + // ErrInvalidLastHeaderHash is returned when a header does not link to the current state's last header. + ErrInvalidLastHeaderHash = errors.New("invalid last header hash") + + // ErrInvalidLastAppHash is returned when a header does not reference the current state's app hash. + ErrInvalidLastAppHash = errors.New("invalid last app hash") +) + // State contains information about current state of the blockchain. type State struct { Version Version @@ -37,26 +59,42 @@ type State struct { // the latest AppHash we've received from calling abci.Commit() AppHash []byte + + // NextProposerAddress is the proposer expected to sign LastBlockHeight+1. + // It is initialized from genesis and then updated from execution results. + NextProposerAddress []byte } -func (s *State) NextState(header Header, stateRoot []byte) (State, error) { +func (s *State) NextState(header Header, stateRoot []byte, nextProposerAddress ...[]byte) (State, error) { height := header.Height() + nextProposer := s.NextProposerAddress + if len(nextProposerAddress) > 0 && len(nextProposerAddress[0]) > 0 { + nextProposer = nextProposerAddress[0] + } + if len(nextProposer) == 0 { + nextProposer = header.ProposerAddress + } return State{ - Version: s.Version, - ChainID: s.ChainID, - InitialHeight: s.InitialHeight, - LastBlockHeight: height, - LastBlockTime: header.Time(), - AppHash: stateRoot, - LastHeaderHash: header.Hash(), - DAHeight: s.DAHeight, + Version: s.Version, + ChainID: s.ChainID, + InitialHeight: s.InitialHeight, + LastBlockHeight: height, + LastBlockTime: header.Time(), + AppHash: stateRoot, + LastHeaderHash: header.Hash(), + DAHeight: s.DAHeight, + NextProposerAddress: cloneBytes(nextProposer), }, nil } // AssertValidForNextState performs common validation of a header and data against the current state. // It assumes any context-specific basic header checks and verifier setup have already been performed func (s State) AssertValidForNextState(header *SignedHeader, data *Data) error { + if err := s.AssertExpectedProposer(header); err != nil { + return err + } + if err := s.AssertValidSequence(header); err != nil { return err } @@ -67,6 +105,14 @@ func (s State) AssertValidForNextState(header *SignedHeader, data *Data) error { return nil } +// AssertExpectedProposer checks that the header was signed by the proposer expected for the next block. +func (s State) AssertExpectedProposer(header *SignedHeader) error { + if len(s.NextProposerAddress) > 0 && !bytes.Equal(header.ProposerAddress, s.NextProposerAddress) { + return fmt.Errorf("%w - got: %x, want: %x", ErrUnexpectedProposer, header.ProposerAddress, s.NextProposerAddress) + } + return nil +} + var ( basedSequencerTracking sync.Once lastHeaderHashErrCount = 0 @@ -75,7 +121,7 @@ var ( // AssertValidSequence performs lightweight state-sequence validation for self-produced blocks. func (s State) AssertValidSequence(header *SignedHeader) error { if header.ChainID() != s.ChainID { - return fmt.Errorf("invalid chain ID - got %s, want %s", header.ChainID(), s.ChainID) + return fmt.Errorf("%w - got %s, want %s", ErrInvalidChainID, header.ChainID(), s.ChainID) } if len(s.LastHeaderHash) == 0 { // initial state @@ -83,11 +129,11 @@ func (s State) AssertValidSequence(header *SignedHeader) error { } if expdHeight := s.LastBlockHeight + 1; header.Height() != expdHeight { - return fmt.Errorf("invalid block height - got: %d, want: %d", header.Height(), expdHeight) + return fmt.Errorf("%w - got: %d, want: %d", ErrInvalidBlockHeight, header.Height(), expdHeight) } if headerTime := header.Time(); s.LastBlockTime.After(headerTime) { - return fmt.Errorf("invalid block time - got: %v, last: %v", headerTime, s.LastBlockTime) + return fmt.Errorf("%w - got: %v, last: %v", ErrInvalidBlockTime, headerTime, s.LastBlockTime) } // Trick to support the switch from a base sequencer to a normal syncing node. @@ -95,7 +141,7 @@ func (s State) AssertValidSequence(header *SignedHeader) error { // newly derived header hash when a base sequencer have switched to a syncing node if !bytes.Equal(header.LastHeaderHash, s.LastHeaderHash) { if lastHeaderHashErrCount == 1 { - return fmt.Errorf("invalid last header hash - got: %x, want: %x", header.LastHeaderHash, s.LastHeaderHash) + return fmt.Errorf("%w - got: %x, want: %x", ErrInvalidLastHeaderHash, header.LastHeaderHash, s.LastHeaderHash) } basedSequencerTracking.Do(func() { @@ -104,7 +150,7 @@ func (s State) AssertValidSequence(header *SignedHeader) error { } if !bytes.Equal(header.AppHash, s.AppHash) { - return fmt.Errorf("invalid last app hash - got: %x, want: %x", header.AppHash, s.AppHash) + return fmt.Errorf("%w - got: %x, want: %x", ErrInvalidLastAppHash, header.AppHash, s.AppHash) } return nil