Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
da7b970
add key rotation
tac0turtle Apr 23, 2026
6a0f367
remove need for pubkey and add some tests
tac0turtle Apr 23, 2026
6ad13f2
comments and amendments
tac0turtle Apr 23, 2026
45a7d75
commen changes
tac0turtle Apr 24, 2026
8db31f9
redo the design
tac0turtle Apr 24, 2026
6b2db4b
revert header change
tac0turtle Apr 24, 2026
656944d
Merge branch 'main' into marko/key_rotation
tac0turtle Apr 27, 2026
2fbd381
deps
Apr 27, 2026
e505b36
fix docker and proto
Apr 27, 2026
7b4011c
ammendments from commnets
Apr 27, 2026
68997e7
linting
Apr 27, 2026
04f6b12
test: cover unexpected DA proposer rejection
randygrok May 5, 2026
0b7bb5a
fix: resolve key rotation conflicts with main (#3313)
randygrok May 8, 2026
da43b26
Merge branch 'main' into marko/key_rotation
julienrbrt May 8, 2026
e8ce3b9
fix merge
julienrbrt May 8, 2026
c811388
Merge branch 'main' into marko/key_rotation
randygrok May 12, 2026
3d349ed
fix: classify invalid external blocks safely
randygrok May 13, 2026
de542e2
chore: update EVM module tidy output
randygrok May 13, 2026
ea805f7
ci: retry Docker image pulls in E2E tests
randygrok May 13, 2026
6686e6a
fix: preserve DA data until block acceptance
randygrok May 13, 2026
a5bac4e
Merge branch 'main' into marko/key_rotation
randygrok May 19, 2026
2b53162
Merge remote-tracking branch 'origin/main' into marko/key_rotation
randygrok May 21, 2026
e5537b8
fix: selectively evict header or data from cache on block validation …
randygrok May 21, 2026
b6baa26
feat: handle execution proposer rotation
randygrok May 27, 2026
bd2c237
test: require proposer-control image for rotation e2e
randygrok May 27, 2026
31d3993
Merge remote-tracking branch 'origin/main' into marko/key_rotation
randygrok May 27, 2026
e18383d
feat: support promotable proposer rotation
randygrok Jun 1, 2026
f1e8765
ci: remove Docker image pre-pull retries
randygrok Jun 1, 2026
66e21e0
chore: remove unused markdown link config
randygrok Jun 1, 2026
a07fc20
refactor: make execute result structural
randygrok Jun 1, 2026
1e15672
refactor: remove proposer from execution info
randygrok Jun 2, 2026
58ea3e8
fix: resolve CI lint and benchmark failures
randygrok Jun 2, 2026
0a50aa6
fix: reject unexpected DA proposers early
randygrok Jun 3, 2026
a145fa0
refactor: simplify sync validation faults
randygrok Jun 3, 2026
df99024
refactor: remove unused header verifier
randygrok Jun 3, 2026
a55f5e4
docs: remove stale execution info proposer field
randygrok Jun 3, 2026
f0a8943
fix: replace pending DA header with expected proposer
randygrok Jun 3, 2026
dc0d874
Merge remote-tracking branch 'origin/main' into marko/key_rotation
randygrok Jun 8, 2026
5fa2b17
ci: configure markdown link checker
randygrok Jun 8, 2026
5bb20d5
Merge remote-tracking branch 'origin/main' into marko/key_rotation
randygrok Jun 8, 2026
6638ec9
fix: address key rotation review comments
randygrok Jun 8, 2026
5f65532
fix: stabilize kv HTTP shutdown test
randygrok Jun 9, 2026
cb61c77
docs: use relative markdown links
randygrok Jun 16, 2026
70232ed
Merge remote-tracking branch 'origin/main' into marko/key_rotation
randygrok Jun 16, 2026
5de475b
fix: update CI tidy and markdown checks
randygrok Jun 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
commen changes
  • Loading branch information
tac0turtle committed Apr 24, 2026
commit 45a7d75cb67b6d3ed6b3c02b9979945fd8a2b85c
7 changes: 6 additions & 1 deletion block/internal/executing/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -755,7 +755,12 @@ func (e *Executor) CreateBlock(ctx context.Context, height uint64, batchData *Ba
return nil, nil, fmt.Errorf("failed to get validator hash: %w", err)
}
} else {
validatorHash, err = e.options.ValidatorHasherProvider(proposer.Address, nil)
pubKey, err = proposer.PublicKey()
if err != nil {
return nil, nil, fmt.Errorf("failed to get scheduled proposer public key: %w", err)
}

validatorHash, err = e.options.ValidatorHasherProvider(proposer.Address, pubKey)
if err != nil {
return nil, nil, fmt.Errorf("failed to get validator hash: %w", err)
}
Expand Down
94 changes: 88 additions & 6 deletions block/internal/executing/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/ipfs/go-datastore"
"github.com/ipfs/go-datastore/sync"
"github.com/libp2p/go-libp2p/core/crypto"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand All @@ -20,6 +21,8 @@ import (
"github.com/evstack/ev-node/types"
)

var fixedExecutorTestStartTime = time.Unix(1_700_000_000, 0).UTC()

func TestExecutor_BroadcasterIntegration(t *testing.T) {
// Create in-memory store
ds := sync.MutexWrap(datastore.NewMapDatastore())
Expand Down Expand Up @@ -143,7 +146,7 @@ func TestExecutor_CreateBlock_UsesScheduledProposerForHeight(t *testing.T) {
gen := genesis.Genesis{
ChainID: "test-chain",
InitialHeight: 1,
StartTime: time.Now().Add(-time.Second),
StartTime: fixedExecutorTestStartTime,
ProposerAddress: entry1.Address,
ProposerSchedule: []genesis.ProposerScheduleEntry{entry1, entry2},
DAEpochForcedInclusion: 1,
Expand Down Expand Up @@ -209,14 +212,93 @@ func TestExecutor_CreateBlock_UsesScheduledProposerForHeight(t *testing.T) {

header, data, err := executor.CreateBlock(context.Background(), 2, &BatchData{
Batch: &coreseq.Batch{},
Time: time.Now(),
Time: fixedExecutorTestStartTime.Add(time.Second),
})
require.NoError(t, err)
require.Equal(t, newAddr, header.ProposerAddress)
require.Equal(t, newAddr, header.Signer.Address)
require.Equal(t, uint64(2), data.Height())
}

func TestExecutor_CreateBlock_BasedSequencerUsesScheduledPubKey(t *testing.T) {
ds := sync.MutexWrap(datastore.NewMapDatastore())
memStore := store.New(ds)

cacheManager, err := cache.NewManager(config.DefaultConfig(), memStore, zerolog.Nop())
require.NoError(t, err)

_, signerInfo, _ := buildTestSigner(t)
entry, err := genesis.NewProposerScheduleEntry(1, signerInfo.PubKey)
require.NoError(t, err)

gen := genesis.Genesis{
ChainID: "test-chain",
InitialHeight: 1,
StartTime: fixedExecutorTestStartTime,
ProposerAddress: entry.Address,
ProposerSchedule: []genesis.ProposerScheduleEntry{entry},
DAEpochForcedInclusion: 1,
}

cfg := config.DefaultConfig()
cfg.Node.BasedSequencer = true

wantValidatorHash := types.Hash{0x01}
hasherCalled := false
options := common.DefaultBlockOptions()
options.ValidatorHasherProvider = func(address []byte, pubKey crypto.PubKey) (types.Hash, error) {
hasherCalled = true
require.Equal(t, entry.Address, address)
require.NotNil(t, pubKey)

marshalledPubKey, err := crypto.MarshalPublicKey(pubKey)
require.NoError(t, err)
require.Equal(t, entry.PubKey, marshalledPubKey)

return wantValidatorHash, nil
}

executor, err := NewExecutor(
memStore,
nil,
nil,
nil,
cacheManager,
common.NopMetrics(),
cfg,
gen,
nil,
nil,
zerolog.Nop(),
options,
make(chan error, 1),
nil,
)
require.NoError(t, err)

executor.setLastState(types.State{
Version: types.InitStateVersion,
ChainID: gen.ChainID,
InitialHeight: gen.InitialHeight,
AppHash: []byte("state-root-1"),
})

header, data, err := executor.CreateBlock(context.Background(), 1, &BatchData{
Batch: &coreseq.Batch{},
Time: fixedExecutorTestStartTime,
})
require.NoError(t, err)
require.True(t, hasherCalled)
require.Equal(t, wantValidatorHash, header.ValidatorHash)
require.Equal(t, entry.Address, header.Signer.Address)
require.NotNil(t, header.Signer.PubKey)

marshalledPubKey, err := crypto.MarshalPublicKey(header.Signer.PubKey)
require.NoError(t, err)
require.Equal(t, entry.PubKey, marshalledPubKey)
require.Equal(t, uint64(1), data.Height())
}

// TestNewExecutor_RejectsSignerOutsideSchedule verifies that a signer whose
// address does not appear anywhere in the proposer schedule cannot start the
// executor. This prevents a misconfigured replacement key from coming up as
Expand All @@ -237,7 +319,7 @@ func TestNewExecutor_RejectsSignerOutsideSchedule(t *testing.T) {
gen := genesis.Genesis{
ChainID: "test-chain",
InitialHeight: 1,
StartTime: time.Now(),
StartTime: fixedExecutorTestStartTime,
ProposerAddress: entry.Address,
ProposerSchedule: []genesis.ProposerScheduleEntry{entry},
DAEpochForcedInclusion: 1,
Expand Down Expand Up @@ -277,7 +359,7 @@ func TestExecutor_CreateBlock_RejectsSignerAtWrongHeight(t *testing.T) {
gen := genesis.Genesis{
ChainID: "test-chain",
InitialHeight: 1,
StartTime: time.Now().Add(-time.Second),
StartTime: fixedExecutorTestStartTime,
ProposerAddress: entry1.Address,
ProposerSchedule: []genesis.ProposerScheduleEntry{entry1, entry2},
DAEpochForcedInclusion: 1,
Expand All @@ -300,7 +382,7 @@ func TestExecutor_CreateBlock_RejectsSignerAtWrongHeight(t *testing.T) {
BaseHeader: types.BaseHeader{
ChainID: gen.ChainID,
Height: 4,
Time: uint64(gen.StartTime.UnixNano()),
Time: uint64(fixedExecutorTestStartTime.Add(4 * time.Second).UnixNano()),
},
AppHash: []byte("state-root-4"),
ProposerAddress: oldAddr,
Expand Down Expand Up @@ -337,7 +419,7 @@ func TestExecutor_CreateBlock_RejectsSignerAtWrongHeight(t *testing.T) {
// signer must be rejected even though it's a known schedule member.
_, _, err = executor.CreateBlock(context.Background(), 5, &BatchData{
Batch: &coreseq.Batch{},
Time: time.Now(),
Time: fixedExecutorTestStartTime.Add(5 * time.Second),
})
require.Error(t, err)
require.Contains(t, err.Error(), "proposer")
Expand Down
9 changes: 9 additions & 0 deletions block/internal/syncing/p2p_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@ func (h *P2PHandler) ProcessHeight(ctx context.Context, height uint64, heightInC
}
return err
}
if headerHeight := p2pHeader.Height(); headerHeight != height {
err := fmt.Errorf("header height mismatch: requested %d, got %d", height, headerHeight)
h.logger.Warn().
Uint64("requested_height", height).
Uint64("header_height", headerHeight).
Err(err).
Msg("discarding mismatched header from P2P")
return err
}
if err := h.assertExpectedProposer(p2pHeader.SignedHeader); err != nil {
h.logger.Debug().Uint64("height", height).Err(err).Msg("invalid header from P2P")
return err
Expand Down
17 changes: 17 additions & 0 deletions block/internal/syncing/p2p_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,23 @@ func TestP2PHandler_ProcessHeight_SkipsOnProposerMismatch(t *testing.T) {
p.DataStore.AssertNotCalled(t, "GetByHeight", mock.Anything, uint64(11))
}

func TestP2PHandler_ProcessHeight_RejectsHeaderHeightMismatch(t *testing.T) {
p := setupP2P(t)
ctx := context.Background()

header := p2pMakeSignedHeader(t, p.Genesis.ChainID, 12, p.ProposerAddr, p.ProposerPub, p.Signer)

p.HeaderStore.EXPECT().GetByHeight(mock.Anything, uint64(10)).Return(header, nil).Once()

ch := make(chan common.DAHeightEvent, 1)
err := p.Handler.ProcessHeight(ctx, 10, ch)
require.Error(t, err)
require.Contains(t, err.Error(), "header height mismatch")

require.Empty(t, collectEvents(t, ch, 50*time.Millisecond))
p.DataStore.AssertNotCalled(t, "GetByHeight", mock.Anything, uint64(10))
}

func TestP2PHandler_ProcessHeight_AllowsScheduledProposerRotation(t *testing.T) {
p := setupP2P(t)
ctx := context.Background()
Expand Down
12 changes: 6 additions & 6 deletions docs/adr/adr-023-proposer-key-rotation.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Each entry declares:

- `start_height`
- `address`
- `pub_key`
- `pub_key` (optional; when present, it must match `address`)

The active proposer for block height `h` is the last entry whose `start_height <= h`.

Expand All @@ -68,8 +68,9 @@ When an explicit schedule is present:

- the first entry must start at `initial_height`
- entries must be strictly increasing by `start_height`
- each entry's `address` must match the configured `pub_key`
- `proposer_address`, when present, must match the first schedule entry
- if `pub_key` is present, the entry's `address` must match it
- entries without `pub_key` are interpreted by `address` only
- `proposer_address`, when present, must match the first schedule entry's `address`

## Detailed Design

Expand All @@ -86,8 +87,7 @@ Genesis gains:
},
{
"start_height": 1250000,
"address": "...",
"pub_key": "..."
"address": "..."
}
]
```
Expand Down Expand Up @@ -121,7 +121,7 @@ The old proposer remains valid for heights `< H`, and the new proposer becomes v

### Security considerations

This design improves safety over address-only pinning by allowing validation against the scheduled public key.
This design improves safety by allowing validation against the scheduled public key when one is pinned.
Comment thread
tac0turtle marked this conversation as resolved.
Outdated
It does not solve emergency rotation authorization by itself; a future design can add a separate upgrade authority
or rotation certificate flow if the network needs signer replacement without prior static scheduling.

Expand Down
6 changes: 3 additions & 3 deletions pkg/genesis/genesis_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ func TestGenesis_Validate(t *testing.T) {
}

func TestGenesis_ValidateProposerSchedule(t *testing.T) {
validTime := time.Now().UTC()
validTime := time.Unix(1_700_000_000, 0).UTC()

newEntry := func(startHeight uint64) (ProposerScheduleEntry, crypto.PubKey) {
_, pub, err := crypto.GenerateEd25519Key(rand.Reader)
Expand Down Expand Up @@ -230,11 +230,11 @@ func TestGenesis_ValidateProposerSchedule(t *testing.T) {
ChainID: "c",
StartTime: validTime,
InitialHeight: 1,
ProposerSchedule: []ProposerScheduleEntry{entry20, entry10},
ProposerSchedule: []ProposerScheduleEntry{entry1, entry20, entry10},
DAEpochForcedInclusion: 1,
}
},
wantErr: "start_height must equal initial_height",
wantErr: "strictly increasing",
},
{
name: "invalid - entry address does not match pub_key",
Expand Down
3 changes: 2 additions & 1 deletion pkg/genesis/proposer_schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ func TestEffectiveProposerSchedule_ExplicitScheduleIsDeepCopy(t *testing.T) {

func TestEffectiveProposerSchedule_LegacyFallback(t *testing.T) {
addr := []byte("some-address-bytes")
origAddr := bytes.Clone(addr)
legacy := Genesis{
ChainID: "c",
InitialHeight: 7,
Expand All @@ -283,7 +284,7 @@ func TestEffectiveProposerSchedule_LegacyFallback(t *testing.T) {

// mutating the derived slice must not affect the genesis backing data.
schedule[0].Address[0] ^= 0xFF
require.Equal(t, addr, legacy.ProposerAddress)
require.Equal(t, origAddr, legacy.ProposerAddress)
}

func TestEffectiveProposerSchedule_Empty(t *testing.T) {
Expand Down
Loading