Skip to content

Commit 6ad13f2

Browse files
committed
comments and amendments
1 parent 6a0f367 commit 6ad13f2

6 files changed

Lines changed: 608 additions & 10 deletions

File tree

block/internal/executing/executor_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,129 @@ func TestExecutor_CreateBlock_UsesScheduledProposerForHeight(t *testing.T) {
216216
require.Equal(t, newAddr, header.Signer.Address)
217217
require.Equal(t, uint64(2), data.Height())
218218
}
219+
220+
// TestNewExecutor_RejectsSignerOutsideSchedule verifies that a signer whose
221+
// address does not appear anywhere in the proposer schedule cannot start the
222+
// executor. This prevents a misconfigured replacement key from coming up as
223+
// an aggregator on a chain it was never scheduled on.
224+
func TestNewExecutor_RejectsSignerOutsideSchedule(t *testing.T) {
225+
ds := sync.MutexWrap(datastore.NewMapDatastore())
226+
memStore := store.New(ds)
227+
228+
cacheManager, err := cache.NewManager(config.DefaultConfig(), memStore, zerolog.Nop())
229+
require.NoError(t, err)
230+
231+
_, scheduledSigner, _ := buildTestSigner(t)
232+
_, _, strayerSigner := buildTestSigner(t)
233+
234+
entry, err := genesis.NewProposerScheduleEntry(1, scheduledSigner.PubKey)
235+
require.NoError(t, err)
236+
237+
gen := genesis.Genesis{
238+
ChainID: "test-chain",
239+
InitialHeight: 1,
240+
StartTime: time.Now(),
241+
ProposerAddress: entry.Address,
242+
ProposerSchedule: []genesis.ProposerScheduleEntry{entry},
243+
DAEpochForcedInclusion: 1,
244+
}
245+
246+
_, err = NewExecutor(
247+
memStore, nil, nil, strayerSigner, cacheManager,
248+
common.NopMetrics(), config.DefaultConfig(), gen,
249+
nil, nil, zerolog.Nop(), common.DefaultBlockOptions(),
250+
make(chan error, 1), nil,
251+
)
252+
require.ErrorIs(t, err, common.ErrNotProposer)
253+
}
254+
255+
// TestExecutor_CreateBlock_RejectsSignerAtWrongHeight verifies that a signer
256+
// which is scheduled (so startup succeeds) but not active at the current
257+
// height cannot produce a block. This guards the per-height proposer check
258+
// inside CreateBlock — without it, a rotation could be jumped ahead or
259+
// rolled back by whichever signer the operator happens to start.
260+
func TestExecutor_CreateBlock_RejectsSignerAtWrongHeight(t *testing.T) {
261+
ds := sync.MutexWrap(datastore.NewMapDatastore())
262+
memStore := store.New(ds)
263+
264+
cacheManager, err := cache.NewManager(config.DefaultConfig(), memStore, zerolog.Nop())
265+
require.NoError(t, err)
266+
267+
oldAddr, oldSignerInfo, oldSigner := buildTestSigner(t)
268+
_, newSignerInfo, _ := buildTestSigner(t)
269+
270+
entry1, err := genesis.NewProposerScheduleEntry(1, oldSignerInfo.PubKey)
271+
require.NoError(t, err)
272+
// Second entry activates at height 5. The old signer is scheduled at
273+
// height 1 and is NOT the proposer for height 5+.
274+
entry2, err := genesis.NewProposerScheduleEntry(5, newSignerInfo.PubKey)
275+
require.NoError(t, err)
276+
277+
gen := genesis.Genesis{
278+
ChainID: "test-chain",
279+
InitialHeight: 1,
280+
StartTime: time.Now().Add(-time.Second),
281+
ProposerAddress: entry1.Address,
282+
ProposerSchedule: []genesis.ProposerScheduleEntry{entry1, entry2},
283+
DAEpochForcedInclusion: 1,
284+
}
285+
286+
// Start the executor as the old signer — it IS in the schedule at
287+
// height 1, so NewExecutor must accept it.
288+
executor, err := NewExecutor(
289+
memStore, nil, nil, oldSigner, cacheManager,
290+
common.NopMetrics(), config.DefaultConfig(), gen,
291+
nil, nil, zerolog.Nop(), common.DefaultBlockOptions(),
292+
make(chan error, 1), nil,
293+
)
294+
require.NoError(t, err)
295+
296+
// Seed a height-4 block so CreateBlock(5) has a parent to reference.
297+
prevHeader := &types.SignedHeader{
298+
Header: types.Header{
299+
Version: types.InitStateVersion,
300+
BaseHeader: types.BaseHeader{
301+
ChainID: gen.ChainID,
302+
Height: 4,
303+
Time: uint64(gen.StartTime.UnixNano()),
304+
},
305+
AppHash: []byte("state-root-4"),
306+
ProposerAddress: oldAddr,
307+
DataHash: common.DataHashForEmptyTxs,
308+
},
309+
Signature: types.Signature([]byte("sig-4")),
310+
Signer: oldSignerInfo,
311+
}
312+
prevData := &types.Data{
313+
Metadata: &types.Metadata{
314+
ChainID: gen.ChainID,
315+
Height: 4,
316+
Time: prevHeader.BaseHeader.Time,
317+
},
318+
}
319+
320+
batch, err := memStore.NewBatch(context.Background())
321+
require.NoError(t, err)
322+
require.NoError(t, batch.SaveBlockData(prevHeader, prevData, &prevHeader.Signature))
323+
require.NoError(t, batch.SetHeight(4))
324+
require.NoError(t, batch.Commit())
325+
326+
executor.setLastState(types.State{
327+
Version: types.InitStateVersion,
328+
ChainID: gen.ChainID,
329+
InitialHeight: gen.InitialHeight,
330+
LastBlockHeight: 4,
331+
LastBlockTime: prevHeader.Time(),
332+
LastHeaderHash: prevHeader.Hash(),
333+
AppHash: []byte("state-root-4"),
334+
})
335+
336+
// Height 5 belongs to the NEW signer per the schedule — the old
337+
// signer must be rejected even though it's a known schedule member.
338+
_, _, err = executor.CreateBlock(context.Background(), 5, &BatchData{
339+
Batch: &coreseq.Batch{},
340+
Time: time.Now(),
341+
})
342+
require.Error(t, err)
343+
require.Contains(t, err.Error(), "proposer")
344+
}

block/internal/syncing/p2p_handler_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,44 @@ func TestP2PHandler_ProcessHeight_AllowsScheduledProposerRotation(t *testing.T)
253253
require.Equal(t, nextAddr, events[0].Header.ProposerAddress)
254254
}
255255

256+
// TestP2PHandler_ProcessHeight_RejectsScheduledProposerBeforeActivation verifies
257+
// the counterpart to the rotation-allows test: a signer that IS in the schedule
258+
// but only active at a later height must not be accepted for blocks before the
259+
// activation height. Without the per-height check, any scheduled signer could
260+
// forge blocks outside their active window.
261+
func TestP2PHandler_ProcessHeight_RejectsScheduledProposerBeforeActivation(t *testing.T) {
262+
p := setupP2P(t)
263+
ctx := context.Background()
264+
265+
nextAddr, nextPub, nextSigner := buildTestSigner(t)
266+
267+
entry1, err := genesis.NewProposerScheduleEntry(p.Genesis.InitialHeight, p.ProposerPub)
268+
require.NoError(t, err)
269+
entry2, err := genesis.NewProposerScheduleEntry(11, nextPub)
270+
require.NoError(t, err)
271+
272+
p.Genesis.ProposerAddress = entry1.Address
273+
p.Genesis.ProposerSchedule = []genesis.ProposerScheduleEntry{entry1, entry2}
274+
p.Genesis.DAEpochForcedInclusion = 1
275+
require.NoError(t, p.Genesis.Validate())
276+
p.Handler.genesis = p.Genesis
277+
278+
// entry2 is scheduled but only active at height 11. Height 10 still
279+
// belongs to entry1, so a header from the next signer at height 10
280+
// must be rejected.
281+
header := p2pMakeSignedHeader(t, p.Genesis.ChainID, 10, nextAddr, nextPub, nextSigner)
282+
header.DataHash = common.DataHashForEmptyTxs
283+
284+
p.HeaderStore.EXPECT().GetByHeight(mock.Anything, uint64(10)).Return(header, nil).Once()
285+
286+
ch := make(chan common.DAHeightEvent, 1)
287+
err = p.Handler.ProcessHeight(ctx, 10, ch)
288+
require.Error(t, err)
289+
290+
require.Empty(t, collectEvents(t, ch, 50*time.Millisecond))
291+
p.DataStore.AssertNotCalled(t, "GetByHeight", mock.Anything, uint64(10))
292+
}
293+
256294
func TestP2PHandler_ProcessedHeightSkipsPreviouslyHandledBlocks(t *testing.T) {
257295
p := setupP2P(t)
258296
ctx := t.Context()

docs/adr/adr-023-proposer-key-rotation.md

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,32 @@ and validation become ambiguous.
2727

2828
### 2. Re-issue a new genesis on each rotation
2929

30-
This treats every proposer rotation like a chain restart. It is operationally heavy, conflates upgrades with
31-
rotations, and breaks continuity for nodes syncing historical data.
30+
This treats every proposer rotation like a chain restart: a new `chain_id`, state reset back to `initial_height`,
31+
and existing block history discarded. It is operationally heavy, conflates upgrades with rotations, and breaks
32+
continuity for nodes syncing historical data.
3233

3334
### 3. Height-indexed proposer schedule in genesis (Chosen)
3435

35-
Record proposer changes as an ordered schedule indexed by activation height. This preserves chain continuity while
36-
making rotation rules explicit and replayable from genesis.
36+
Record proposer changes as an ordered schedule indexed by activation height. The `genesis.json` file is updated
37+
with a new schedule entry and redistributed, but the chain keeps its `chain_id`, continues from the current
38+
height, preserves all block history, and fresh nodes can still validate the entire chain end-to-end across
39+
rotation boundaries. The rollout is still coordinated — every node must receive the updated `genesis.json` and
40+
restart before the activation height — but none of the chain's state or provenance is reset.
3741

3842
## Decision
3943

4044
ev-node now supports proposer rotation through a `proposer_schedule` field in genesis.
4145

46+
### What this is not
47+
48+
This is **not** a re-genesis. Re-genesis — in the sense we mean it above — would involve issuing a new `chain_id`,
49+
resetting height to `initial_height`, and discarding existing block history. Proposer key rotation does none of
50+
that: the `chain_id` is unchanged, block height keeps progressing, all previous blocks remain valid, and fresh
51+
nodes can sync the chain from genesis across any number of rotation boundaries.
52+
53+
The `genesis.json` file itself is updated (a new `proposer_schedule` entry is appended) and operators must
54+
restart every node to reload it. The file changes; the chain's state does not.
55+
4256
Each entry declares:
4357

4458
- `start_height`
@@ -137,7 +151,7 @@ Implemented
137151

138152
- proposer schedule changes are consensus-visible and require coordinated rollout
139153
- operators must distribute updated genesis/config before activation height
140-
- emergency rotation still requires preplanned scheduling or a later authority-based mechanism
154+
- emergency rotation still requires prior scheduling or a later authority-based mechanism
141155

142156
### Neutral
143157

0 commit comments

Comments
 (0)