diff --git a/apps/evm/go.mod b/apps/evm/go.mod index 2dcdda8469..a215b11b07 100644 --- a/apps/evm/go.mod +++ b/apps/evm/go.mod @@ -4,6 +4,7 @@ go 1.25.7 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/grpc/go.mod b/apps/grpc/go.mod index 66caa09cb6..64a32c143d 100644 --- a/apps/grpc/go.mod +++ b/apps/grpc/go.mod @@ -4,6 +4,7 @@ go 1.25.7 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/testapp/go.mod b/apps/testapp/go.mod index 652a4615c0..7285464eb2 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.7 -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/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..426961422d 100644 --- a/block/internal/common/replay.go +++ b/block/internal/common/replay.go @@ -152,11 +152,12 @@ func (s *Replayer) replayBlock(ctx context.Context, height uint64) error { if height == s.genesis.InitialHeight { // For the first block, use genesis state. prevState = types.State{ - ChainID: s.genesis.ChainID, - InitialHeight: s.genesis.InitialHeight, - LastBlockHeight: s.genesis.InitialHeight - 1, - LastBlockTime: s.genesis.StartTime, - AppHash: header.AppHash, // 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: append([]byte(nil), s.genesis.ProposerAddress...), } } else { // GetStateAtHeight(height-1) returns the state AFTER block height-1 was executed, @@ -179,10 +180,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 +214,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 +235,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..f5f4a829bf 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 { @@ -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, _ genesis.Genesis) ([]*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/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 d4fa93ce04..62405cf61d 100644 --- a/block/internal/syncing/da_retriever.go +++ b/block/internal/syncing/da_retriever.go @@ -299,11 +299,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 +350,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..c2786c36b0 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)) } diff --git a/block/internal/syncing/p2p_handler.go b/block/internal/syncing/p2p_handler.go index a3778757a1..87a8b6a093 100644 --- a/block/internal/syncing/p2p_handler.go +++ b/block/internal/syncing/p2p_handler.go @@ -81,10 +81,6 @@ 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") - return err - } p2pData, err := h.dataStore.GetByHeight(ctx, height) if err != nil { @@ -124,12 +120,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..c615c1f38d 100644 --- a/block/internal/syncing/syncer.go +++ b/block/internal/syncing/syncer.go @@ -320,14 +320,18 @@ 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.DAHeight != 0 && state.DAHeight < s.genesis.DAStartHeight { return fmt.Errorf("DA height (%d) is lower than DA start height (%d)", state.DAHeight, s.genesis.DAStartHeight) } @@ -398,6 +402,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") @@ -816,14 +832,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 +849,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,14 +867,14 @@ 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 diff --git a/block/internal/syncing/syncer_test.go b/block/internal/syncing/syncer_test.go index 67c87e06ed..2b4bce1aa6 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,58 @@ 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_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) @@ -936,7 +988,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/client/crates/types/src/proto/evnode.v1.messages.rs b/client/crates/types/src/proto/evnode.v1.messages.rs index 019046d0b7..e6038f54ce 100644 --- a/client/crates/types/src/proto/evnode.v1.messages.rs +++ b/client/crates/types/src/proto/evnode.v1.messages.rs @@ -76,6 +76,19 @@ pub struct SignedHeader { #[prost(message, optional, tag = "3")] pub signer: ::core::option::Option, } +/// DAHeaderEnvelope is a wrapper around SignedHeader for DA submission. +/// It is binary compatible with SignedHeader (fields 1-3) but adds an envelope signature. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DaHeaderEnvelope { + #[prost(message, optional, tag = "1")] + pub header: ::core::option::Option
, + #[prost(bytes = "vec", tag = "2")] + pub signature: ::prost::alloc::vec::Vec, + #[prost(message, optional, tag = "3")] + pub signer: ::core::option::Option, + #[prost(bytes = "vec", tag = "4")] + pub envelope_signature: ::prost::alloc::vec::Vec, +} /// Signer is a signer of a block in the blockchain. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct Signer { @@ -139,6 +152,28 @@ pub struct Vote { #[prost(bytes = "vec", tag = "5")] pub validator_address: ::prost::alloc::vec::Vec, } +/// P2PSignedHeader +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct P2pSignedHeader { + #[prost(message, optional, tag = "1")] + pub header: ::core::option::Option
, + #[prost(bytes = "vec", tag = "2")] + pub signature: ::prost::alloc::vec::Vec, + #[prost(message, optional, tag = "3")] + pub signer: ::core::option::Option, + #[prost(uint64, optional, tag = "4")] + pub da_height_hint: ::core::option::Option, +} +/// P2PData +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct P2pData { + #[prost(message, optional, tag = "1")] + pub metadata: ::core::option::Option, + #[prost(bytes = "vec", repeated, tag = "2")] + pub txs: ::prost::alloc::vec::Vec<::prost::alloc::vec::Vec>, + #[prost(uint64, optional, tag = "3")] + pub da_height_hint: ::core::option::Option, +} /// State is the state of the blockchain. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct State { @@ -158,6 +193,26 @@ 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)] +pub struct RaftBlockState { + #[prost(uint64, tag = "1")] + pub height: u64, + #[prost(uint64, tag = "2")] + pub last_submitted_da_header_height: u64, + #[prost(uint64, tag = "3")] + pub last_submitted_da_data_height: u64, + #[prost(bytes = "vec", tag = "4")] + pub hash: ::prost::alloc::vec::Vec, + #[prost(uint64, tag = "5")] + pub timestamp: u64, + #[prost(bytes = "vec", tag = "6")] + pub header: ::prost::alloc::vec::Vec, + #[prost(bytes = "vec", tag = "7")] + pub data: ::prost::alloc::vec::Vec, } /// SequencerDACheckpoint tracks the position in the DA where transactions were last processed #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] @@ -212,6 +267,17 @@ pub struct Batch { #[prost(bytes = "vec", repeated, tag = "1")] pub txs: ::prost::alloc::vec::Vec<::prost::alloc::vec::Vec>, } +/// BlockData contains data retrieved from a single DA height. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BlockData { + #[prost(uint64, tag = "1")] + pub height: u64, + /// Unix timestamp in nanoseconds + #[prost(int64, tag = "2")] + pub timestamp: i64, + #[prost(bytes = "vec", repeated, tag = "3")] + pub blobs: ::prost::alloc::vec::Vec<::prost::alloc::vec::Vec>, +} /// InitChainRequest contains the genesis parameters for chain initialization #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct InitChainRequest { @@ -231,9 +297,6 @@ pub struct InitChainResponse { /// Hash representing initial state #[prost(bytes = "vec", tag = "1")] pub state_root: ::prost::alloc::vec::Vec, - /// Maximum allowed bytes for transactions in a block - #[prost(uint64, tag = "2")] - pub max_bytes: u64, } /// GetTxsRequest is the request for fetching transactions /// @@ -272,6 +335,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)] @@ -285,6 +352,77 @@ pub struct SetFinalRequest { /// Empty response, errors are returned via gRPC status #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct SetFinalResponse {} +/// GetExecutionInfoRequest requests execution layer parameters +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GetExecutionInfoRequest {} +/// GetExecutionInfoResponse contains execution layer parameters +#[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)] +pub struct FilterTxsRequest { + /// All transactions (force-included + mempool) + #[prost(bytes = "vec", repeated, tag = "1")] + pub txs: ::prost::alloc::vec::Vec<::prost::alloc::vec::Vec>, + /// Maximum cumulative size allowed (0 means no size limit) + #[prost(uint64, tag = "2")] + pub max_bytes: u64, + /// Maximum cumulative gas allowed (0 means no gas limit) + #[prost(uint64, tag = "3")] + pub max_gas: u64, + /// Whether force-included transactions are present + #[prost(bool, tag = "4")] + pub has_force_included_transaction: bool, +} +/// FilterTxsResponse contains the filter status for each transaction +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct FilterTxsResponse { + /// Filter status for each transaction (same length as txs in request) + #[prost(enumeration = "FilterStatus", repeated, tag = "1")] + pub statuses: ::prost::alloc::vec::Vec, +} +/// FilterStatus represents the result of filtering a transaction +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum FilterStatus { + /// Transaction will make it to the next batch + FilterOk = 0, + /// Transaction will be filtered out because invalid (too big, malformed, etc.) + FilterRemove = 1, + /// Transaction is valid but postponed for later processing due to size/gas constraint + FilterPostpone = 2, +} +impl FilterStatus { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::FilterOk => "FILTER_OK", + Self::FilterRemove => "FILTER_REMOVE", + Self::FilterPostpone => "FILTER_POSTPONE", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "FILTER_OK" => Some(Self::FilterOk), + "FILTER_REMOVE" => Some(Self::FilterRemove), + "FILTER_POSTPONE" => Some(Self::FilterPostpone), + _ => None, + } + } +} /// Block contains all the components of a complete block #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct Block { diff --git a/client/crates/types/src/proto/evnode.v1.services.rs b/client/crates/types/src/proto/evnode.v1.services.rs index b34ae918b7..013e96db37 100644 --- a/client/crates/types/src/proto/evnode.v1.services.rs +++ b/client/crates/types/src/proto/evnode.v1.services.rs @@ -450,6 +450,19 @@ pub struct SignedHeader { #[prost(message, optional, tag = "3")] pub signer: ::core::option::Option, } +/// DAHeaderEnvelope is a wrapper around SignedHeader for DA submission. +/// It is binary compatible with SignedHeader (fields 1-3) but adds an envelope signature. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DaHeaderEnvelope { + #[prost(message, optional, tag = "1")] + pub header: ::core::option::Option
, + #[prost(bytes = "vec", tag = "2")] + pub signature: ::prost::alloc::vec::Vec, + #[prost(message, optional, tag = "3")] + pub signer: ::core::option::Option, + #[prost(bytes = "vec", tag = "4")] + pub envelope_signature: ::prost::alloc::vec::Vec, +} /// Signer is a signer of a block in the blockchain. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct Signer { @@ -513,6 +526,28 @@ pub struct Vote { #[prost(bytes = "vec", tag = "5")] pub validator_address: ::prost::alloc::vec::Vec, } +/// P2PSignedHeader +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct P2pSignedHeader { + #[prost(message, optional, tag = "1")] + pub header: ::core::option::Option
, + #[prost(bytes = "vec", tag = "2")] + pub signature: ::prost::alloc::vec::Vec, + #[prost(message, optional, tag = "3")] + pub signer: ::core::option::Option, + #[prost(uint64, optional, tag = "4")] + pub da_height_hint: ::core::option::Option, +} +/// P2PData +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct P2pData { + #[prost(message, optional, tag = "1")] + pub metadata: ::core::option::Option, + #[prost(bytes = "vec", repeated, tag = "2")] + pub txs: ::prost::alloc::vec::Vec<::prost::alloc::vec::Vec>, + #[prost(uint64, optional, tag = "3")] + pub da_height_hint: ::core::option::Option, +} /// State is the state of the blockchain. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct State { @@ -532,6 +567,26 @@ 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)] +pub struct RaftBlockState { + #[prost(uint64, tag = "1")] + pub height: u64, + #[prost(uint64, tag = "2")] + pub last_submitted_da_header_height: u64, + #[prost(uint64, tag = "3")] + pub last_submitted_da_data_height: u64, + #[prost(bytes = "vec", tag = "4")] + pub hash: ::prost::alloc::vec::Vec, + #[prost(uint64, tag = "5")] + pub timestamp: u64, + #[prost(bytes = "vec", tag = "6")] + pub header: ::prost::alloc::vec::Vec, + #[prost(bytes = "vec", tag = "7")] + pub data: ::prost::alloc::vec::Vec, } /// SequencerDACheckpoint tracks the position in the DA where transactions were last processed #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] @@ -957,6 +1012,17 @@ pub struct Batch { #[prost(bytes = "vec", repeated, tag = "1")] pub txs: ::prost::alloc::vec::Vec<::prost::alloc::vec::Vec>, } +/// BlockData contains data retrieved from a single DA height. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BlockData { + #[prost(uint64, tag = "1")] + pub height: u64, + /// Unix timestamp in nanoseconds + #[prost(int64, tag = "2")] + pub timestamp: i64, + #[prost(bytes = "vec", repeated, tag = "3")] + pub blobs: ::prost::alloc::vec::Vec<::prost::alloc::vec::Vec>, +} /// InitChainRequest contains the genesis parameters for chain initialization #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct InitChainRequest { @@ -976,9 +1042,6 @@ pub struct InitChainResponse { /// Hash representing initial state #[prost(bytes = "vec", tag = "1")] pub state_root: ::prost::alloc::vec::Vec, - /// Maximum allowed bytes for transactions in a block - #[prost(uint64, tag = "2")] - pub max_bytes: u64, } /// GetTxsRequest is the request for fetching transactions /// @@ -1017,6 +1080,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)] @@ -1030,6 +1097,77 @@ pub struct SetFinalRequest { /// Empty response, errors are returned via gRPC status #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct SetFinalResponse {} +/// GetExecutionInfoRequest requests execution layer parameters +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GetExecutionInfoRequest {} +/// GetExecutionInfoResponse contains execution layer parameters +#[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)] +pub struct FilterTxsRequest { + /// All transactions (force-included + mempool) + #[prost(bytes = "vec", repeated, tag = "1")] + pub txs: ::prost::alloc::vec::Vec<::prost::alloc::vec::Vec>, + /// Maximum cumulative size allowed (0 means no size limit) + #[prost(uint64, tag = "2")] + pub max_bytes: u64, + /// Maximum cumulative gas allowed (0 means no gas limit) + #[prost(uint64, tag = "3")] + pub max_gas: u64, + /// Whether force-included transactions are present + #[prost(bool, tag = "4")] + pub has_force_included_transaction: bool, +} +/// FilterTxsResponse contains the filter status for each transaction +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct FilterTxsResponse { + /// Filter status for each transaction (same length as txs in request) + #[prost(enumeration = "FilterStatus", repeated, tag = "1")] + pub statuses: ::prost::alloc::vec::Vec, +} +/// FilterStatus represents the result of filtering a transaction +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum FilterStatus { + /// Transaction will make it to the next batch + FilterOk = 0, + /// Transaction will be filtered out because invalid (too big, malformed, etc.) + FilterRemove = 1, + /// Transaction is valid but postponed for later processing due to size/gas constraint + FilterPostpone = 2, +} +impl FilterStatus { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::FilterOk => "FILTER_OK", + Self::FilterRemove => "FILTER_REMOVE", + Self::FilterPostpone => "FILTER_POSTPONE", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "FILTER_OK" => Some(Self::FilterOk), + "FILTER_REMOVE" => Some(Self::FilterRemove), + "FILTER_POSTPONE" => Some(Self::FilterPostpone), + _ => None, + } + } +} /// Generated client implementations. pub mod executor_service_client { #![allow( @@ -1219,6 +1357,58 @@ pub mod executor_service_client { .insert(GrpcMethod::new("evnode.v1.ExecutorService", "SetFinal")); self.inner.unary(req, path, codec).await } + /// GetExecutionInfo returns current execution layer parameters + pub async fn get_execution_info( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/evnode.v1.ExecutorService/GetExecutionInfo", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new("evnode.v1.ExecutorService", "GetExecutionInfo"), + ); + self.inner.unary(req, path, codec).await + } + /// FilterTxs validates force-included transactions and calculates gas for all transactions + pub async fn filter_txs( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/evnode.v1.ExecutorService/FilterTxs", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("evnode.v1.ExecutorService", "FilterTxs")); + self.inner.unary(req, path, codec).await + } } } /// Generated server implementations. @@ -1263,6 +1453,22 @@ pub mod executor_service_server { tonic::Response, tonic::Status, >; + /// GetExecutionInfo returns current execution layer parameters + async fn get_execution_info( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + /// FilterTxs validates force-included transactions and calculates gas for all transactions + async fn filter_txs( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; } /// ExecutorService defines the execution layer interface for EVNode #[derive(Debug)] @@ -1521,6 +1727,97 @@ pub mod executor_service_server { }; Box::pin(fut) } + "/evnode.v1.ExecutorService/GetExecutionInfo" => { + #[allow(non_camel_case_types)] + struct GetExecutionInfoSvc(pub Arc); + impl< + T: ExecutorService, + > tonic::server::UnaryService + for GetExecutionInfoSvc { + type Response = super::GetExecutionInfoResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_execution_info(&inner, request) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = GetExecutionInfoSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/evnode.v1.ExecutorService/FilterTxs" => { + #[allow(non_camel_case_types)] + struct FilterTxsSvc(pub Arc); + impl< + T: ExecutorService, + > tonic::server::UnaryService + for FilterTxsSvc { + type Response = super::FilterTxsResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::filter_txs(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = FilterTxsSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } _ => { Box::pin(async move { let mut response = http::Response::new( 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 f3ebe1da91..477407e1da 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,21 @@ 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 proposer address that should sign the next + // block from the execution layer's current view. Empty means unchanged or + // unavailable, and callers should fall back to their current 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 proposer address selected by execution for the + // next block. Empty means the current proposer remains active. + 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/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/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/execution.go b/execution/evm/execution.go index 3067893360..ecca8bd642 100644 --- a/execution/evm/execution.go +++ b/execution/evm/execution.go @@ -344,7 +344,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 +353,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 execution.ExecuteResult{UpdatedStateRoot: stateRoot}, nil } 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 execution.ExecuteResult{UpdatedStateRoot: stateRoot}, nil } } 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 +449,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 +457,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 execution.ExecuteResult{UpdatedStateRoot: stateRoot}, nil } // setHead updates the head block hash without changing safe or finalized. diff --git a/execution/evm/go.mod b/execution/evm/go.mod index 5a014af738..17b6831d67 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.7 +replace ( + github.com/evstack/ev-node => ../../ + github.com/evstack/ev-node/core => ../../core +) + require ( github.com/ethereum/go-ethereum v1.17.2 github.com/evstack/ev-node v1.1.0 diff --git a/execution/evm/test/go.mod b/execution/evm/test/go.mod index 78b3552949..23aadab045 100644 --- a/execution/evm/test/go.mod +++ b/execution/evm/test/go.mod @@ -199,5 +199,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/grpc/client.go b/execution/grpc/client.go index efb9d2f840..fcc805a1c2 100644 --- a/execution/grpc/client.go +++ b/execution/grpc/client.go @@ -99,7 +99,7 @@ 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) { req := connect.NewRequest(&pb.ExecuteTxsRequest{ Txs: txs, BlockHeight: blockHeight, @@ -109,10 +109,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. @@ -145,7 +148,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 59ec6416ff..3a9477823d 100644 --- a/execution/grpc/client_test.go +++ b/execution/grpc/client_test.go @@ -13,7 +13,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) @@ -33,11 +33,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 { @@ -151,7 +151,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)) } @@ -164,7 +164,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 }, } @@ -177,13 +177,13 @@ func TestClient_ExecuteTxs(t *testing.T) { client := NewClient(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 2312dd9d25..7817bb8f91 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.19.2 connectrpc.com/grpcreflect v1.3.0 diff --git a/execution/grpc/server.go b/execution/grpc/server.go index e0488b7655..1123d60fe7 100644 --- a/execution/grpc/server.go +++ b/execution/grpc/server.go @@ -102,7 +102,7 @@ func (s *Server) ExecuteTxs( return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("prev_state_root is required")) } - updatedStateRoot, err := s.executor.ExecuteTxs( + result, err := s.executor.ExecuteTxs( ctx, req.Msg.Txs, req.Msg.BlockHeight, @@ -114,7 +114,8 @@ func (s *Server) ExecuteTxs( } return connect.NewResponse(&pb.ExecuteTxsResponse{ - UpdatedStateRoot: updatedStateRoot, + UpdatedStateRoot: result.UpdatedStateRoot, + NextProposerAddress: result.NextProposerAddress, }), nil } @@ -150,7 +151,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 e2a01b4bc4..559d8457b2 100644 --- a/execution/grpc/server_test.go +++ b/execution/grpc/server_test.go @@ -9,6 +9,7 @@ import ( "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/evstack/ev-node/core/execution" pb "github.com/evstack/ev-node/types/pb/evnode/v1" ) @@ -190,7 +191,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 }{ @@ -202,8 +203,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, }, @@ -245,8 +246,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/go.mod b/go.mod index 2b517a48af..37b153773f 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.18.2 // indirect 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/pkg/rpc/server/server.go b/pkg/rpc/server/server.go index 419f8b6631..24eb133124 100644 --- a/pkg/rpc/server/server.go +++ b/pkg/rpc/server/server.go @@ -142,11 +142,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..a86f234998 100644 --- a/proto/evnode/v1/evnode.proto +++ b/proto/evnode/v1/evnode.proto @@ -38,8 +38,7 @@ message Header { bytes validator_hash = 11; // Chain ID the block belongs to string chain_id = 12; - - reserved 5, 7, 9; + reserved 5, 7, 9, 13; } // SignedHeader is a header with a signature and a signer. diff --git a/proto/evnode/v1/execution.proto b/proto/evnode/v1/execution.proto index a3abbea36a..13d19db336 100644 --- a/proto/evnode/v1/execution.proto +++ b/proto/evnode/v1/execution.proto @@ -77,6 +77,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 @@ -98,6 +102,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/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/go.mod b/test/e2e/go.mod index 9ffb941fe7..6d58d40d17 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/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/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/evnode.pb.go b/types/pb/evnode/v1/evnode.pb.go index b0a866e76e..d98acd10a2 100644 --- a/types/pb/evnode/v1/evnode.pb.go +++ b/types/pb/evnode/v1/evnode.pb.go @@ -792,7 +792,7 @@ const file_evnode_v1_evnode_proto_rawDesc = "" + "\x16evnode/v1/evnode.proto\x12\tevnode.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"1\n" + "\aVersion\x12\x14\n" + "\x05block\x18\x01 \x01(\x04R\x05block\x12\x10\n" + - "\x03app\x18\x02 \x01(\x04R\x03app\"\xc3\x02\n" + + "\x03app\x18\x02 \x01(\x04R\x03app\"\xc9\x02\n" + "\x06Header\x12,\n" + "\aversion\x18\x01 \x01(\v2\x12.evnode.v1.VersionR\aversion\x12\x16\n" + "\x06height\x18\x02 \x01(\x04R\x06height\x12\x12\n" + @@ -804,7 +804,7 @@ const file_evnode_v1_evnode_proto_rawDesc = "" + " \x01(\fR\x0fproposerAddress\x12%\n" + "\x0evalidator_hash\x18\v \x01(\fR\rvalidatorHash\x12\x19\n" + "\bchain_id\x18\f \x01(\tR\achainIdJ\x04\b\x05\x10\x06J\x04\b\a\x10\bJ\x04\b\t\x10\n" + - "\"\x88\x01\n" + + "J\x04\b\r\x10\x0e\"\x88\x01\n" + "\fSignedHeader\x12)\n" + "\x06header\x18\x01 \x01(\v2\x11.evnode.v1.HeaderR\x06header\x12\x1c\n" + "\tsignature\x18\x02 \x01(\fR\tsignature\x12)\n" + diff --git a/types/pb/evnode/v1/execution.pb.go b/types/pb/evnode/v1/execution.pb.go index 2b33c910d2..86d2ae8031 100644 --- a/types/pb/evnode/v1/execution.pb.go +++ b/types/pb/evnode/v1/execution.pb.go @@ -347,9 +347,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() { @@ -396,6 +399,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"` @@ -521,9 +531,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() { @@ -563,6 +576,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"` @@ -701,16 +721,18 @@ const file_evnode_v1_execution_proto_rawDesc = "" + "\x03txs\x18\x01 \x03(\fR\x03txs\x12!\n" + "\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\"_\n" + + "\x0fprev_state_root\x18\x04 \x01(\fR\rprevStateRoot\"\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\"\x9f\x01\n" + + "\amax_gas\x18\x01 \x01(\x04R\x06maxGas\x122\n" + + "\x15next_proposer_address\x18\x02 \x01(\fR\x13nextProposerAddress\"\x9f\x01\n" + "\x10FilterTxsRequest\x12\x10\n" + "\x03txs\x18\x01 \x03(\fR\x03txs\x12\x1b\n" + "\tmax_bytes\x18\x02 \x01(\x04R\bmaxBytes\x12\x17\n" + 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_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..10f11a51ae 100644 --- a/types/state.go +++ b/types/state.go @@ -37,20 +37,32 @@ 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 } @@ -64,6 +76,9 @@ func (s State) AssertValidForNextState(header *SignedHeader, data *Data) error { if err := Validate(header, data); err != nil { return fmt.Errorf("header-data validation failed: %w", err) } + if len(s.NextProposerAddress) > 0 && !bytes.Equal(header.ProposerAddress, s.NextProposerAddress) { + return fmt.Errorf("unexpected proposer - got: %x, want: %x", header.ProposerAddress, s.NextProposerAddress) + } return nil }