Skip to content

feat: add key rotation#3282

Open
tac0turtle wants to merge 3 commits intomainfrom
marko/key_rotation
Open

feat: add key rotation#3282
tac0turtle wants to merge 3 commits intomainfrom
marko/key_rotation

Conversation

@tac0turtle
Copy link
Copy Markdown
Contributor

@tac0turtle tac0turtle commented Apr 23, 2026

Overview

Add key rotation for proposers

Summary by CodeRabbit

  • New Features

    • Proposer key rotation via a genesis-provided proposer_schedule; active proposer is resolved per block height and enforced during block production and data submission.
  • Bug Fixes / Validation

    • Startup and runtime now validate that configured signers match the scheduled proposer for the relevant heights, producing height-specific errors on mismatch.
  • Tests

    • Added unit tests covering proposer-schedule resolution, startup rejection of unscheduled signers, and height-based enforcement.
  • Documentation

    • Added operational guide and ADR for proposer key rotation and rollout procedures.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 23, 2026

📝 Walkthrough

Walkthrough

Proposer key rotation added via a height-indexed ProposerSchedule in genesis; runtime code resolves and validates proposers per-height for block production, DA submission, and sync validation instead of using a single static proposer address. Genesis is normalized/validated for schedules.

Changes

Cohort / File(s) Summary
Core Proposer Schedule Implementation
pkg/genesis/proposer_schedule.go, pkg/genesis/proposer_schedule_test.go
Adds ProposerScheduleEntry, schedule normalization, resolver (ProposerAtHeight), membership (HasScheduledProposer), and validator (ValidateProposer). Tests cover selection, pubkey binding, fallback legacy behavior, and defensive copies.
Genesis Structure & IO
pkg/genesis/genesis.go, pkg/genesis/io.go, pkg/genesis/genesis_test.go
Adds Genesis.ProposerSchedule field, enforces schedule validation rules in Validate(), and normalizes persisted/loaded genesis to canonical schedule form; tests for validation failures/successes.
Block Execution & Tests
block/internal/executing/executor.go, block/internal/executing/executor_test.go
Executor startup and CreateBlock now check schedule membership and resolve the proposer for the target height; signer pubkey is validated against schedule when present. New tests exercise startup rejection, correct proposer selection, and rejection when signer is not scheduled.
DA Submission & Tests
block/internal/submitting/da_submitter.go, block/internal/submitting/da_submitter_test.go
signData validates signer against the proposer schedule for the data's height (uses ValidateProposer) instead of comparing to a single genesis address. Test ensures submitter uses scheduled proposer per-height.
Sync Validation & Tests
block/internal/syncing/assert.go, block/internal/syncing/da_retriever.go, block/internal/syncing/p2p_handler.go, block/internal/syncing/raft_retriever.go, block/internal/syncing/p2p_handler_test.go
Sync flow now validates headers/signed data with height-aware assertExpectedProposer that delegates to genesis.ValidateProposer (includes height and signer pubkey). Tests cover acceptance/rejection around activation heights.
Node Initialization
node/failover.go, node/full.go
Switched uses of legacy ProposerAddress access to genesis.InitialProposerAddress() for compatibility with schedule-normalized genesis.
Documentation
docs/.vitepress/config.ts, docs/adr/adr-023-proposer-key-rotation.md, docs/guides/create-genesis.md, docs/guides/operations/proposer-key-rotation.md, docs/guides/operations/upgrades.md
Adds ADR and detailed operational guide for proposer key rotation, updates VitePress sidebar and upgrade docs with rotation workflow, samples, and rollout checklist.

Sequence Diagram

sequenceDiagram
    participant G as Genesis (ProposerSchedule)
    participant Exec as Executor
    participant Signer as Signer
    participant DA as DA Submitter
    participant Sync as Sync Validator

    Exec->>G: LoadGenesis() -> EffectiveProposerSchedule
    Exec->>G: HasScheduledProposer(signerAddr) at startup
    G-->>Exec: membership OK/Err

    Exec->>G: ProposerAtHeight(H)
    G-->>Exec: ProposerEntry (address, pubkey?)

    Exec->>Signer: GetAddress()/PubKey()
    Signer-->>Exec: addr, pubkey

    Exec->>G: ValidateProposer(H, addr, pubkey)
    G-->>Exec: Validated / Error

    Exec->>DA: SubmitData(height H')
    DA->>G: ProposerAtHeight(H')
    DA->>Signer: SignData()
    DA->>G: ValidateProposer(H', signerAddr, pubKey)
    G-->>DA: Validated / Error

    Sync->>G: ProposerAtHeight(H)
    Sync->>Sync: Validate header signer via ValidateProposer(H, addr, pubKey)
    G-->>Sync: Validated / Error
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • julienrbrt
  • chatton
  • alpe

Poem

🐇 I hopped through genesis, keys in tow,
Heights marked bright where new proposers go,
Schedules set, old bytes made clear—
I sign the blocks and cheer, my dear!
✨🌿

🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (2 warnings, 1 inconclusive)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description is minimal and lacks required details about the change's rationale, implementation approach, and testing scope despite the template requesting comprehensive overview information. Expand the description to explain the key rotation mechanism, why it's needed, how the proposer schedule works, and what testing validates the implementation.
Docstring Coverage ⚠️ Warning Docstring coverage is 21.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'feat: add key rotation' is vague and overly generic, lacking specificity about what exactly is being rotated or the scope of changes. Consider a more descriptive title such as 'feat: implement proposer key rotation via genesis schedule' to clarify the feature's scope and primary change.
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch marko/key_rotation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 23, 2026

The latest Buf updates on your PR. Results from workflow CI / buf-check (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed⏩ skipped✅ passed✅ passedApr 23, 2026, 3:08 PM

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 23, 2026

Claude encountered an error —— View job


I'll analyze this and get back to you.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 23, 2026

PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://evstack.github.io/docs-preview/pr-3282/

Built to branch main at 2026-04-23 15:08 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 23, 2026

Codecov Report

❌ Patch coverage is 86.18421% with 21 lines in your changes missing coverage. Please review.
✅ Project coverage is 62.82%. Comparing base (3efcf93) to head (6ad13f2).

Files with missing lines Patch % Lines
pkg/genesis/proposer_schedule.go 88.67% 6 Missing and 6 partials ⚠️
block/internal/executing/executor.go 64.28% 3 Missing and 2 partials ⚠️
block/internal/submitting/da_submitter.go 0.00% 1 Missing and 1 partial ⚠️
block/internal/syncing/da_retriever.go 66.66% 0 Missing and 1 partial ⚠️
block/internal/syncing/raft_retriever.go 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3282      +/-   ##
==========================================
+ Coverage   61.84%   62.82%   +0.98%     
==========================================
  Files         122      123       +1     
  Lines       16241    13151    -3090     
==========================================
- Hits        10044     8262    -1782     
+ Misses       5312     3993    -1319     
- Partials      885      896      +11     
Flag Coverage Δ
combined 62.82% <86.18%> (+0.98%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tac0turtle tac0turtle marked this pull request as ready for review April 23, 2026 13:39
@tac0turtle tac0turtle requested a review from julienrbrt April 23, 2026 13:42
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
block/internal/executing/executor.go (1)

757-762: ⚠️ Potential issue | 🟠 Major

Populate the scheduled pubkey when producing unsigned based-sequencer blocks.

When e.signer == nil, a schedule entry with PubKey set still produces a header with Signer.PubKey == nil. That conflicts with ValidateProposer, which rejects pinned schedule entries when the provided pubkey is missing.

Proposed fix
 	} else {
-		validatorHash, err = e.options.ValidatorHasherProvider(proposer.Address, nil)
+		if len(proposer.PubKey) > 0 {
+			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)
 		}
 	}

Also applies to: 782-785

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@block/internal/executing/executor.go` around lines 757 - 762, When producing
unsigned based-sequencer blocks (the branch where e.signer == nil and you call
e.options.ValidatorHasherProvider to compute validatorHash), ensure you also
populate the header's Signer.PubKey from the schedule entry so pinned schedule
entries are not missing a pubkey; specifically, after computing validatorHash in
that branch set the header's Signer.PubKey (and any equivalent scheduled pubkey
field) to the schedule entry's PubKey used to compute the hash. Apply the same
fix in the second similar branch around the code at the other occurrence (the
block referenced by the reviewer at 782-785).
🧹 Nitpick comments (3)
docs/adr/adr-023-proposer-key-rotation.md (1)

140-140: Minor wording nit.

"requires preplanned scheduling" reads awkwardly; consider rephrasing for clarity.

✏️ Proposed tweak
-- emergency rotation still requires preplanned scheduling or a later authority-based mechanism
+- emergency rotation still requires scheduling to be planned in advance, or a later authority-based mechanism
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/adr/adr-023-proposer-key-rotation.md` at line 140, The sentence
"emergency rotation still requires preplanned scheduling or a later
authority-based mechanism" is awkwardly phrased; update that clause in adr-023
to clearer wording such as "emergency rotation still requires a preplanned
schedule or, alternatively, a subsequent authority-based mechanism" or
"emergency rotation still requires prior scheduling or a later authority-based
mechanism"—locate the exact phrase and replace it with one of these clearer
alternatives to improve readability.
block/internal/submitting/da_submitter.go (1)

486-495: Schedule-aware signer check looks correct; consider fail-fast behavior.

Validating the signer against the schedule per-height is right. One observation: if the signer becomes non-authoritative for a future height (e.g., a rotation boundary was crossed and this aggregator still holds pending data at the new height), the loop errors out on the first offending item and discards the whole batch, including already-built earlier entries. That's acceptable correctness-wise (we must not sign data we're not authorized for), but you may want to log the height and skip/stop more explicitly so operators can tell "rotation reached, stopping signer" apart from a config mismatch. Non-blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@block/internal/submitting/da_submitter.go` around lines 486 - 495, The loop
currently returns an error from genesis.ValidateProposer when it encounters the
first height the signer is no longer authorized for, discarding any prior
entries; change this to fail-fast with an explicit log and stop-processing
behavior so earlier built entries are preserved and operators can see a clear
"rotation reached" message. Specifically, in the block that calls
genesis.ValidateProposer(unsignedData.Height(), addr, pubKey) inside the
unsignedDataList loop, replace the immediate return with a log statement that
includes unsignedData.Height(), addr and pubKey (or a short identifier) and then
break out of the loop (or otherwise stop further processing) so the function can
return the already-accumulated results instead of an error; ensure the log
message clearly states the signer lost authority due to schedule rotation.
pkg/genesis/proposer_schedule.go (1)

80-95: Deep-copy schedule entry byte slices.

EffectiveProposerSchedule copies the slice header only; callers can mutate Address or PubKey on returned entries and accidentally alter Genesis.ProposerSchedule.

Proposed fix
 	if len(g.ProposerSchedule) > 0 {
 		out := make([]ProposerScheduleEntry, len(g.ProposerSchedule))
-		copy(out, 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
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/genesis/proposer_schedule.go` around lines 80 - 95,
EffectiveProposerSchedule currently only copies the slice header for
Genesis.ProposerSchedule so returned ProposerScheduleEntry.Address and .PubKey
still reference underlying byte slices; modify EffectiveProposerSchedule to
deep-copy the byte slices for each entry when building out (iterate over
g.ProposerSchedule, copy each ProposerScheduleEntry, and set entry.Address =
bytes.Clone(entry.Address) and if entry.PubKey != nil set entry.PubKey =
bytes.Clone(entry.PubKey)) and ensure the single-entry return path also clones
any PubKey in addition to the existing bytes.Clone(g.ProposerAddress).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@block/internal/syncing/p2p_handler.go`:
- Around line 84-86: Check that the height embedded in the incoming P2P header
matches the requested height before validating the proposer: call
p2pHeader.SignedHeader.Height() (or use header.Height() where applicable) and if
it differs from the requested variable height, log a debug/error via h.logger
(include both heights) and return an error immediately; only call
h.assertExpectedProposer(p2pHeader.SignedHeader) after the heights match. Apply
the same explicit height check in the other occurrence around the second
h.assertExpectedProposer call (the block at lines ~128-130).

In `@pkg/genesis/proposer_schedule_test.go`:
- Around line 31-37: Replace nondeterministic StartTime values in the Genesis
test fixtures by using a fixed, deterministic time constant (e.g., a
time.Date(...) value assigned to a variable like fixedGenesisTime) instead of
time.Now().UTC(); update the Genesis struct literal that sets StartTime (and any
other Genesis literals in this file such as the other fixtures) to use that
fixedGenesisTime so tests become deterministic while keeping fields like
ChainID, InitialHeight and ProposerSchedule unchanged.

In `@pkg/genesis/proposer_schedule.go`:
- Around line 152-181: The ValidateProposer function currently accepts any
provided pubKey when the schedule entry has no PubKey; change behavior so that
when entry.PubKey is empty but the caller passed a non-nil pubKey you must
derive the address from that pubKey (e.g., call pubKey.Address() or the
appropriate address-derivation on crypto.PubKey) and compare it to
entry.Address, returning an error if they differ; retain the existing path where
entry.PubKey is present (marshal via crypto.MarshalPublicKey and compare) and
keep allowing nil pubKey only when entry.PubKey is empty.

---

Outside diff comments:
In `@block/internal/executing/executor.go`:
- Around line 757-762: When producing unsigned based-sequencer blocks (the
branch where e.signer == nil and you call e.options.ValidatorHasherProvider to
compute validatorHash), ensure you also populate the header's Signer.PubKey from
the schedule entry so pinned schedule entries are not missing a pubkey;
specifically, after computing validatorHash in that branch set the header's
Signer.PubKey (and any equivalent scheduled pubkey field) to the schedule
entry's PubKey used to compute the hash. Apply the same fix in the second
similar branch around the code at the other occurrence (the block referenced by
the reviewer at 782-785).

---

Nitpick comments:
In `@block/internal/submitting/da_submitter.go`:
- Around line 486-495: The loop currently returns an error from
genesis.ValidateProposer when it encounters the first height the signer is no
longer authorized for, discarding any prior entries; change this to fail-fast
with an explicit log and stop-processing behavior so earlier built entries are
preserved and operators can see a clear "rotation reached" message.
Specifically, in the block that calls
genesis.ValidateProposer(unsignedData.Height(), addr, pubKey) inside the
unsignedDataList loop, replace the immediate return with a log statement that
includes unsignedData.Height(), addr and pubKey (or a short identifier) and then
break out of the loop (or otherwise stop further processing) so the function can
return the already-accumulated results instead of an error; ensure the log
message clearly states the signer lost authority due to schedule rotation.

In `@docs/adr/adr-023-proposer-key-rotation.md`:
- Line 140: The sentence "emergency rotation still requires preplanned
scheduling or a later authority-based mechanism" is awkwardly phrased; update
that clause in adr-023 to clearer wording such as "emergency rotation still
requires a preplanned schedule or, alternatively, a subsequent authority-based
mechanism" or "emergency rotation still requires prior scheduling or a later
authority-based mechanism"—locate the exact phrase and replace it with one of
these clearer alternatives to improve readability.

In `@pkg/genesis/proposer_schedule.go`:
- Around line 80-95: EffectiveProposerSchedule currently only copies the slice
header for Genesis.ProposerSchedule so returned ProposerScheduleEntry.Address
and .PubKey still reference underlying byte slices; modify
EffectiveProposerSchedule to deep-copy the byte slices for each entry when
building out (iterate over g.ProposerSchedule, copy each ProposerScheduleEntry,
and set entry.Address = bytes.Clone(entry.Address) and if entry.PubKey != nil
set entry.PubKey = bytes.Clone(entry.PubKey)) and ensure the single-entry return
path also clones any PubKey in addition to the existing
bytes.Clone(g.ProposerAddress).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 614bc46d-68db-47cd-bd87-9f6f34553c74

📥 Commits

Reviewing files that changed from the base of the PR and between 3efcf93 and 6a0f367.

📒 Files selected for processing (20)
  • block/internal/executing/executor.go
  • block/internal/executing/executor_test.go
  • block/internal/submitting/da_submitter.go
  • block/internal/submitting/da_submitter_test.go
  • block/internal/syncing/assert.go
  • block/internal/syncing/da_retriever.go
  • block/internal/syncing/p2p_handler.go
  • block/internal/syncing/p2p_handler_test.go
  • block/internal/syncing/raft_retriever.go
  • docs/.vitepress/config.ts
  • docs/adr/adr-023-proposer-key-rotation.md
  • docs/guides/create-genesis.md
  • docs/guides/operations/proposer-key-rotation.md
  • docs/guides/operations/upgrades.md
  • node/failover.go
  • node/full.go
  • pkg/genesis/genesis.go
  • pkg/genesis/io.go
  • pkg/genesis/proposer_schedule.go
  • pkg/genesis/proposer_schedule_test.go

Comment on lines +84 to 86
if err := h.assertExpectedProposer(p2pHeader.SignedHeader); err != nil {
h.logger.Debug().Uint64("height", height).Err(err).Msg("invalid header from P2P")
return err
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject P2P headers whose embedded height differs from the requested height.

Line 84 now validates the proposer schedule using header.Height(). If the store returns a header for a different height, it can validate against that height’s scheduled proposer and later mark the requested height as processed. Add an explicit height check before proposer validation.

🐛 Proposed fix
 	p2pHeader, err := h.headerStore.GetByHeight(ctx, height)
 	if err != nil {
 		if ctx.Err() == nil {
 			h.logger.Debug().Uint64("height", height).Err(err).Msg("header unavailable in store")
 		}
 		return err
 	}
+	if headerHeight := p2pHeader.SignedHeader.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
 	}

As per coding guidelines, validate all inputs from external sources in Go code.

Also applies to: 128-130

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@block/internal/syncing/p2p_handler.go` around lines 84 - 86, Check that the
height embedded in the incoming P2P header matches the requested height before
validating the proposer: call p2pHeader.SignedHeader.Height() (or use
header.Height() where applicable) and if it differs from the requested variable
height, log a debug/error via h.logger (include both heights) and return an
error immediately; only call h.assertExpectedProposer(p2pHeader.SignedHeader)
after the heights match. Apply the same explicit height check in the other
occurrence around the second h.assertExpectedProposer call (the block at lines
~128-130).

Comment thread pkg/genesis/proposer_schedule_test.go
Comment thread pkg/genesis/proposer_schedule.go
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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless I missed something, this is the currently implemented way. Genesis will need to be updated in each node and restarted. Same for sequencer. I suppose this is OK, but the ADR make it seem it isn't

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@block/internal/executing/executor_test.go`:
- Around line 143-218: Tests use wall-clock times (time.Now()) making fixtures
non-deterministic; replace those with a fixed timestamp constant (e.g., a single
time variable declared in the test like fixedTime := time.Unix(0, 0).UTC()) and
use that for genesis.StartTime, prevHeader/BaseHeader.Time, and BatchData.Time
when constructing genesis.Genesis, prevHeader, and the CreateBlock call; apply
the same fixed-time pattern to the other new genesis and BatchData fixtures in
this file so NewExecutor, setLastState, CreateBlock, BatchData and prevHeader
use deterministic times.

In `@docs/adr/adr-023-proposer-key-rotation.md`:
- Around line 56-73: The ADR text incorrectly states `pub_key` is required for
every schedule entry; update the wording to reflect that `pub_key` is optional
at runtime (entries may be address-only with `entry.PubKey` empty) and that
validation accepts entries lacking `pub_key`; adjust the sentences around the
bullet list and the rules that currently say “each entry's `address` must match
the configured `pub_key`” and “the first entry must start at `initial_height`”
to instead state that when `pub_key` is present it must match the configured
value and, when absent, the entry is interpreted by `address` only (also clarify
that `proposer_address`, when present, must match the first schedule entry’s
address regardless of `pub_key` presence).

In `@pkg/genesis/genesis_test.go`:
- Line 143: Replace the nondeterministic wall-clock timestamp used for the test
fixture: locate the declaration of validTime in genesis_test.go (the line
setting validTime := time.Now().UTC()) and change it to a fixed, non-zero UTC
timestamp (e.g., construct with time.Date and time.UTC) so the test becomes
deterministic while preserving the same type and layout expected by the rest of
the test.
- Around line 227-238: Change the test case so the Genesis.InitialHeight matches
the first ProposerScheduleEntry's start_height (i.e., use the same value as
entry20) so validation doesn't fail on the initial-height equality check and
instead proceeds to the strictly-increasing start_height check for
ProposerSchedule (where entry20 is followed by entry10). Update the mutate
function to set InitialHeight to the start_height value used by entry20 while
keeping ProposerSchedule: []ProposerScheduleEntry{entry20, entry10} and the same
wantErr.

In `@pkg/genesis/proposer_schedule_test.go`:
- Around line 271-286: The test TestEffectiveProposerSchedule_LegacyFallback
mutates schedule[0].Address which shares backing memory with
legacy.ProposerAddress; before mutating, make a defensive copy of the expected
address (e.g. copy addr or legacy.ProposerAddress into a new byte slice) or copy
schedule[0].Address into a separate slice and mutate that copy, then assert the
original legacy.ProposerAddress equals the copied original; update the test to
perform the mutation on a cloned slice so the assertion against
legacy.ProposerAddress is valid.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 311c4ae3-f34f-40e8-b7a7-d1eee5506200

📥 Commits

Reviewing files that changed from the base of the PR and between 6a0f367 and 6ad13f2.

📒 Files selected for processing (6)
  • block/internal/executing/executor_test.go
  • block/internal/syncing/p2p_handler_test.go
  • docs/adr/adr-023-proposer-key-rotation.md
  • pkg/genesis/genesis_test.go
  • pkg/genesis/proposer_schedule.go
  • pkg/genesis/proposer_schedule_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • pkg/genesis/proposer_schedule.go

Comment on lines +143 to +218
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())
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use fixed times in the new executor fixtures.

These tests only need ordered/non-zero times, so wall-clock values make the fixtures less reproducible.

🧪 Proposed direction
+	fixedStartTime := time.Unix(1_700_000_000, 0).UTC()
+	fixedBatchTime := fixedStartTime.Add(time.Second)

 	gen := genesis.Genesis{
 		ChainID:                "test-chain",
 		InitialHeight:          1,
-		StartTime:              time.Now().Add(-time.Second),
+		StartTime:              fixedStartTime,
 		ProposerAddress:        entry1.Address,
 		ProposerSchedule:       []genesis.ProposerScheduleEntry{entry1, entry2},
 		DAEpochForcedInclusion: 1,
 	}
@@
 	header, data, err := executor.CreateBlock(context.Background(), 2, &BatchData{
 		Batch: &coreseq.Batch{},
-		Time:  time.Now(),
+		Time:  fixedBatchTime,
 	})

Apply the same pattern to the other new genesis and BatchData fixtures in this file.

As per coding guidelines, Ensure tests are deterministic.

Also applies to: 237-343

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@block/internal/executing/executor_test.go` around lines 143 - 218, Tests use
wall-clock times (time.Now()) making fixtures non-deterministic; replace those
with a fixed timestamp constant (e.g., a single time variable declared in the
test like fixedTime := time.Unix(0, 0).UTC()) and use that for
genesis.StartTime, prevHeader/BaseHeader.Time, and BatchData.Time when
constructing genesis.Genesis, prevHeader, and the CreateBlock call; apply the
same fixed-time pattern to the other new genesis and BatchData fixtures in this
file so NewExecutor, setLastState, CreateBlock, BatchData and prevHeader use
deterministic times.

Comment on lines +56 to +73
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Documentation currently contradicts optional pub_key behavior

Line 60/Line 71 and the JSON example imply pub_key is required for every entry, but runtime validation supports address-only schedule entries (entry.PubKey can be empty). Please align the ADR wording so operators don’t treat missing pub_key as invalid by default.

✏️ Suggested doc fix
 Each entry declares:
 
 - `start_height`
 - `address`
-- `pub_key`
+- `pub_key` (optional; when present, it must match `address`)
@@
 When an explicit schedule is present:
@@
-- each entry's `address` must match the configured `pub_key`
+- if `pub_key` is provided, the entry's `address` must match it
@@
   {
     "start_height": 1,
     "address": "...",
-    "pub_key": "..."
+    "pub_key": "..."
   },
   {
     "start_height": 1250000,
-    "address": "...",
-    "pub_key": "..."
+    "address": "..."
   }
 ]
@@
-This design improves safety over address-only pinning by allowing validation against the scheduled public key.
+This design improves safety by allowing validation against a scheduled public key when one is pinned.

Also applies to: 80-91, 124-126

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/adr/adr-023-proposer-key-rotation.md` around lines 56 - 73, The ADR text
incorrectly states `pub_key` is required for every schedule entry; update the
wording to reflect that `pub_key` is optional at runtime (entries may be
address-only with `entry.PubKey` empty) and that validation accepts entries
lacking `pub_key`; adjust the sentences around the bullet list and the rules
that currently say “each entry's `address` must match the configured `pub_key`”
and “the first entry must start at `initial_height`” to instead state that when
`pub_key` is present it must match the configured value and, when absent, the
entry is interpreted by `address` only (also clarify that `proposer_address`,
when present, must match the first schedule entry’s address regardless of
`pub_key` presence).

}

func TestGenesis_ValidateProposerSchedule(t *testing.T) {
validTime := time.Now().UTC()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use a fixed timestamp for this fixture.

This test only needs a non-zero time, so using wall-clock time adds unnecessary nondeterminism.

🧪 Proposed fix
-	validTime := time.Now().UTC()
+	validTime := time.Unix(1_700_000_000, 0).UTC()

As per coding guidelines, Ensure tests are deterministic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/genesis/genesis_test.go` at line 143, Replace the nondeterministic
wall-clock timestamp used for the test fixture: locate the declaration of
validTime in genesis_test.go (the line setting validTime := time.Now().UTC())
and change it to a fixed, non-zero UTC timestamp (e.g., construct with time.Date
and time.UTC) so the test becomes deterministic while preserving the same type
and layout expected by the rest of the test.

Comment on lines +227 to +238
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",
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Make the decreasing-height case reach the strictly-increasing check.

With entry20 first and InitialHeight: 1, validation fails on proposer_schedule[0].start_height before it can test the decreasing schedule path.

🧪 Proposed fix
 		{
 			name: "invalid - non-increasing (decreasing start_heights)",
 			mutate: func() Genesis {
 				return Genesis{
 					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",
 		},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/genesis/genesis_test.go` around lines 227 - 238, Change the test case so
the Genesis.InitialHeight matches the first ProposerScheduleEntry's start_height
(i.e., use the same value as entry20) so validation doesn't fail on the
initial-height equality check and instead proceeds to the strictly-increasing
start_height check for ProposerSchedule (where entry20 is followed by entry10).
Update the mutate function to set InitialHeight to the start_height value used
by entry20 while keeping ProposerSchedule: []ProposerScheduleEntry{entry20,
entry10} and the same wantErr.

Comment on lines +271 to +286
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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clone the expected address before mutating the returned schedule.

As written, a missing defensive copy can still pass because addr and legacy.ProposerAddress share the same backing slice.

🧪 Proposed fix
 func TestEffectiveProposerSchedule_LegacyFallback(t *testing.T) {
 	addr := []byte("some-address-bytes")
+	origAddr := bytes.Clone(addr)
 	legacy := Genesis{
 		ChainID:         "c",
 		InitialHeight:   7,
 		ProposerAddress: addr,
 	}
@@
 	// 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)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/genesis/proposer_schedule_test.go` around lines 271 - 286, The test
TestEffectiveProposerSchedule_LegacyFallback mutates schedule[0].Address which
shares backing memory with legacy.ProposerAddress; before mutating, make a
defensive copy of the expected address (e.g. copy addr or legacy.ProposerAddress
into a new byte slice) or copy schedule[0].Address into a separate slice and
mutate that copy, then assert the original legacy.ProposerAddress equals the
copied original; update the test to perform the mutation on a cloned slice so
the assertion against legacy.ProposerAddress is valid.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants