Skip to content

Commit 6b2db4b

Browse files
committed
revert header change
1 parent 8db31f9 commit 6b2db4b

14 files changed

Lines changed: 48 additions & 165 deletions

File tree

block/internal/common/replay.go

Lines changed: 6 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -185,19 +185,10 @@ func (s *Replayer) replayBlock(ctx context.Context, height uint64) error {
185185
return fmt.Errorf("failed to execute transactions: %w", err)
186186
}
187187
newAppHash := result.UpdatedStateRoot
188-
if len(result.NextProposerAddress) > 0 {
189-
if len(header.NextProposerAddress) == 0 {
190-
return fmt.Errorf("next proposer mismatch at height %d: header empty, execution %x", height, result.NextProposerAddress)
191-
}
192-
if !bytes.Equal(header.NextProposerAddress, result.NextProposerAddress) {
193-
return fmt.Errorf("next proposer mismatch at height %d: header %x, execution %x",
194-
height,
195-
header.NextProposerAddress,
196-
result.NextProposerAddress,
197-
)
198-
}
199-
} else if len(header.NextProposerAddress) > 0 && !bytes.Equal(header.NextProposerAddress, header.ProposerAddress) {
200-
return fmt.Errorf("next proposer mismatch at height %d: header %x, execution unchanged", height, header.NextProposerAddress)
188+
189+
newState, err := prevState.NextState(header.Header, newAppHash, result.NextProposerAddress)
190+
if err != nil {
191+
return fmt.Errorf("calculate next state: %w", err)
201192
}
202193

203194
// The result of ExecuteTxs (newAppHash) should match the stored state at this height.
@@ -224,18 +215,11 @@ func (s *Replayer) replayBlock(ctx context.Context, height uint64) error {
224215
return err
225216
}
226217
if len(expectedState.NextProposerAddress) > 0 {
227-
expectedNextProposer := header.NextProposerAddress
228-
if len(expectedNextProposer) == 0 {
229-
expectedNextProposer = result.NextProposerAddress
230-
}
231-
if len(expectedNextProposer) == 0 {
232-
expectedNextProposer = header.ProposerAddress
233-
}
234-
if !bytes.Equal(expectedNextProposer, expectedState.NextProposerAddress) {
218+
if !bytes.Equal(newState.NextProposerAddress, expectedState.NextProposerAddress) {
235219
return fmt.Errorf("next proposer mismatch at height %d: expected %x got %x",
236220
height,
237221
expectedState.NextProposerAddress,
238-
expectedNextProposer,
222+
newState.NextProposerAddress,
239223
)
240224
}
241225
}
@@ -251,12 +235,6 @@ func (s *Replayer) replayBlock(ctx context.Context, height uint64) error {
251235
Msg("replayBlock: ExecuteTxs completed (no stored state to verify against)")
252236
}
253237

254-
// Calculate new state
255-
newState, err := prevState.NextState(header.Header, newAppHash)
256-
if err != nil {
257-
return fmt.Errorf("calculate next state: %w", err)
258-
}
259-
260238
// Persist the new state
261239
batch, err := s.store.NewBatch(ctx)
262240
if err != nil {

block/internal/executing/executor.go

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -572,13 +572,6 @@ func (e *Executor) ProduceBlock(ctx context.Context) error {
572572
if err != nil {
573573
return fmt.Errorf("failed to apply block: %w", err)
574574
}
575-
if !bytes.Equal(newState.NextProposerAddress, header.ProposerAddress) {
576-
header.NextProposerAddress = append([]byte(nil), newState.NextProposerAddress...)
577-
header.InvalidateHash()
578-
} else if len(header.NextProposerAddress) > 0 {
579-
header.NextProposerAddress = nil
580-
header.InvalidateHash()
581-
}
582575

583576
// set the DA height in the sequencer
584577
newState.DAHeight = e.sequencer.GetDAHeight()
@@ -861,19 +854,9 @@ func (e *Executor) ApplyBlock(ctx context.Context, header types.Header, data *ty
861854
e.sendCriticalError(fmt.Errorf("failed to execute transactions: %w", err))
862855
return types.State{}, fmt.Errorf("failed to execute transactions: %w", err)
863856
}
864-
if len(result.NextProposerAddress) > 0 {
865-
if len(header.NextProposerAddress) == 0 {
866-
header.NextProposerAddress = append([]byte(nil), result.NextProposerAddress...)
867-
} else if !bytes.Equal(header.NextProposerAddress, result.NextProposerAddress) {
868-
return types.State{}, fmt.Errorf("next proposer mismatch: header %x, execution %x", header.NextProposerAddress, result.NextProposerAddress)
869-
}
870-
header.InvalidateHash()
871-
} else if len(header.NextProposerAddress) > 0 && !bytes.Equal(header.NextProposerAddress, header.ProposerAddress) {
872-
return types.State{}, fmt.Errorf("next proposer mismatch: header %x, execution unchanged", header.NextProposerAddress)
873-
}
874857

875858
// Create new state
876-
newState, err := currentState.NextState(header, result.UpdatedStateRoot)
859+
newState, err := currentState.NextState(header, result.UpdatedStateRoot, result.NextProposerAddress)
877860
if err != nil {
878861
return types.State{}, fmt.Errorf("failed to create next state: %w", err)
879862
}

block/internal/executing/executor_logic_test.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,13 @@ func TestProduceBlock_EmptyBatch_SetsEmptyDataHash(t *testing.T) {
6969
require.NoError(t, err)
7070
assert.Equal(t, 0, len(data.Txs))
7171
assert.EqualValues(t, common.DataHashForEmptyTxs, sh.DataHash)
72-
assert.Empty(t, sh.NextProposerAddress)
7372

7473
state, err := fx.MemStore.GetState(context.Background())
7574
require.NoError(t, err)
7675
assert.Equal(t, fx.Exec.genesis.ProposerAddress, state.NextProposerAddress)
7776
}
7877

79-
func TestProduceBlock_CommitsExecutionNextProposer(t *testing.T) {
78+
func TestProduceBlock_PersistsExecutionNextProposer(t *testing.T) {
8079
fx := setupTestExecutor(t, 1000)
8180
defer fx.Cancel()
8281

@@ -100,7 +99,6 @@ func TestProduceBlock_CommitsExecutionNextProposer(t *testing.T) {
10099
header, data, err := fx.MemStore.GetBlockData(context.Background(), 1)
101100
require.NoError(t, err)
102101
require.NoError(t, header.ValidateBasicWithData(data))
103-
assert.Equal(t, nextAddr, header.NextProposerAddress)
104102

105103
state, err := fx.MemStore.GetState(context.Background())
106104
require.NoError(t, err)

block/internal/syncing/syncer.go

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -837,19 +837,9 @@ func (s *Syncer) ApplyBlock(ctx context.Context, header types.Header, data *type
837837
s.sendCriticalError(fmt.Errorf("failed to execute transactions: %w", err))
838838
return types.State{}, fmt.Errorf("failed to execute transactions: %w", err)
839839
}
840-
if len(result.NextProposerAddress) > 0 {
841-
if len(header.NextProposerAddress) == 0 {
842-
return types.State{}, fmt.Errorf("next proposer mismatch: header empty, execution %x", result.NextProposerAddress)
843-
}
844-
if !bytes.Equal(header.NextProposerAddress, result.NextProposerAddress) {
845-
return types.State{}, fmt.Errorf("next proposer mismatch: header %x, execution %x", header.NextProposerAddress, result.NextProposerAddress)
846-
}
847-
} else if len(header.NextProposerAddress) > 0 && !bytes.Equal(header.NextProposerAddress, header.ProposerAddress) {
848-
return types.State{}, fmt.Errorf("next proposer mismatch: header %x, execution unchanged", header.NextProposerAddress)
849-
}
850840

851841
// Create new state
852-
newState, err := currentState.NextState(header, result.UpdatedStateRoot)
842+
newState, err := currentState.NextState(header, result.UpdatedStateRoot, result.NextProposerAddress)
853843
if err != nil {
854844
return types.State{}, fmt.Errorf("failed to create next state: %w", err)
855845
}

block/internal/syncing/syncer_test.go

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -214,17 +214,15 @@ func TestSyncer_ValidateBlock_UsesStateNextProposer(t *testing.T) {
214214
require.Contains(t, err.Error(), "unexpected proposer")
215215
}
216216

217-
func TestSyncer_ApplyBlockRejectsExecutionNextProposerMismatch(t *testing.T) {
217+
func TestSyncer_ApplyBlockPersistsExecutionNextProposer(t *testing.T) {
218218
addr, _, _ := buildSyncTestSigner(t)
219-
headerNext := []byte("header-next-proposer")
220219
execNext := []byte("execution-next-proposer")
221220

222221
mockExec := testmocks.NewMockExecutor(t)
223222
data := makeData("tchain", 1, 1)
224223
header := types.Header{
225-
BaseHeader: types.BaseHeader{ChainID: "tchain", Height: 1, Time: uint64(time.Now().UnixNano())},
226-
ProposerAddress: addr,
227-
NextProposerAddress: headerNext,
224+
BaseHeader: types.BaseHeader{ChainID: "tchain", Height: 1, Time: uint64(time.Now().UnixNano())},
225+
ProposerAddress: addr,
228226
}
229227
currentState := types.State{AppHash: []byte("app0"), NextProposerAddress: addr}
230228

@@ -240,9 +238,9 @@ func TestSyncer_ApplyBlockRejectsExecutionNextProposerMismatch(t *testing.T) {
240238
logger: zerolog.Nop(),
241239
}
242240

243-
_, err := s.ApplyBlock(t.Context(), header, data, currentState)
244-
require.Error(t, err)
245-
require.Contains(t, err.Error(), "next proposer mismatch")
241+
newState, err := s.ApplyBlock(t.Context(), header, data, currentState)
242+
require.NoError(t, err)
243+
require.Equal(t, execNext, newState.NextProposerAddress)
246244
}
247245

248246
func TestProcessHeightEvent_SyncsAndUpdatesState(t *testing.T) {

client/crates/types/src/proto/evnode.v1.messages.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,6 @@ pub struct Header {
6565
/// Chain ID the block belongs to
6666
#[prost(string, tag = "12")]
6767
pub chain_id: ::prost::alloc::string::String,
68-
/// Proposer address selected by this block's execution result for the next block.
69-
#[prost(bytes = "vec", tag = "13")]
70-
pub next_proposer_address: ::prost::alloc::vec::Vec<u8>,
7168
}
7269
/// SignedHeader is a header with a signature and a signer.
7370
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]

client/crates/types/src/proto/evnode.v1.services.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -439,9 +439,6 @@ pub struct Header {
439439
/// Chain ID the block belongs to
440440
#[prost(string, tag = "12")]
441441
pub chain_id: ::prost::alloc::string::String,
442-
/// Proposer address selected by this block's execution result for the next block.
443-
#[prost(bytes = "vec", tag = "13")]
444-
pub next_proposer_address: ::prost::alloc::vec::Vec<u8>,
445442
}
446443
/// SignedHeader is a header with a signature and a signer.
447444
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]

docs/adr/adr-023-execution-owned-proposer-rotation.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,11 @@ An empty `NextProposerAddress` from `ExecuteTxs` means the proposer is unchanged
2727

2828
When execution returns a non-empty next proposer:
2929

30-
- The producing node commits it to `Header.NextProposerAddress` before signing the header.
31-
- Syncing nodes require the signed header value to match the execution result.
3230
- `State.NextProposerAddress` is updated and used as the expected signer for `LastBlockHeight + 1`.
31+
- Full nodes validate the next block signer against the previous state's `NextProposerAddress`.
32+
- Header encoding remains unchanged. `Header.ProposerAddress` continues to identify the signer of the current block only.
3333

34-
`Header.NextProposerAddress` lets header-only paths and DA envelope validation see proposer transitions without replaying execution first. The execution result remains the authority; mismatches between the signed header and execution are invalid.
34+
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.
3535

3636
## EVM System Contract Model
3737

@@ -47,7 +47,7 @@ The security council or multisig becomes the authority for proposer updates. It
4747

4848
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.
4949

50-
ev-node validates the execution output against the signed header. A malicious proposer cannot advertise one next proposer in the header while execution derives another.
50+
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.
5151

5252
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.
5353

@@ -60,13 +60,14 @@ Positive:
6060
- Proposer rotation becomes deterministic execution state.
6161
- EVM chains can use a system contract and multisig-controlled rotation.
6262
- Existing chains keep working when execution returns an empty proposer.
63-
- Header verification can follow rotations once the rotating block is known.
63+
- Existing header encoding remains compatible because no new header field is required.
6464

6565
Negative:
6666

6767
- The execution API changes and all execution adapters must return `ExecuteResult`.
6868
- Proposer updates become consensus-critical execution outputs.
6969
- ev-reth needs a separate system-contract design and implementation.
70+
- Header-only/light-client paths cannot follow proposer rotation without execution replay or a later proof design.
7071

7172
## Alternatives Considered
7273

@@ -78,6 +79,6 @@ Node-local proposer configuration:
7879

7980
- Rejected. Nodes could disagree about the active proposer unless every operator updates configuration at the same time.
8081

81-
Execution-only proposer without header commitment:
82+
Header commitment for next proposer:
8283

83-
- Rejected. Syncing nodes can replay execution, but header and DA envelope paths benefit from having the selected next proposer committed in the signed header when it changes.
84+
- 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.

proto/evnode/v1/evnode.proto

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,7 @@ message Header {
3838
bytes validator_hash = 11;
3939
// Chain ID the block belongs to
4040
string chain_id = 12;
41-
// Proposer address selected by this block's execution result for the next block.
42-
bytes next_proposer_address = 13;
43-
44-
reserved 5, 7, 9;
41+
reserved 5, 7, 9, 13;
4542
}
4643

4744
// SignedHeader is a header with a signature and a signer.

types/header.go

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package types
22

33
import (
4-
"bytes"
54
"context"
65
"encoding"
76
"errors"
@@ -43,7 +42,8 @@ var (
4342
// ErrNoProposerAddress is returned when the proposer address is not set.
4443
ErrNoProposerAddress = errors.New("no proposer address")
4544

46-
// ErrProposerVerificationFailed is returned when the proposer verification fails.
45+
// ErrProposerVerificationFailed is deprecated. Proposer authorization is
46+
// enforced through State validation because proposer rotation is execution-owned.
4747
ErrProposerVerificationFailed = errors.New("proposer verification failed")
4848

4949
// ErrInvalidTimestamp is returned when the timestamp is invalid.
@@ -82,11 +82,6 @@ type Header struct {
8282
// pubkey can't be recovered by the signature (e.g. ed25519).
8383
ProposerAddress []byte // original proposer of the block
8484

85-
// NextProposerAddress is selected by executing this block and becomes the
86-
// proposer expected for the next block. Empty means the current proposer
87-
// remains active.
88-
NextProposerAddress []byte
89-
9085
// Legacy holds fields that were removed from the canonical header JSON/Go
9186
// representation but may still be required for backwards compatible binary
9287
// serialization (e.g. legacy signing payloads).
@@ -129,19 +124,9 @@ func (h *Header) Time() time.Time {
129124

130125
// Verify verifies the header.
131126
func (h *Header) Verify(untrstH *Header) error {
132-
expectedProposer := h.ProposerAddress
133-
if len(h.NextProposerAddress) > 0 {
134-
expectedProposer = h.NextProposerAddress
135-
}
136-
if !bytes.Equal(untrstH.ProposerAddress, expectedProposer) {
137-
return &header.VerifyError{
138-
Reason: fmt.Errorf("%w: expected proposer (%X) got (%X)",
139-
ErrProposerVerificationFailed,
140-
expectedProposer,
141-
untrstH.ProposerAddress,
142-
),
143-
}
144-
}
127+
// Proposer rotation is execution/state-owned. The trusted header alone no
128+
// longer contains enough information to authorize the signer of the next
129+
// header, so full nodes enforce proposer validity through State validation.
145130
return nil
146131
}
147132

@@ -279,7 +264,6 @@ func (h Header) Clone() Header {
279264
clone.AppHash = cloneBytes(h.AppHash)
280265
clone.ValidatorHash = cloneBytes(h.ValidatorHash)
281266
clone.ProposerAddress = cloneBytes(h.ProposerAddress)
282-
clone.NextProposerAddress = cloneBytes(h.NextProposerAddress)
283267
clone.Legacy = h.Legacy.Clone()
284268
clone.cachedHash = nil
285269

0 commit comments

Comments
 (0)