diff --git a/block/internal/executing/executor.go b/block/internal/executing/executor.go index f5be5e1b40..4cc7f4984c 100644 --- a/block/internal/executing/executor.go +++ b/block/internal/executing/executor.go @@ -126,7 +126,7 @@ func NewExecutor( return nil, fmt.Errorf("failed to get address: %w", err) } - if !bytes.Equal(addr, genesis.ProposerAddress) { + if !genesis.HasScheduledProposer(addr) { return nil, common.ErrNotProposer } } @@ -696,6 +696,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()) + proposer, err := e.genesis.ProposerAtHeight(height) + if err != nil { + return nil, nil, fmt.Errorf("resolve proposer for height %d: %w", height, err) + } var lastHeaderHash types.Hash var lastDataHash types.Hash @@ -728,22 +732,30 @@ func (e *Executor) CreateBlock(ctx context.Context, height uint64, batchData *Ba // Get signer info and validator hash var pubKey crypto.PubKey + var signerAddress []byte var validatorHash types.Hash if e.signer != nil { - var err error pubKey, err = e.signer.GetPublic() if err != nil { return nil, nil, fmt.Errorf("failed to get public key: %w", err) } - validatorHash, err = e.options.ValidatorHasherProvider(e.genesis.ProposerAddress, pubKey) + signerAddress, err = e.signer.GetAddress() + if err != nil { + return nil, nil, fmt.Errorf("failed to get signer address: %w", err) + } + + if err := e.genesis.ValidateProposer(height, signerAddress, pubKey); err != nil { + return nil, nil, fmt.Errorf("signer does not match proposer schedule: %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) } } else { - var err error - validatorHash, err = e.options.ValidatorHasherProvider(e.genesis.ProposerAddress, nil) + validatorHash, err = e.options.ValidatorHasherProvider(proposer.Address, nil) if err != nil { return nil, nil, fmt.Errorf("failed to get validator hash: %w", err) } @@ -763,13 +775,13 @@ func (e *Executor) CreateBlock(ctx context.Context, height uint64, batchData *Ba }, LastHeaderHash: lastHeaderHash, AppHash: currentState.AppHash, - ProposerAddress: e.genesis.ProposerAddress, + ProposerAddress: proposer.Address, ValidatorHash: validatorHash, }, Signature: lastSignature, Signer: types.Signer{ PubKey: pubKey, - Address: e.genesis.ProposerAddress, + Address: proposer.Address, }, } diff --git a/block/internal/executing/executor_test.go b/block/internal/executing/executor_test.go index 1099cdb87d..5f2d4db7d8 100644 --- a/block/internal/executing/executor_test.go +++ b/block/internal/executing/executor_test.go @@ -1,6 +1,7 @@ package executing import ( + "context" "testing" "time" @@ -12,6 +13,7 @@ import ( "github.com/evstack/ev-node/block/internal/cache" "github.com/evstack/ev-node/block/internal/common" + coreseq "github.com/evstack/ev-node/core/sequencer" "github.com/evstack/ev-node/pkg/config" "github.com/evstack/ev-node/pkg/genesis" "github.com/evstack/ev-node/pkg/store" @@ -121,3 +123,222 @@ func TestExecutor_NilBroadcasters(t *testing.T) { assert.Equal(t, cacheManager, executor.cache) assert.Equal(t, gen, executor.genesis) } + +func TestExecutor_CreateBlock_UsesScheduledProposerForHeight(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) + + metrics := common.NopMetrics() + oldAddr, oldSignerInfo, _ := buildTestSigner(t) + newAddr, newSignerInfo, newSigner := buildTestSigner(t) + + entry1, err := genesis.NewProposerScheduleEntry(1, oldSignerInfo.PubKey) + require.NoError(t, err) + entry2, err := genesis.NewProposerScheduleEntry(2, newSignerInfo.PubKey) + require.NoError(t, err) + + gen := genesis.Genesis{ + ChainID: "test-chain", + InitialHeight: 1, + StartTime: time.Now().Add(-time.Second), + ProposerAddress: entry1.Address, + ProposerSchedule: []genesis.ProposerScheduleEntry{entry1, entry2}, + DAEpochForcedInclusion: 1, + } + + executor, err := NewExecutor( + memStore, + nil, + nil, + newSigner, + cacheManager, + metrics, + config.DefaultConfig(), + gen, + nil, + nil, + zerolog.Nop(), + common.DefaultBlockOptions(), + make(chan error, 1), + nil, + ) + require.NoError(t, err) + + prevHeader := &types.SignedHeader{ + Header: types.Header{ + Version: types.InitStateVersion, + BaseHeader: types.BaseHeader{ + ChainID: gen.ChainID, + Height: 1, + Time: uint64(gen.StartTime.UnixNano()), + }, + AppHash: []byte("state-root-0"), + ProposerAddress: oldAddr, + DataHash: common.DataHashForEmptyTxs, + }, + Signature: types.Signature([]byte("sig-1")), + Signer: oldSignerInfo, + } + prevData := &types.Data{ + Metadata: &types.Metadata{ + ChainID: gen.ChainID, + Height: 1, + Time: prevHeader.BaseHeader.Time, + }, + Txs: nil, + } + + batch, err := memStore.NewBatch(context.Background()) + require.NoError(t, err) + require.NoError(t, batch.SaveBlockData(prevHeader, prevData, &prevHeader.Signature)) + require.NoError(t, batch.SetHeight(1)) + require.NoError(t, batch.Commit()) + + executor.setLastState(types.State{ + Version: types.InitStateVersion, + ChainID: gen.ChainID, + InitialHeight: gen.InitialHeight, + LastBlockHeight: 1, + LastBlockTime: prevHeader.Time(), + LastHeaderHash: prevHeader.Hash(), + AppHash: []byte("state-root-1"), + }) + + header, data, err := executor.CreateBlock(context.Background(), 2, &BatchData{ + Batch: &coreseq.Batch{}, + Time: time.Now(), + }) + require.NoError(t, err) + require.Equal(t, newAddr, header.ProposerAddress) + require.Equal(t, newAddr, header.Signer.Address) + require.Equal(t, uint64(2), 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 +// an aggregator on a chain it was never scheduled on. +func TestNewExecutor_RejectsSignerOutsideSchedule(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) + + _, scheduledSigner, _ := buildTestSigner(t) + _, _, strayerSigner := buildTestSigner(t) + + entry, err := genesis.NewProposerScheduleEntry(1, scheduledSigner.PubKey) + require.NoError(t, err) + + gen := genesis.Genesis{ + ChainID: "test-chain", + InitialHeight: 1, + StartTime: time.Now(), + ProposerAddress: entry.Address, + ProposerSchedule: []genesis.ProposerScheduleEntry{entry}, + DAEpochForcedInclusion: 1, + } + + _, err = NewExecutor( + memStore, nil, nil, strayerSigner, cacheManager, + common.NopMetrics(), config.DefaultConfig(), gen, + nil, nil, zerolog.Nop(), common.DefaultBlockOptions(), + make(chan error, 1), nil, + ) + require.ErrorIs(t, err, common.ErrNotProposer) +} + +// TestExecutor_CreateBlock_RejectsSignerAtWrongHeight verifies that a signer +// which is scheduled (so startup succeeds) but not active at the current +// height cannot produce a block. This guards the per-height proposer check +// inside CreateBlock — without it, a rotation could be jumped ahead or +// rolled back by whichever signer the operator happens to start. +func TestExecutor_CreateBlock_RejectsSignerAtWrongHeight(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) + + oldAddr, oldSignerInfo, oldSigner := buildTestSigner(t) + _, newSignerInfo, _ := buildTestSigner(t) + + entry1, err := genesis.NewProposerScheduleEntry(1, oldSignerInfo.PubKey) + require.NoError(t, err) + // Second entry activates at height 5. The old signer is scheduled at + // height 1 and is NOT the proposer for height 5+. + entry2, err := genesis.NewProposerScheduleEntry(5, newSignerInfo.PubKey) + require.NoError(t, err) + + gen := genesis.Genesis{ + ChainID: "test-chain", + InitialHeight: 1, + StartTime: time.Now().Add(-time.Second), + ProposerAddress: entry1.Address, + ProposerSchedule: []genesis.ProposerScheduleEntry{entry1, entry2}, + DAEpochForcedInclusion: 1, + } + + // Start the executor as the old signer — it IS in the schedule at + // height 1, so NewExecutor must accept it. + executor, err := NewExecutor( + memStore, nil, nil, oldSigner, cacheManager, + common.NopMetrics(), config.DefaultConfig(), gen, + nil, nil, zerolog.Nop(), common.DefaultBlockOptions(), + make(chan error, 1), nil, + ) + require.NoError(t, err) + + // Seed a height-4 block so CreateBlock(5) has a parent to reference. + prevHeader := &types.SignedHeader{ + Header: types.Header{ + Version: types.InitStateVersion, + BaseHeader: types.BaseHeader{ + ChainID: gen.ChainID, + Height: 4, + Time: uint64(gen.StartTime.UnixNano()), + }, + AppHash: []byte("state-root-4"), + ProposerAddress: oldAddr, + DataHash: common.DataHashForEmptyTxs, + }, + Signature: types.Signature([]byte("sig-4")), + Signer: oldSignerInfo, + } + prevData := &types.Data{ + Metadata: &types.Metadata{ + ChainID: gen.ChainID, + Height: 4, + Time: prevHeader.BaseHeader.Time, + }, + } + + batch, err := memStore.NewBatch(context.Background()) + require.NoError(t, err) + require.NoError(t, batch.SaveBlockData(prevHeader, prevData, &prevHeader.Signature)) + require.NoError(t, batch.SetHeight(4)) + require.NoError(t, batch.Commit()) + + executor.setLastState(types.State{ + Version: types.InitStateVersion, + ChainID: gen.ChainID, + InitialHeight: gen.InitialHeight, + LastBlockHeight: 4, + LastBlockTime: prevHeader.Time(), + LastHeaderHash: prevHeader.Hash(), + AppHash: []byte("state-root-4"), + }) + + // Height 5 belongs to the NEW signer per the schedule — the old + // 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(), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "proposer") +} diff --git a/block/internal/submitting/da_submitter.go b/block/internal/submitting/da_submitter.go index 83f56d9cb5..e53e351832 100644 --- a/block/internal/submitting/da_submitter.go +++ b/block/internal/submitting/da_submitter.go @@ -1,7 +1,6 @@ package submitting import ( - "bytes" "context" "encoding/json" "fmt" @@ -476,10 +475,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, @@ -494,6 +489,10 @@ func (s *DASubmitter) signData(ctx context.Context, unsignedDataList []*types.Si continue } + if err := genesis.ValidateProposer(unsignedData.Height(), addr, pubKey); err != nil { + return nil, nil, fmt.Errorf("signer does not match proposer schedule for data at height %d: %w", unsignedData.Height(), err) + } + signature, err := signer.Sign(ctx, unsignedDataListBz[i]) if err != nil { return nil, nil, fmt.Errorf("failed to sign data: %w", err) diff --git a/block/internal/submitting/da_submitter_test.go b/block/internal/submitting/da_submitter_test.go index d25786018b..9c55b9bd6c 100644 --- a/block/internal/submitting/da_submitter_test.go +++ b/block/internal/submitting/da_submitter_test.go @@ -343,6 +343,97 @@ func TestDASubmitter_SubmitData_Success(t *testing.T) { assert.True(t, ok) } +func TestDASubmitter_SubmitData_UsesScheduledProposerForHeight(t *testing.T) { + submitter, st, cm, mockDA, gen := setupDASubmitterTest(t) + ctx := context.Background() + dataNamespace := datypes.NamespaceFromString(testDataNamespace).Bytes() + + mockDA.On( + "Submit", + mock.Anything, + mock.AnythingOfType("[][]uint8"), + mock.AnythingOfType("float64"), + dataNamespace, + mock.Anything, + ).Return(func(_ context.Context, blobs [][]byte, _ float64, _ []byte, _ []byte) datypes.ResultSubmit { + return datypes.ResultSubmit{BaseResult: datypes.BaseResult{Code: datypes.StatusSuccess, SubmittedCount: uint64(len(blobs)), Height: 2}} + }).Once() + + oldAddr, oldPub, _ := createTestSigner(t) + nextAddr, nextPub, nextSigner := createTestSigner(t) + + entry1, err := genesis.NewProposerScheduleEntry(gen.InitialHeight, oldPub) + require.NoError(t, err) + entry2, err := genesis.NewProposerScheduleEntry(2, nextPub) + require.NoError(t, err) + + gen.ProposerAddress = entry1.Address + gen.ProposerSchedule = []genesis.ProposerScheduleEntry{entry1, entry2} + submitter.genesis = gen + + data1 := &types.Data{ + Metadata: &types.Metadata{ + ChainID: gen.ChainID, + Height: 1, + Time: uint64(time.Now().UnixNano()), + }, + Txs: types.Txs{}, + } + + header1 := &types.SignedHeader{ + Header: types.Header{ + BaseHeader: types.BaseHeader{ + ChainID: gen.ChainID, + Height: 1, + Time: uint64(time.Now().UnixNano()), + }, + ProposerAddress: oldAddr, + DataHash: common.DataHashForEmptyTxs, + }, + Signer: types.Signer{PubKey: oldPub, Address: oldAddr}, + } + + data := &types.Data{ + Metadata: &types.Metadata{ + ChainID: gen.ChainID, + Height: 2, + Time: uint64(time.Now().UnixNano()), + }, + Txs: types.Txs{types.Tx("rotated-key-tx")}, + } + + header := &types.SignedHeader{ + Header: types.Header{ + BaseHeader: types.BaseHeader{ + ChainID: gen.ChainID, + Height: 2, + Time: uint64(time.Now().UnixNano()), + }, + ProposerAddress: nextAddr, + DataHash: data.DACommitment(), + }, + Signer: types.Signer{PubKey: nextPub, Address: nextAddr}, + } + + sig1 := types.Signature([]byte("sig-1")) + sig2 := types.Signature([]byte("sig-2")) + batch, err := st.NewBatch(ctx) + require.NoError(t, err) + require.NoError(t, batch.SaveBlockData(header1, data1, &sig1)) + require.NoError(t, batch.SaveBlockData(header, data, &sig2)) + require.NoError(t, batch.SetHeight(2)) + require.NoError(t, batch.Commit()) + + signedDataList, marshalledData, err := cm.GetPendingData(ctx) + require.NoError(t, err) + err = submitter.SubmitData(ctx, signedDataList, marshalledData, cm, nextSigner, gen) + require.NoError(t, err) + + _, ok := cm.GetDataDAIncludedByHeight(2) + assert.True(t, ok) + assert.NotEqual(t, oldAddr, nextAddr) +} + func TestDASubmitter_SubmitData_SkipsEmptyData(t *testing.T) { submitter, st, cm, mockDA, gen := setupDASubmitterTest(t) ctx := context.Background() diff --git a/block/internal/syncing/assert.go b/block/internal/syncing/assert.go index 7c77400571..1bed6db8b9 100644 --- a/block/internal/syncing/assert.go +++ b/block/internal/syncing/assert.go @@ -1,19 +1,20 @@ package syncing import ( - "bytes" "errors" "fmt" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/evstack/ev-node/pkg/genesis" "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) +func assertExpectedProposer(genesis genesis.Genesis, height uint64, proposerAddr []byte, pubKey crypto.PubKey) error { + if err := genesis.ValidateProposer(height, proposerAddr, pubKey); err != nil { + return fmt.Errorf("unexpected proposer at height %d: %w", height, err) } + return nil } @@ -22,7 +23,7 @@ func assertValidSignedData(signedData *types.SignedData, genesis genesis.Genesis return errors.New("empty signed data") } - if err := assertExpectedProposer(genesis, signedData.Signer.Address); err != nil { + if err := assertExpectedProposer(genesis, signedData.Height(), signedData.Signer.Address, signedData.Signer.PubKey); err != nil { return err } diff --git a/block/internal/syncing/da_retriever.go b/block/internal/syncing/da_retriever.go index d4fa93ce04..75bc631e8f 100644 --- a/block/internal/syncing/da_retriever.go +++ b/block/internal/syncing/da_retriever.go @@ -299,7 +299,7 @@ func (r *daRetriever) tryDecodeHeader(bz []byte, daHeight uint64) *types.SignedH return nil } - if err := r.assertExpectedProposer(header.ProposerAddress); err != nil { + if err := r.assertExpectedProposer(header); err != nil { r.logger.Debug().Err(err).Msg("unexpected proposer") return nil } @@ -355,9 +355,9 @@ 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) +// assertExpectedProposer validates the proposer schedule entry for the header height. +func (r *daRetriever) assertExpectedProposer(header *types.SignedHeader) error { + return assertExpectedProposer(r.genesis, header.Height(), header.ProposerAddress, header.Signer.PubKey) } // assertValidSignedData validates signed data using the configured signature provider diff --git a/block/internal/syncing/p2p_handler.go b/block/internal/syncing/p2p_handler.go index a3778757a1..0e8a08cea3 100644 --- a/block/internal/syncing/p2p_handler.go +++ b/block/internal/syncing/p2p_handler.go @@ -81,7 +81,7 @@ func (h *P2PHandler) ProcessHeight(ctx context.Context, height uint64, heightInC } return err } - if err := h.assertExpectedProposer(p2pHeader.ProposerAddress); err != nil { + if err := h.assertExpectedProposer(p2pHeader.SignedHeader); err != nil { h.logger.Debug().Uint64("height", height).Err(err).Msg("invalid header from P2P") return err } @@ -125,11 +125,7 @@ func (h *P2PHandler) ProcessHeight(ctx context.Context, height uint64, heightInC 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 +// assertExpectedProposer validates the proposer schedule entry for the header height. +func (h *P2PHandler) assertExpectedProposer(header *types.SignedHeader) error { + return assertExpectedProposer(h.genesis, header.Height(), header.ProposerAddress, header.Signer.PubKey) } diff --git a/block/internal/syncing/p2p_handler_test.go b/block/internal/syncing/p2p_handler_test.go index 8bffc31ede..1ba3b86e27 100644 --- a/block/internal/syncing/p2p_handler_test.go +++ b/block/internal/syncing/p2p_handler_test.go @@ -215,6 +215,82 @@ func TestP2PHandler_ProcessHeight_SkipsOnProposerMismatch(t *testing.T) { p.DataStore.AssertNotCalled(t, "GetByHeight", mock.Anything, uint64(11)) } +func TestP2PHandler_ProcessHeight_AllowsScheduledProposerRotation(t *testing.T) { + p := setupP2P(t) + ctx := context.Background() + + nextAddr, nextPub, nextSigner := buildTestSigner(t) + + entry1, err := genesis.NewProposerScheduleEntry(p.Genesis.InitialHeight, p.ProposerPub) + require.NoError(t, err) + entry2, err := genesis.NewProposerScheduleEntry(11, nextPub) + require.NoError(t, err) + + p.Genesis.ProposerAddress = entry1.Address + p.Genesis.ProposerSchedule = []genesis.ProposerScheduleEntry{entry1, entry2} + p.Genesis.DAEpochForcedInclusion = 1 + require.NoError(t, p.Genesis.Validate()) + p.Handler.genesis = p.Genesis + + header := p2pMakeSignedHeader(t, p.Genesis.ChainID, 11, nextAddr, nextPub, nextSigner) + 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 := nextSigner.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.NoError(t, err) + + events := collectEvents(t, ch, 50*time.Millisecond) + require.Len(t, events, 1) + require.Equal(t, nextAddr, events[0].Header.ProposerAddress) +} + +// TestP2PHandler_ProcessHeight_RejectsScheduledProposerBeforeActivation verifies +// the counterpart to the rotation-allows test: a signer that IS in the schedule +// but only active at a later height must not be accepted for blocks before the +// activation height. Without the per-height check, any scheduled signer could +// forge blocks outside their active window. +func TestP2PHandler_ProcessHeight_RejectsScheduledProposerBeforeActivation(t *testing.T) { + p := setupP2P(t) + ctx := context.Background() + + nextAddr, nextPub, nextSigner := buildTestSigner(t) + + entry1, err := genesis.NewProposerScheduleEntry(p.Genesis.InitialHeight, p.ProposerPub) + require.NoError(t, err) + entry2, err := genesis.NewProposerScheduleEntry(11, nextPub) + require.NoError(t, err) + + p.Genesis.ProposerAddress = entry1.Address + p.Genesis.ProposerSchedule = []genesis.ProposerScheduleEntry{entry1, entry2} + p.Genesis.DAEpochForcedInclusion = 1 + require.NoError(t, p.Genesis.Validate()) + p.Handler.genesis = p.Genesis + + // entry2 is scheduled but only active at height 11. Height 10 still + // belongs to entry1, so a header from the next signer at height 10 + // must be rejected. + header := p2pMakeSignedHeader(t, p.Genesis.ChainID, 10, nextAddr, nextPub, nextSigner) + header.DataHash = common.DataHashForEmptyTxs + + 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.Empty(t, collectEvents(t, ch, 50*time.Millisecond)) + p.DataStore.AssertNotCalled(t, "GetByHeight", mock.Anything, uint64(10)) +} + func TestP2PHandler_ProcessedHeightSkipsPreviouslyHandledBlocks(t *testing.T) { p := setupP2P(t) ctx := t.Context() diff --git a/block/internal/syncing/raft_retriever.go b/block/internal/syncing/raft_retriever.go index aaebb7a458..4cb15aec07 100644 --- a/block/internal/syncing/raft_retriever.go +++ b/block/internal/syncing/raft_retriever.go @@ -125,7 +125,7 @@ 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 { + if err := assertExpectedProposer(r.genesis, header.Height(), header.ProposerAddress, header.Signer.PubKey); err != nil { r.logger.Debug().Err(err).Msg("unexpected proposer") return nil } diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 0cfdf5c7ae..01bda4a8d9 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -297,6 +297,10 @@ function sidebarHome() { text: "Create genesis for your chain", link: "/guides/create-genesis", }, + { + text: "Rotate proposer key", + link: "/guides/operations/proposer-key-rotation", + }, { text: "Metrics", link: "/guides/metrics", diff --git a/docs/adr/adr-023-proposer-key-rotation.md b/docs/adr/adr-023-proposer-key-rotation.md new file mode 100644 index 0000000000..2c2934bf28 --- /dev/null +++ b/docs/adr/adr-023-proposer-key-rotation.md @@ -0,0 +1,165 @@ +# ADR 023: Proposer Key Rotation via Height-Based Schedule + +## Changelog + +- 2026-04-23: Implemented proposer key rotation through a height-indexed proposer schedule + +## Context + +ev-node historically treated the proposer as a single static identity embedded in genesis via `proposer_address`. +That assumption leaked into block production, DA submission, and sync validation. As a result, rotating a compromised +or operationally obsolete proposer key required out-of-band coordination and effectively behaved like a manual +re-genesis from the point of view of node operators. + +This was suboptimal for three reasons: + +1. It made proposer rotation operationally risky and easy to get wrong. +2. Fresh nodes syncing from genesis had no protocol-visible record of when the proposer changed. +3. Validation only pinned the proposer address, not the scheduled public key that should be producing blocks. + +## Alternative Approaches + +### 1. Manual key swap only + +Operators can stop the sequencer, swap the local signer, redistribute config, and restart nodes. +This is insufficient because the chain itself does not encode when the proposer changed, so historical sync +and validation become ambiguous. + +### 2. Re-issue a new genesis on each rotation + +This treats every proposer rotation like a chain restart: a new `chain_id`, state reset back to `initial_height`, +and existing block history discarded. It is operationally heavy, conflates upgrades with rotations, and breaks +continuity for nodes syncing historical data. + +### 3. Height-indexed proposer schedule in genesis (Chosen) + +Record proposer changes as an ordered schedule indexed by activation height. The `genesis.json` file is updated +with a new schedule entry and redistributed, but the chain keeps its `chain_id`, continues from the current +height, preserves all block history, and fresh nodes can still validate the entire chain end-to-end across +rotation boundaries. The rollout is still coordinated — every node must receive the updated `genesis.json` and +restart before the activation height — but none of the chain's state or provenance is reset. + +## Decision + +ev-node now supports proposer rotation through a `proposer_schedule` field in genesis. + +### What this is not + +This is **not** a re-genesis. Re-genesis — in the sense we mean it above — would involve issuing a new `chain_id`, +resetting height to `initial_height`, and discarding existing block history. Proposer key rotation does none of +that: the `chain_id` is unchanged, block height keeps progressing, all previous blocks remain valid, and fresh +nodes can sync the chain from genesis across any number of rotation boundaries. + +The `genesis.json` file itself is updated (a new `proposer_schedule` entry is appended) and operators must +restart every node to reload it. The file changes; the chain's state does not. + +Each entry declares: + +- `start_height` +- `address` +- `pub_key` + +The active proposer for block height `h` is the last entry whose `start_height <= h`. + +The legacy `proposer_address` field remains for backward compatibility. When no explicit schedule is present, +ev-node derives an implicit single-entry schedule beginning at `initial_height`. + +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 + +## Detailed Design + +### Data model + +Genesis gains: + +```json +"proposer_schedule": [ + { + "start_height": 1, + "address": "...", + "pub_key": "..." + }, + { + "start_height": 1250000, + "address": "...", + "pub_key": "..." + } +] +``` + +The existing `proposer_address` field is retained as a compatibility field and is normalized to the first +scheduled proposer when a schedule is present. + +### Validation rules + +The proposer schedule is now consulted in all proposer-sensitive paths: + +1. executor startup accepts any signer that appears somewhere in the schedule +2. block creation resolves the proposer for the exact height being produced +3. DA submission validates the configured signer against the scheduled proposer for each signed data height +4. sync validation validates incoming headers and signed data against the scheduled proposer for their heights + +This makes proposer rotation protocol-visible for both live nodes and nodes syncing historical data. + +### Operational procedure + +For a planned rotation: + +1. Choose activation height `H` +2. Add a new `proposer_schedule` entry with `start_height = H` +3. Distribute the updated genesis/config to node operators +4. Upgrade follower/full nodes before activation +5. Stop the old sequencer before `H` +6. Start the new sequencer with the replacement key at or after `H` + +The old proposer remains valid for heights `< H`, and the new proposer becomes valid at heights `>= H`. + +### Security considerations + +This design improves safety over address-only pinning by allowing validation against the scheduled public key. +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. + +### Testing + +Coverage includes: + +- genesis schedule validation and height resolution +- sync acceptance of scheduled proposer rotation +- DA submission using a rotated proposer key at the configured height +- executor block creation using the proposer scheduled for the produced height + +## Status + +Implemented + +## Consequences + +### Positive + +- proposer rotation is now part of the chain configuration rather than an operator convention +- fresh nodes can validate historical proposer changes from genesis +- sync and DA validation can pin scheduled public keys, not just addresses +- routine key rotation no longer requires a chain restart + +### Negative + +- proposer schedule changes are consensus-visible and require coordinated rollout +- operators must distribute updated genesis/config before activation height +- emergency rotation still requires prior scheduling or a later authority-based mechanism + +### Neutral + +- legacy single-proposer deployments continue to work without defining `proposer_schedule` + +## References + +- [pkg/genesis/genesis.go](../../pkg/genesis/genesis.go) +- [pkg/genesis/proposer_schedule.go](../../pkg/genesis/proposer_schedule.go) +- [block/internal/executing/executor.go](../../block/internal/executing/executor.go) +- [block/internal/syncing/assert.go](../../block/internal/syncing/assert.go) diff --git a/docs/guides/create-genesis.md b/docs/guides/create-genesis.md index 5886325dab..365b491b82 100644 --- a/docs/guides/create-genesis.md +++ b/docs/guides/create-genesis.md @@ -125,6 +125,10 @@ Before doing so, add a `da_start_height` field to the genesis file, that corresp jq '.da_start_height = 1' ~/.$CHAIN_ID/config/genesis.json > temp.json && mv temp.json ~/.$CHAIN_ID/config/genesis.json ``` +:::tip +If you want to plan a future proposer key migration without restarting the chain, define a `proposer_schedule` in your genesis and roll it out as a coordinated upgrade. See [Rotate proposer key](./operations/proposer-key-rotation.md). +::: + ## Summary By following these steps, you will set up the genesis for your chain, initialize the validator, add a genesis account, and start the chain. This guide provides a basic framework for configuring and starting your chain using the gm-world binary. Make sure you initialized your chain correctly, and use the `gmd` command for all operations. diff --git a/docs/guides/operations/proposer-key-rotation.md b/docs/guides/operations/proposer-key-rotation.md new file mode 100644 index 0000000000..3c5667d50c --- /dev/null +++ b/docs/guides/operations/proposer-key-rotation.md @@ -0,0 +1,186 @@ +# Rotate proposer key + +Use this guide to rotate a sequencer proposer key without restarting the chain. The active proposer is selected from `proposer_schedule` in `genesis.json` based on block height. + +## Before you start + +- This is a coordinated network upgrade. Every node must run a binary that supports `proposer_schedule`. +- Every node must use the same updated `genesis.json` before the activation height. +- `ev-node` loads `genesis.json` when the node starts. Updating the file on disk is not enough; you must restart nodes after replacing it. +- The old proposer key remains valid until the block before the activation height. If the old key cannot safely produce until then, stop the sequencer and coordinate operator recovery first. + +## How proposer rotation is stored in genesis + +`proposer_address` and `proposer_schedule[].address` are base64-encoded strings in JSON. + +```json +{ + "initial_height": 1, + "proposer_address": "0FQmA4Hn9dn8m4ZpM4+fV4e8KhkWjI4V2Vt1j9Qm5pA=", + "proposer_schedule": [ + { + "start_height": 1, + "address": "0FQmA4Hn9dn8m4ZpM4+fV4e8KhkWjI4V2Vt1j9Qm5pA=" + }, + { + "start_height": 125000, + "address": "Y7z5v9mQm4Nw6mD0a2yR9kD2B0qv5iJj1Q1R7gD4B7Q=" + } + ] +} +``` + +Rules enforced by `ev-node`: + +- `proposer_schedule[0].start_height` must equal `initial_height` +- schedule entries must be strictly increasing by `start_height` +- if `proposer_address` is set, it must match the first schedule entry + +Keep all earlier schedule entries. Fresh full nodes need them to validate historical blocks. + +## 1. Pick an activation height + +Choose an activation height `H` far enough in the future that you can distribute the updated genesis and restart every non-producing node before the cutover. + +```bash +ACTIVATION_HEIGHT=125000 +GENESIS="$HOME/.evnode/config/genesis.json" +INITIAL_HEIGHT="$(jq -r '.initial_height' "$GENESIS")" +``` + +## 2. Get the current and replacement proposer public keys + +For a file-based signer, the signer public key is stored in `signer.json` as base64. You only put the derived address into genesis, but you still need the public key once to compute that address. + +```bash +OLD_SIGNER_DIR="$HOME/.evnode/config" +NEW_SIGNER_DIR="/secure/path/new-signer" + +OLD_PROPOSER_PUBKEY="$(jq -r '.pub_key' "$OLD_SIGNER_DIR/signer.json")" +NEW_PROPOSER_PUBKEY="$(jq -r '.pub_key' "$NEW_SIGNER_DIR/signer.json")" +``` + +If you use a KMS-backed signer, export the replacement Ed25519 public key from your signer flow and base64-encode the raw public key bytes in the same format. The runtime configuration stays the same as in the [AWS KMS signer guide](./aws-kms-signer.md). + +## 3. Derive proposer addresses from the public keys + +`ev-node` derives the proposer address as `sha256(raw_pubkey_bytes)`. The helper below prints the address in the base64 format used by `genesis.json`. + +```bash +proposer_address() { + python3 - "$1" <<'PY' +import base64 +import hashlib +import sys + +pub_key = base64.b64decode(sys.argv[1]) +address = hashlib.sha256(pub_key).digest() +print(base64.b64encode(address).decode()) +PY +} + +OLD_PROPOSER_ADDRESS="$(proposer_address "$OLD_PROPOSER_PUBKEY")" +NEW_PROPOSER_ADDRESS="$(proposer_address "$NEW_PROPOSER_PUBKEY")" +``` + +## 4. Update `genesis.json` + +### If your chain only has `proposer_address` today + +Create an explicit schedule with the current proposer at `initial_height` and the new proposer at `ACTIVATION_HEIGHT`. + +```bash +jq \ + --arg old_addr "$OLD_PROPOSER_ADDRESS" \ + --arg new_addr "$NEW_PROPOSER_ADDRESS" \ + --argjson initial_height "$INITIAL_HEIGHT" \ + --argjson activation_height "$ACTIVATION_HEIGHT" \ + ' + .proposer_address = $old_addr + | .proposer_schedule = [ + { + start_height: $initial_height, + address: $old_addr + }, + { + start_height: $activation_height, + address: $new_addr + } + ] + ' "$GENESIS" > "$GENESIS.tmp" && mv "$GENESIS.tmp" "$GENESIS" +``` + +### If your chain already has `proposer_schedule` + +Append the new entry. Do not replace older entries, and make sure `ACTIVATION_HEIGHT` is greater than the last scheduled `start_height`. + +```bash +jq \ + --arg new_addr "$NEW_PROPOSER_ADDRESS" \ + --argjson activation_height "$ACTIVATION_HEIGHT" \ + ' + .proposer_schedule += [ + { + start_height: $activation_height, + address: $new_addr + } + ] + ' "$GENESIS" > "$GENESIS.tmp" && mv "$GENESIS.tmp" "$GENESIS" +``` + +Verify the result before you distribute it: + +```bash +jq '{initial_height, proposer_address, proposer_schedule}' "$GENESIS" +``` + +## 5. Distribute the updated genesis and restart followers + +Copy the same `genesis.json` to every full node, replica, and failover node. Restart them after copying the file so they load the updated schedule. + +Do this before the chain reaches `ACTIVATION_HEIGHT`. + +## 6. Cut over the sequencer + +Wait until the chain reaches `ACTIVATION_HEIGHT - 1`, then stop the old sequencer and start it with the replacement signer. + +Example with a file-based signer: + +```bash +evnode start \ + --home "$HOME/.evnode" \ + --evnode.node.aggregator \ + --evnode.signer.signer_type file \ + --evnode.signer.signer_path "$NEW_SIGNER_DIR" \ + --evnode.signer.passphrase "$SIGNER_PASSPHRASE" +``` + +If you run a custom chain binary such as `gmd` or `appd`, use the same start command you already use for the sequencer and only change the signer configuration. + +## 7. Verify the first post-upgrade block + +Fetch the header at `ACTIVATION_HEIGHT` or the next produced block and confirm that it carries the new proposer address. + +```bash +curl -s http://127.0.0.1:26657/header \ + -H 'Content-Type: application/json' \ + -d "{\"jsonrpc\":\"2.0\",\"method\":\"header\",\"params\":{\"height\":\"${ACTIVATION_HEIGHT}\"},\"id\":1}" \ + | jq . +``` + +Some RPC clients render binary fields as hex instead of base64. If needed, convert the base64 genesis address before comparing: + +```bash +python3 - "$NEW_PROPOSER_ADDRESS" <<'PY' +import base64 +import sys + +print("0x" + base64.b64decode(sys.argv[1]).hex()) +PY +``` + +If the node at `ACTIVATION_HEIGHT` is still signed by the old key, stop block production and check three things first: + +1. every node was restarted after receiving the updated genesis +2. `proposer_schedule` contains the new entry at the intended height +3. the sequencer is actually running with the replacement signer diff --git a/docs/guides/operations/upgrades.md b/docs/guides/operations/upgrades.md index 0027f13c36..ac5f6dcbf1 100644 --- a/docs/guides/operations/upgrades.md +++ b/docs/guides/operations/upgrades.md @@ -38,6 +38,12 @@ May require state migration or coordinated network upgrade. 5. Run any migration scripts 6. Restart +### Proposer Key Rotation + +Rotating the proposer key is a coordinated upgrade even when the chain does not restart. All nodes must receive the same updated `genesis.json`, restart to load it, and be ready before the scheduled activation height. + +Use [Rotate proposer key](./proposer-key-rotation.md) for the exact `proposer_schedule` format, genesis update steps, and cutover procedure. + ## ev-node Upgrades ### Check Current Version diff --git a/node/failover.go b/node/failover.go index 42dac4e8bc..752b6aaba3 100644 --- a/node/failover.go +++ b/node/failover.go @@ -139,7 +139,7 @@ func setupFailoverState( headerSyncService.Store(), dataSyncService.Store(), p2pClient, - genesis.ProposerAddress, + genesis.InitialProposerAddress(), logger, nodeConfig, bestKnownHeightProvider, diff --git a/node/full.go b/node/full.go index bd44f9ef42..5d13beebbd 100644 --- a/node/full.go +++ b/node/full.go @@ -78,7 +78,7 @@ func newFullNode( logger zerolog.Logger, nodeOpts NodeOptions, ) (fn *FullNode, err error) { - logger.Debug().Hex("address", genesis.ProposerAddress).Msg("Proposer address") + logger.Debug().Hex("address", genesis.InitialProposerAddress()).Msg("Initial proposer address") blockMetrics, _ := metricsProvider(genesis.ChainID) diff --git a/pkg/genesis/genesis.go b/pkg/genesis/genesis.go index e1a401d9fc..1cbe506e1c 100644 --- a/pkg/genesis/genesis.go +++ b/pkg/genesis/genesis.go @@ -1,6 +1,7 @@ package genesis import ( + "bytes" "fmt" "time" ) @@ -11,10 +12,11 @@ const ChainIDFlag = "chain_id" // This genesis struct only contains the fields required by evolve. // The app state or other fields are not included here. type Genesis struct { - ChainID string `json:"chain_id"` - StartTime time.Time `json:"start_time"` - InitialHeight uint64 `json:"initial_height"` - ProposerAddress []byte `json:"proposer_address"` + ChainID string `json:"chain_id"` + StartTime time.Time `json:"start_time"` + InitialHeight uint64 `json:"initial_height"` + ProposerAddress []byte `json:"proposer_address"` + ProposerSchedule []ProposerScheduleEntry `json:"proposer_schedule,omitempty"` // DAStartHeight corresponds to the height at which the first DA header/data has been published. // This value is meant to be updated after genesis and shared to all syncing nodes for speeding up syncing via DA. DAStartHeight uint64 `json:"da_start_height"` @@ -56,8 +58,28 @@ func (g Genesis) Validate() error { return fmt.Errorf("start_time cannot be zero time") } - if g.ProposerAddress == nil { - return fmt.Errorf("proposer_address cannot be nil") + if len(g.ProposerSchedule) == 0 { + if len(g.ProposerAddress) == 0 { + return fmt.Errorf("proposer_address cannot be empty when proposer_schedule is unset") + } + } else { + if err := g.ProposerSchedule[0].validate(g.InitialHeight); err != nil { + return fmt.Errorf("invalid proposer_schedule[0]: %w", err) + } + if g.ProposerSchedule[0].StartHeight != g.InitialHeight { + return fmt.Errorf("proposer_schedule[0].start_height must equal initial_height (%d), got %d", g.InitialHeight, g.ProposerSchedule[0].StartHeight) + } + for i := 1; i < len(g.ProposerSchedule); i++ { + if err := g.ProposerSchedule[i].validate(g.InitialHeight); err != nil { + return fmt.Errorf("invalid proposer_schedule[%d]: %w", i, err) + } + if g.ProposerSchedule[i].StartHeight <= g.ProposerSchedule[i-1].StartHeight { + return fmt.Errorf("proposer_schedule must be strictly increasing: entry %d start_height %d is not greater than previous %d", i, g.ProposerSchedule[i].StartHeight, g.ProposerSchedule[i-1].StartHeight) + } + } + if len(g.ProposerAddress) > 0 && !bytes.Equal(g.ProposerAddress, g.ProposerSchedule[0].Address) { + return fmt.Errorf("proposer_address must match proposer_schedule[0].address") + } } if g.DAEpochForcedInclusion < 1 { diff --git a/pkg/genesis/genesis_test.go b/pkg/genesis/genesis_test.go index da3cc14b1f..a5aca88586 100644 --- a/pkg/genesis/genesis_test.go +++ b/pkg/genesis/genesis_test.go @@ -1,10 +1,13 @@ package genesis import ( + "crypto/rand" "testing" "time" + "github.com/libp2p/go-libp2p/core/crypto" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNewGenesis(t *testing.T) { @@ -135,3 +138,175 @@ func TestGenesis_Validate(t *testing.T) { }) } } + +func TestGenesis_ValidateProposerSchedule(t *testing.T) { + validTime := time.Now().UTC() + + newEntry := func(startHeight uint64) (ProposerScheduleEntry, crypto.PubKey) { + _, pub, err := crypto.GenerateEd25519Key(rand.Reader) + require.NoError(t, err) + entry, err := NewProposerScheduleEntry(startHeight, pub) + require.NoError(t, err) + return entry, pub + } + + entry1, _ := newEntry(1) + entry10, _ := newEntry(10) + entry20, _ := newEntry(20) + + tests := []struct { + name string + mutate func() Genesis + wantErr string + }{ + { + name: "valid - schedule without proposer_address", + mutate: func() Genesis { + return Genesis{ + ChainID: "c", + StartTime: validTime, + InitialHeight: 1, + ProposerSchedule: []ProposerScheduleEntry{entry1, entry10}, + DAEpochForcedInclusion: 1, + } + }, + }, + { + name: "valid - schedule with matching proposer_address", + mutate: func() Genesis { + return Genesis{ + ChainID: "c", + StartTime: validTime, + InitialHeight: 1, + ProposerAddress: entry1.Address, + ProposerSchedule: []ProposerScheduleEntry{entry1, entry10}, + DAEpochForcedInclusion: 1, + } + }, + }, + { + name: "invalid - first entry start_height != initial_height", + mutate: func() Genesis { + return Genesis{ + ChainID: "c", + StartTime: validTime, + InitialHeight: 5, + ProposerSchedule: []ProposerScheduleEntry{entry10, entry20}, + DAEpochForcedInclusion: 1, + } + }, + wantErr: "start_height must equal initial_height", + }, + { + name: "invalid - first entry start_height below initial_height", + mutate: func() Genesis { + return Genesis{ + ChainID: "c", + StartTime: validTime, + InitialHeight: 5, + ProposerSchedule: []ProposerScheduleEntry{entry1, entry10}, + DAEpochForcedInclusion: 1, + } + }, + wantErr: "start_height must be >= initial_height", + }, + { + name: "invalid - non-increasing (equal start_heights)", + mutate: func() Genesis { + return Genesis{ + ChainID: "c", + StartTime: validTime, + InitialHeight: 1, + ProposerSchedule: []ProposerScheduleEntry{entry1, entry1}, + DAEpochForcedInclusion: 1, + } + }, + wantErr: "strictly increasing", + }, + { + name: "invalid - non-increasing (decreasing start_heights)", + mutate: func() Genesis { + return Genesis{ + ChainID: "c", + StartTime: validTime, + InitialHeight: 1, + ProposerSchedule: []ProposerScheduleEntry{entry20, entry10}, + DAEpochForcedInclusion: 1, + } + }, + wantErr: "start_height must equal initial_height", + }, + { + name: "invalid - entry address does not match pub_key", + mutate: func() Genesis { + tampered := entry10 + tampered.Address = append([]byte(nil), entry10.Address...) + tampered.Address[0] ^= 0xFF + return Genesis{ + ChainID: "c", + StartTime: validTime, + InitialHeight: 1, + ProposerSchedule: []ProposerScheduleEntry{entry1, tampered}, + DAEpochForcedInclusion: 1, + } + }, + wantErr: "address does not match pub_key", + }, + { + name: "invalid - proposer_address mismatches schedule[0].address", + mutate: func() Genesis { + return Genesis{ + ChainID: "c", + StartTime: validTime, + InitialHeight: 1, + ProposerAddress: entry10.Address, + ProposerSchedule: []ProposerScheduleEntry{entry1, entry10}, + DAEpochForcedInclusion: 1, + } + }, + wantErr: "proposer_address must match proposer_schedule[0].address", + }, + { + name: "invalid - empty address in entry", + mutate: func() Genesis { + empty := entry10 + empty.Address = nil + return Genesis{ + ChainID: "c", + StartTime: validTime, + InitialHeight: 1, + ProposerSchedule: []ProposerScheduleEntry{entry1, empty}, + DAEpochForcedInclusion: 1, + } + }, + wantErr: "address cannot be empty", + }, + { + name: "invalid - malformed pub_key bytes", + mutate: func() Genesis { + bad := entry10 + bad.PubKey = []byte{0x00, 0x01, 0x02} + return Genesis{ + ChainID: "c", + StartTime: validTime, + InitialHeight: 1, + ProposerSchedule: []ProposerScheduleEntry{entry1, bad}, + DAEpochForcedInclusion: 1, + } + }, + wantErr: "unmarshal proposer pub_key", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.mutate().Validate() + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + }) + } +} diff --git a/pkg/genesis/io.go b/pkg/genesis/io.go index 8c9d88e955..dcf9048aa6 100644 --- a/pkg/genesis/io.go +++ b/pkg/genesis/io.go @@ -72,12 +72,12 @@ func LoadGenesis(genesisPath string) (Genesis, error) { return Genesis{}, err } - return genesis, nil + return genesis.normalized(), nil } // Save saves the genesis state to the specified file path. func (g Genesis) Save(genesisPath string) error { - genesisJSON, err := json.MarshalIndent(g, "", " ") + genesisJSON, err := json.MarshalIndent(g.normalized(), "", " ") if err != nil { return fmt.Errorf("failed to marshal genesis state: %w", err) } diff --git a/pkg/genesis/proposer_schedule.go b/pkg/genesis/proposer_schedule.go new file mode 100644 index 0000000000..c53684535a --- /dev/null +++ b/pkg/genesis/proposer_schedule.go @@ -0,0 +1,213 @@ +package genesis + +import ( + "bytes" + "crypto/sha256" + "fmt" + + "github.com/libp2p/go-libp2p/core/crypto" +) + +// ProposerScheduleEntry declares the proposer address that becomes active at start_height. +// PubKey is optional and can be used to pin the exact key material for a schedule entry. +type ProposerScheduleEntry struct { + StartHeight uint64 `json:"start_height"` + Address []byte `json:"address"` + PubKey []byte `json:"pub_key,omitempty"` +} + +// NewProposerScheduleEntry creates a proposer schedule entry from a libp2p public key. +func NewProposerScheduleEntry(startHeight uint64, pubKey crypto.PubKey) (ProposerScheduleEntry, error) { + if pubKey == nil { + return ProposerScheduleEntry{}, fmt.Errorf("proposer pub_key cannot be nil") + } + + marshalledPubKey, err := crypto.MarshalPublicKey(pubKey) + if err != nil { + return ProposerScheduleEntry{}, fmt.Errorf("marshal proposer pub_key: %w", err) + } + + return ProposerScheduleEntry{ + StartHeight: startHeight, + Address: proposerKeyAddress(pubKey), + PubKey: marshalledPubKey, + }, nil +} + +// PublicKey unmarshals the configured proposer public key. Address-only schedule +// entries may omit the pubkey and will return nil, nil here. +func (e ProposerScheduleEntry) PublicKey() (crypto.PubKey, error) { + if len(e.PubKey) == 0 { + return nil, nil + } + + pubKey, err := crypto.UnmarshalPublicKey(e.PubKey) + if err != nil { + return nil, fmt.Errorf("unmarshal proposer pub_key: %w", err) + } + + return pubKey, nil +} + +func (e ProposerScheduleEntry) validate(initialHeight uint64) error { + if e.StartHeight < initialHeight { + return fmt.Errorf("proposer schedule start_height must be >= initial_height (%d), got %d", initialHeight, e.StartHeight) + } + + if len(e.Address) == 0 { + return fmt.Errorf("proposer schedule address cannot be empty") + } + + if len(e.PubKey) == 0 { + return nil + } + + pubKey, err := e.PublicKey() + if err != nil { + return err + } + + expectedAddress := proposerKeyAddress(pubKey) + if !bytes.Equal(expectedAddress, e.Address) { + return fmt.Errorf("proposer schedule address does not match pub_key: got %x, expected %x", e.Address, expectedAddress) + } + + return nil +} + +// EffectiveProposerSchedule returns the explicit proposer schedule when present, +// or derives a legacy single-entry schedule from proposer_address. +func (g Genesis) EffectiveProposerSchedule() []ProposerScheduleEntry { + if len(g.ProposerSchedule) > 0 { + out := make([]ProposerScheduleEntry, len(g.ProposerSchedule)) + for i, entry := range g.ProposerSchedule { + out[i] = ProposerScheduleEntry{ + StartHeight: entry.StartHeight, + Address: bytes.Clone(entry.Address), + PubKey: bytes.Clone(entry.PubKey), + } + } + return out + } + + if len(g.ProposerAddress) == 0 { + return nil + } + + return []ProposerScheduleEntry{{ + StartHeight: g.InitialHeight, + Address: bytes.Clone(g.ProposerAddress), + }} +} + +// InitialProposerAddress returns the first proposer address for compatibility +// with code paths that still surface a single address externally. +func (g Genesis) InitialProposerAddress() []byte { + entry, err := g.ProposerAtHeight(g.InitialHeight) + if err != nil { + return nil + } + + return bytes.Clone(entry.Address) +} + +func (g Genesis) normalized() Genesis { + normalized := g + if len(normalized.ProposerAddress) == 0 { + normalized.ProposerAddress = normalized.InitialProposerAddress() + } + return normalized +} + +// HasScheduledProposer reports whether the address appears in the effective proposer schedule. +func (g Genesis) HasScheduledProposer(address []byte) bool { + for _, entry := range g.EffectiveProposerSchedule() { + if bytes.Equal(entry.Address, address) { + return true + } + } + return false +} + +// ProposerAtHeight resolves the proposer that is active for the given block height. +func (g Genesis) ProposerAtHeight(height uint64) (ProposerScheduleEntry, error) { + schedule := g.EffectiveProposerSchedule() + if len(schedule) == 0 { + return ProposerScheduleEntry{}, fmt.Errorf("no proposer configured") + } + + if height < schedule[0].StartHeight { + return ProposerScheduleEntry{}, fmt.Errorf("no proposer configured for height %d before start_height %d", height, schedule[0].StartHeight) + } + + entry := schedule[0] + for i := 1; i < len(schedule); i++ { + if height < schedule[i].StartHeight { + break + } + entry = schedule[i] + } + + return ProposerScheduleEntry{ + StartHeight: entry.StartHeight, + Address: bytes.Clone(entry.Address), + PubKey: bytes.Clone(entry.PubKey), + }, nil +} + +// ValidateProposer checks that the provided proposer address and public key match +// the proposer schedule entry active at the given height. +func (g Genesis) ValidateProposer(height uint64, address []byte, pubKey crypto.PubKey) error { + entry, err := g.ProposerAtHeight(height) + if err != nil { + return err + } + + if !bytes.Equal(entry.Address, address) { + return fmt.Errorf("unexpected proposer at height %d: got %x, expected %x", height, address, entry.Address) + } + + if len(entry.PubKey) == 0 { + // Address-only schedule entry. Without a pinned pubkey we still + // have to bind the caller-provided pubkey to the scheduled + // address, otherwise a forger can pair the scheduled address + // with an arbitrary key and later satisfy signature checks that + // trust Signer.PubKey. + if pubKey != nil { + derived := proposerKeyAddress(pubKey) + if !bytes.Equal(entry.Address, derived) { + return fmt.Errorf("proposer pub_key does not match scheduled address at height %d", height) + } + } + return nil + } + + if pubKey == nil { + return fmt.Errorf("missing proposer pub_key at height %d", height) + } + + marshalledPubKey, err := crypto.MarshalPublicKey(pubKey) + if err != nil { + return fmt.Errorf("marshal proposer pub_key: %w", err) + } + + if !bytes.Equal(entry.PubKey, marshalledPubKey) { + return fmt.Errorf("unexpected proposer pub_key at height %d", height) + } + + return nil +} + +func proposerKeyAddress(pubKey crypto.PubKey) []byte { + if pubKey == nil { + return nil + } + + raw, err := pubKey.Raw() + if err != nil { + return nil + } + + sum := sha256.Sum256(raw) + return sum[:] +} diff --git a/pkg/genesis/proposer_schedule_test.go b/pkg/genesis/proposer_schedule_test.go new file mode 100644 index 0000000000..950481526f --- /dev/null +++ b/pkg/genesis/proposer_schedule_test.go @@ -0,0 +1,340 @@ +package genesis + +import ( + "bytes" + "crypto/rand" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/stretchr/testify/require" + + "github.com/evstack/ev-node/pkg/signer/noop" +) + +// testGenesisStartTime is a fixed timestamp for genesis fixtures so tests do +// not depend on wall-clock time. +var testGenesisStartTime = time.Unix(1_700_000_000, 0).UTC() + +func makeProposerScheduleEntry(t *testing.T, startHeight uint64) (ProposerScheduleEntry, crypto.PubKey) { + t.Helper() + + _, pubKey, err := crypto.GenerateEd25519Key(rand.Reader) + require.NoError(t, err) + + entry, err := NewProposerScheduleEntry(startHeight, pubKey) + require.NoError(t, err) + + return entry, pubKey +} + +func TestGenesisProposerAtHeight(t *testing.T) { + entry1, _ := makeProposerScheduleEntry(t, 3) + entry2, _ := makeProposerScheduleEntry(t, 10) + + genesis := Genesis{ + ChainID: "test-chain", + StartTime: testGenesisStartTime, + InitialHeight: 3, + ProposerSchedule: []ProposerScheduleEntry{entry1, entry2}, + DAEpochForcedInclusion: 1, + } + + require.NoError(t, genesis.Validate()) + + proposer, err := genesis.ProposerAtHeight(3) + require.NoError(t, err) + require.Equal(t, entry1.Address, proposer.Address) + + proposer, err = genesis.ProposerAtHeight(9) + require.NoError(t, err) + require.Equal(t, entry1.Address, proposer.Address) + + proposer, err = genesis.ProposerAtHeight(10) + require.NoError(t, err) + require.Equal(t, entry2.Address, proposer.Address) +} + +func TestGenesisValidateProposerScheduleWithPinnedPubKey(t *testing.T) { + entry1, pubKey1 := makeProposerScheduleEntry(t, 1) + entry2, pubKey2 := makeProposerScheduleEntry(t, 20) + + genesis := Genesis{ + ChainID: "test-chain", + StartTime: testGenesisStartTime, + InitialHeight: 1, + ProposerSchedule: []ProposerScheduleEntry{entry1, entry2}, + DAEpochForcedInclusion: 1, + } + + require.NoError(t, genesis.Validate()) + require.NoError(t, genesis.ValidateProposer(1, entry1.Address, pubKey1)) + require.NoError(t, genesis.ValidateProposer(21, entry2.Address, pubKey2)) + require.Error(t, genesis.ValidateProposer(21, entry2.Address, pubKey1)) +} + +func TestGenesisValidateAddressOnlyProposerSchedule(t *testing.T) { + entry1, pubKey1 := makeProposerScheduleEntry(t, 1) + entry2, pubKey2 := makeProposerScheduleEntry(t, 20) + entry1.PubKey = nil + entry2.PubKey = nil + + genesis := Genesis{ + ChainID: "test-chain", + StartTime: testGenesisStartTime, + InitialHeight: 1, + ProposerSchedule: []ProposerScheduleEntry{entry1, entry2}, + DAEpochForcedInclusion: 1, + } + + require.NoError(t, genesis.Validate()) + require.NoError(t, genesis.ValidateProposer(1, entry1.Address, pubKey1)) + require.NoError(t, genesis.ValidateProposer(21, entry2.Address, pubKey2)) +} + +func TestNewProposerScheduleEntry_NilPubKey(t *testing.T) { + _, err := NewProposerScheduleEntry(1, nil) + require.Error(t, err) +} + +func TestProposerAtHeight_BeforeFirstStartHeight(t *testing.T) { + entry, _ := makeProposerScheduleEntry(t, 5) + genesis := Genesis{ + ChainID: "c", + StartTime: testGenesisStartTime, + InitialHeight: 5, + ProposerSchedule: []ProposerScheduleEntry{entry}, + DAEpochForcedInclusion: 1, + } + + _, err := genesis.ProposerAtHeight(4) + require.Error(t, err) + require.Contains(t, err.Error(), "before start_height") +} + +func TestProposerAtHeight_NoProposerConfigured(t *testing.T) { + genesis := Genesis{ChainID: "c", InitialHeight: 1} + _, err := genesis.ProposerAtHeight(1) + require.Error(t, err) + require.Contains(t, err.Error(), "no proposer configured") +} + +func TestProposerAtHeight_ReturnedEntryIsCopy(t *testing.T) { + entry, _ := makeProposerScheduleEntry(t, 1) + genesis := Genesis{ + ChainID: "c", + StartTime: testGenesisStartTime, + InitialHeight: 1, + ProposerSchedule: []ProposerScheduleEntry{entry}, + DAEpochForcedInclusion: 1, + } + + got, err := genesis.ProposerAtHeight(1) + require.NoError(t, err) + got.Address[0] ^= 0xFF + got.PubKey[0] ^= 0xFF + + same, err := genesis.ProposerAtHeight(1) + require.NoError(t, err) + require.Equal(t, entry.Address, same.Address) + require.Equal(t, entry.PubKey, same.PubKey) +} + +func TestValidateProposer_WrongAddress(t *testing.T) { + entry, pubKey := makeProposerScheduleEntry(t, 1) + other, _ := makeProposerScheduleEntry(t, 1) + genesis := Genesis{ + ChainID: "c", + StartTime: testGenesisStartTime, + InitialHeight: 1, + ProposerSchedule: []ProposerScheduleEntry{entry}, + DAEpochForcedInclusion: 1, + } + + err := genesis.ValidateProposer(1, other.Address, pubKey) + require.Error(t, err) + require.Contains(t, err.Error(), "unexpected proposer at height 1") +} + +func TestValidateProposer_MissingPubKey(t *testing.T) { + entry, _ := makeProposerScheduleEntry(t, 1) + genesis := Genesis{ + ChainID: "c", + StartTime: testGenesisStartTime, + InitialHeight: 1, + ProposerSchedule: []ProposerScheduleEntry{entry}, + DAEpochForcedInclusion: 1, + } + + err := genesis.ValidateProposer(1, entry.Address, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "missing proposer pub_key") +} + +// TestValidateProposer_AddressOnly_RejectsForgedPubKey ensures that an address-only +// schedule entry still binds the caller-provided pubkey to the scheduled address. +// Without this check, a forger could claim Signer.Address = scheduled_addr with an +// arbitrary Signer.PubKey and later pass signature validation that trusts that pubkey. +func TestValidateProposer_AddressOnly_RejectsForgedPubKey(t *testing.T) { + scheduled, _ := makeProposerScheduleEntry(t, 1) + _, attackerPub := makeProposerScheduleEntry(t, 1) + + scheduled.PubKey = nil // address-only entry + + genesis := Genesis{ + ChainID: "c", + StartTime: testGenesisStartTime, + InitialHeight: 1, + ProposerSchedule: []ProposerScheduleEntry{scheduled}, + DAEpochForcedInclusion: 1, + } + + // Scheduled address paired with a different pubkey must be rejected. + err := genesis.ValidateProposer(1, scheduled.Address, attackerPub) + require.Error(t, err) + require.Contains(t, err.Error(), "does not match scheduled address") +} + +func TestValidateProposer_UsesActiveEntryAtHeight(t *testing.T) { + entry1, pub1 := makeProposerScheduleEntry(t, 1) + entry2, pub2 := makeProposerScheduleEntry(t, 10) + genesis := Genesis{ + ChainID: "c", + StartTime: testGenesisStartTime, + InitialHeight: 1, + ProposerSchedule: []ProposerScheduleEntry{entry1, entry2}, + DAEpochForcedInclusion: 1, + } + + // entry2 signer trying to sign height within entry1's active range must fail. + require.Error(t, genesis.ValidateProposer(9, entry2.Address, pub2)) + // entry1 signer trying to sign height within entry2's active range must fail. + require.Error(t, genesis.ValidateProposer(10, entry1.Address, pub1)) +} + +func TestHasScheduledProposer(t *testing.T) { + entry1, _ := makeProposerScheduleEntry(t, 1) + entry2, _ := makeProposerScheduleEntry(t, 10) + unknown, _ := makeProposerScheduleEntry(t, 99) + + explicit := Genesis{ + ChainID: "c", + StartTime: testGenesisStartTime, + InitialHeight: 1, + ProposerSchedule: []ProposerScheduleEntry{entry1, entry2}, + DAEpochForcedInclusion: 1, + } + require.True(t, explicit.HasScheduledProposer(entry1.Address)) + require.True(t, explicit.HasScheduledProposer(entry2.Address)) + require.False(t, explicit.HasScheduledProposer(unknown.Address)) + + legacy := Genesis{ + ChainID: "c", + StartTime: testGenesisStartTime, + InitialHeight: 1, + ProposerAddress: entry1.Address, + DAEpochForcedInclusion: 1, + } + require.True(t, legacy.HasScheduledProposer(entry1.Address)) + require.False(t, legacy.HasScheduledProposer(entry2.Address)) + + empty := Genesis{ChainID: "c", InitialHeight: 1} + require.False(t, empty.HasScheduledProposer(entry1.Address)) +} + +func TestEffectiveProposerSchedule_ExplicitScheduleIsDeepCopy(t *testing.T) { + entry1, _ := makeProposerScheduleEntry(t, 1) + entry2, _ := makeProposerScheduleEntry(t, 10) + origAddr := bytes.Clone(entry1.Address) + origPub := bytes.Clone(entry1.PubKey) + + genesis := Genesis{ + ChainID: "c", + StartTime: testGenesisStartTime, + InitialHeight: 1, + ProposerSchedule: []ProposerScheduleEntry{entry1, entry2}, + DAEpochForcedInclusion: 1, + } + + // Mutating returned byte slices must not corrupt the genesis-backed data. + got := genesis.EffectiveProposerSchedule() + got[0].Address[0] ^= 0xFF + got[0].PubKey[0] ^= 0xFF + + require.Equal(t, origAddr, genesis.ProposerSchedule[0].Address) + require.Equal(t, origPub, genesis.ProposerSchedule[0].PubKey) +} + +func TestEffectiveProposerSchedule_LegacyFallback(t *testing.T) { + addr := []byte("some-address-bytes") + legacy := Genesis{ + ChainID: "c", + InitialHeight: 7, + ProposerAddress: addr, + } + schedule := legacy.EffectiveProposerSchedule() + require.Len(t, schedule, 1) + require.Equal(t, uint64(7), schedule[0].StartHeight) + require.Equal(t, addr, schedule[0].Address) + require.Empty(t, schedule[0].PubKey) + + // mutating the derived slice must not affect the genesis backing data. + schedule[0].Address[0] ^= 0xFF + require.Equal(t, addr, legacy.ProposerAddress) +} + +func TestEffectiveProposerSchedule_Empty(t *testing.T) { + require.Nil(t, Genesis{}.EffectiveProposerSchedule()) +} + +func TestInitialProposerAddress_EmptyGenesisReturnsNil(t *testing.T) { + require.Nil(t, Genesis{InitialHeight: 1}.InitialProposerAddress()) +} + +// TestProposerKeyAddressMatchesSignerGetAddress pins the invariant that the +// genesis-side address derivation matches the signer implementations. If a +// signer ever changes its address formula this test will fail and flag the +// break instead of silently producing rejected blocks after a key rotation. +func TestProposerKeyAddressMatchesSignerGetAddress(t *testing.T) { + priv, pub, err := crypto.GenerateEd25519Key(rand.Reader) + require.NoError(t, err) + + s, err := noop.NewNoopSigner(priv) + require.NoError(t, err) + + signerAddr, err := s.GetAddress() + require.NoError(t, err) + + genesisAddr := proposerKeyAddress(pub) + require.Equal(t, signerAddr, genesisAddr) + + entry, err := NewProposerScheduleEntry(1, pub) + require.NoError(t, err) + require.Equal(t, signerAddr, entry.Address) +} + +func TestLoadGenesisNormalizesLegacyProposerAddressFromSchedule(t *testing.T) { + entry1, _ := makeProposerScheduleEntry(t, 1) + entry2, _ := makeProposerScheduleEntry(t, 50) + + rawGenesis := Genesis{ + ChainID: "test-chain", + StartTime: testGenesisStartTime, + InitialHeight: 1, + ProposerSchedule: []ProposerScheduleEntry{entry1, entry2}, + DAEpochForcedInclusion: 1, + } + + genesisPath := filepath.Join(t.TempDir(), "genesis.json") + genesisJSON, err := json.Marshal(rawGenesis) + require.NoError(t, err) + require.NoError(t, os.WriteFile(genesisPath, genesisJSON, 0o600)) + + loaded, err := LoadGenesis(genesisPath) + require.NoError(t, err) + require.Equal(t, entry1.Address, loaded.ProposerAddress) + require.Equal(t, rawGenesis.ProposerSchedule, loaded.ProposerSchedule) +}