@@ -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+ }
0 commit comments