Skip to content

fix(consensus): cutover QC handling and parent proposal fetch#4702

Merged
bfish713 merged 8 commits into
mainfrom
bf/cutover
Jul 17, 2026
Merged

fix(consensus): cutover QC handling and parent proposal fetch#4702
bfish713 merged 8 commits into
mainfrom
bf/cutover

Conversation

@bfish713

@bfish713 bfish713 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

This PR hardens the legacy → new-protocol cutover so the last legacy QC always reaches the new protocol and the boundary views decide deterministically, and fixes a legacy proposal-path bug where a DA-only state-map entry aborted the leader's proposal.

This PR:

Cutover QC handling (fix cutover qc handling):

  • Captures the last-legacy QC keyed on the QC's own view instead of cur_view (task-impls/src/consensus/mod.rs). Qc2Formed fires once, and the aggregator can form this QC before processing its own ViewChange into the cutover view; keying on cur_view dropped it, so the cutover seed's high_qc (and the LegacyHighQcFormed bridge) missed it.
  • Bridges legacy timeout votes only for views strictly before new_version_first_view (task-impls/src/consensus/handlers.rs, <=<). Legacy re-times-out the parked boundary view forever; bridging those votes could form a TC2 that skips the first new-protocol view even when its leader is online.
  • Re-requests the block/header when a TC-backed proposal finds no header for the current lock (new-protocol/src/consensus.rs, maybe_propose). The TC-time header request targets the lock held at that moment; if a bridged legacy QC moves the lock afterwards, the leader now re-issues RequestBlockAndHeader for the adopted lock instead of hitting "no block header" until the view times out. The block builder dedups by (view, parent).
  • Adds state-machine tests for both behaviors: a timeout-backed proposal chains only from the locked cert (never a cert1 of the timed-out view), and a bridged legacy QC is adopted as lock, the header re-requested, and the view re-proposed justifying the adopted QC with the TC attached.
  • Makes the cutover test suite exact: the loose permitted-failures mode is removed, every test (including the permutation sweep) asserts the precise attributable failure set in both directions, and the happy path asserts zero failed views. Silencers now watch new-protocol coordinator progress (published via AtomicU64) in addition to legacy cur_view, so post-cutover kills land punctually.

Parent proposal fetch (fetch leaf if we only have a DA proposal in leaf and state map, task-impls/src/helpers.rs):

  • A Da/Failed state-map entry carries no leaf/state, so it cannot be proposed on; parent_leaf_and_state now treats it like a missing view and fetches the parent proposal. This matters when the leader forms the QC from unicast votes before validating the parent proposal itself — previously the DA entry masked the fetch and the proposal aborted.

This PR does not:

  • Change the coordinator's seed extraction or SubmitLegacyHighQc/SubmitTimeoutVote handling — only how legacy feeds the bridge and how the new-protocol state machine reacts once the lock moves.
  • Restrict the header re-request to the cutover: it also fires for a normal TC-backed view whose header is still building. This is benign (deduped by the builder, and the epoch it computes matches the eventual proposal's), but it is broader than the comment suggests.

Key places to review:

  • task-impls/src/consensus/mod.rs, Qc2Formed arm — the guard new_protocol_active(qc.view_number() + 1) && !new_protocol_active(qc.view_number()) selects exactly the last-legacy QC, independent of when the aggregator's own view advances.
  • new-protocol/src/consensus.rs, maybe_propose — the re-request's request_epoch intentionally matches the eventual proposal_epoch derivation below it (not the raw TC epoch used by the original request site in handle_timeout_certificate); that is what makes the leader check correct for a first-of-epoch proposal.
  • new-protocol/src/tests/legacy_cutover.rs, expected_failures — the exact failure model the whole suite now leans on: the silenced view, legacy unicast-vote spillover to the preceding view (for silenced views at or before the cutover), and the dead node's later leader slots.

How to test this PR:

  • cargo nextest run -p hotshot-new-protocol — cutover suite (tests::legacy_cutover) and the new state-machine tests (tests::consensus).

sveitser and others added 6 commits July 16, 2026 07:42
A node's view timer fired exactly once per view, so its TimeoutVote2
broadcast was single-shot. With a live set exactly at the TC2 threshold
(e.g. 7 nodes with 2 silenced at cutover), one lost broadcast deadlocked
consensus permanently: no re-send, no timer re-fire, TC2 unicast only to
the next leader. Observed as perm_silence_m2_m1 stalling for 240s with
zero decisions in both post-#4689 CI runs.

Re-arm the timer on fire so the vote is re-broadcast every timeout
period until the view advances. Collectors dedup by (view, signer);
participation and timeout metrics count once per view.
…utover

Under start-time skew the cutover seed can be snapshotted before the QC
for the last legacy view lands, so the smooth-start precondition fails
and views 24/25 get TC2-skipped: legitimate behavior, same physics
permitted for the perm sweep in #4689. Smooth start stays deterministic
under src/tests/cutover.rs unit tests. Failed CI run 29425671834 with
node 0 skipping view 24.
Recovery gaps after the view-5 crash land on different views run to
run; the consistency task shut the test down on the first out-of-list
gap, which also starved the successful-view count (CI run 29425671834:
unexpected failed view 9, then only 9/20 successful views). Apply the
#4663 budget pattern; num_successful_views=20 remains the gate.
@gemini-code-assist

Copy link
Copy Markdown
Contributor
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

@bfish713 bfish713 changed the title Bf/cutover Fix FF Cutover Tests Jul 17, 2026
@bfish713
bfish713 marked this pull request as ready for review July 17, 2026 02:41
@claude

claude Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Claude finished @bfish713's task in 2m 19s —— View job


Claude Code Review — PR #4702 (Fix FF Cutover Tests)

  • Read existing PR comments (only the Gemini bot intro; no prior review feedback to dedup against)
  • Review production code changes
  • Review test changes
  • Post review feedback

Reviewed the full diff against origin/main. This is a focused, well-reasoned set of fixes to the legacy→0.8 cutover path plus a substantial tightening of the cutover test harness. Overall it looks correct and high-quality — the comments do a genuinely good job explaining the why behind each subtle change. No blocking issues found.

Production changes

consensus/mod.rsQc2Formed boundary guard
Re-keying the guard from self.cur_view to qc.view_number() + 1 is the right call. The old check was racy: the aggregator can form the last-legacy QC before it processes its own ViewChange into the cutover view, so cur_view could still be pre-cutover and the QC would be dropped. new_protocol_active(qc.view_number() + 1) && !new_protocol_active(qc.view_number()) precisely pins "the QC whose view is the last legacy view," independent of local view timing. 👍

consensus/handlers.rshandle_timeout bridge bound (<=<)
Correct. new_version_first_view is run by the new protocol; legacy only parks-and-re-times-out that boundary view, so bridging those votes would feed the new-protocol coordinator timeout evidence that prematurely TC2-skips its first view. Excluding view_number == new_version_first_view is the right boundary.

helpers.rsparent_leaf_and_state fetch gate
Good fix. Switching from contains_key to get(...).is_some_and(|v| v.leaf_and_state().is_some()) correctly treats a Da/Failed state-map entry as "no proposable parent" and triggers the fetch, instead of the DA entry masking it and aborting the proposal. The narrower name vsm_contains_parent_leaf reflects the real predicate.

consensus.rs — re-request header when the lock moved (maybe_propose)
The new block re-emits RequestBlockAndHeader when a TC exists but the header lookup misses, gated on view_change_evidence.is_some() and leadership. This closes the cutover gap where the original TC-time request targeted the old lock and a bridged legacy QC then moved locked_cert past it. Two things I checked that hold up:

  • The request_epoch computation (is_last_block(proposal.block_header.block_number(), …)) is consistent with the later proposal_epoch derivation (is_last_block(header.block_number().saturating_sub(1), …)), since the child header's block number is parent + 1. So the re-request targets the same epoch the eventual proposal will use.
  • It re-fires on every maybe_propose call while the header is outstanding, but that's bounded by proposed_views short-circuiting once proposed and the block builder deduping by (view, parent) — which the comment calls out. Fine as-is.

Test changes

  • test_timeout_proposal_chains_from_lock_not_timed_out_cert1 and test_bridged_legacy_qc_adopts_lock_and_reproposes are good, targeted unit tests that lock down exactly the two behaviors above (chain-from-lock, and adopt-lock-then-re-request). The manual-outbox approach in the second test is a clean way to freeze the "header outstanding when the legacy QC arrives" state.
  • The legacy_cutover.rs rework is the strongest part: dropping the loose flag and replacing permitted_failures (a superset) with expected_failures (an exact, per-silenced-leader attribution) turns previously-permissive assertions into exact ones. The await_node_at_view change to watch both legacy cur_view and the new-protocol coordinator view is what makes the exact assertions viable — post-cutover, legacy only creeps past the boundary via timeouts, so silencing keyed off legacy alone would fire late. The reasoning in the expected_failures doc comment (pre-cutover unicast vote spillover to view-1, none post-cutover) matches the vote-routing model.

Minor notes (non-blocking)

  • expected_failures(..., 50) hardcodes max_view = 50 as the slot-enumeration ceiling. It's commented as "only needs to exceed the highest decided view," and with target_decisions = 6 that's safe, but a brief inline note tying 50 to the deadline/target would help future readers who bump the decision count.
  • I did not run the cutover suite here (multi-thread integration tests, long runtime). Worth confirming the tightened exact-assertion permutation sweep is stable across a few CI runs given it previously relied on the loose check for post-cutover timing — exact failure-set assertions can be sensitive to scheduler jitter.

Nice work — the fixes are minimal and each is backed by a test that would have caught the original flake.

@bfish713 bfish713 changed the title Fix FF Cutover Tests fix(consensus): cutover QC handling and parent proposal fetch Jul 17, 2026
@bfish713
bfish713 merged commit 3738560 into main Jul 17, 2026
183 checks passed
@bfish713
bfish713 deleted the bf/cutover branch July 17, 2026 13:06
@github-actions

Copy link
Copy Markdown
Contributor

Created backport PR for release-ff:

Please cherry-pick the changes locally and resolve any conflicts.

git fetch origin backport-4702-to-release-ff
git worktree add --checkout .worktree/backport-4702-to-release-ff backport-4702-to-release-ff
cd .worktree/backport-4702-to-release-ff
git reset --hard HEAD^
git cherry-pick -x 37385605d677b1e0f2cd3f9d4d9233e7e2ec56a3
git push --force-with-lease

bfish713 added a commit that referenced this pull request Jul 17, 2026
* Start the coordinator at the upgrade view (#4670)

* Start the coordinator only at the upgrade view.

Previously `ConsensusHandle` spawned the coordinator loop at node
startup, and a `CutoverGate` latched routing as a side effect of
queries. Replace this with an explicit Init -> Running state machine:
`activate()` starts the coordinator once legacy crosses the upgrade
boundary, driven by the event loop and `start_consensus`. The
legacy-event forwarders keep running from construction so boundary
events queue until the coordinator starts.

Route per-view queries (`state`, `request_proposal`, `update_leaf`) by
the version of the requested view and current queries by whether the
coordinator is running. Boxing the coordinator-start closure confines
the `NewProtocolStorage` bound to `ConsensusHandle::new` and drops the
storage type parameter from the handle.

Also replace `CoordinatorOutput` with `OpaqueMessage`: the enum's only
constructed variant was `ExternalMessageReceived`.

* Apply cutover seed in `Coordinator::start`.

Pass the pre-cutover seed into `start()` instead of dispatching it
through a `SeedPreCutover` client request after the coordinator is
already running. Seeding before start removes the unseeded window,
the genesis-decide re-emit at cutover, and the seed-arrived-late
repair paths (`resume_after_cutover_tc`, handler fallback). When the
seed lacks the last legacy view's QC, `start()` parks on that view;
a bridged high QC or timeout advances it.

Delete the now-unused `CutoverGate` and make the cutover tests mirror
production activation: the coordinator stays parked until legacy
crosses the upgrade boundary.

* [new-protocol] change empty block time to 1s (#4675)

* [new-protocol ] fetch parent proposal if it is missing (#4644)

* parent proposal fetch

* Update crates/hotshot/new-protocol/src/consensus.rs

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* [Fast Finality] Metric Parity b/t Protocols (#4674)

* feat(new-protocol): emit legacy consensus metrics from coordinator

The coordinator only exported coordinator_* pipeline histograms plus two
renamed gauges, so the consensus health metrics dashboards alert on
(current_view, last_decided_time, last_synced_block_height, ...) would
freeze at the cutover.

Share the node's ConsensusMetricsValue between the legacy handle and the
coordinator -- the Prometheus registry panics on duplicate registration,
so both protocols must write the same registered objects. The coordinator
now sets every legacy metric that has a new-protocol analog:

- current_view, number_of_views_since_last_decide, view_duration_as_leader,
  outstanding_transactions(_memory_size) on view changes
- last_decided_view, last_decided_time, last_synced_block_height,
  number_of_views_per_decide_event, proposal_to_decide_time,
  finalized_bytes, invalid_qc reset on decides
- last_voted_view on vote1/vote2 sends
- number_of_timeouts(_as_leader) on local timeouts
- number_of_empty_blocks_proposed when persisting an own empty proposal
- vid_disperse_duration around the disperse computation

outstanding_transactions was defined but never written even in legacy;
the block builder's retry buffer now feeds it. The coordinator-specific
coordinator_timeouts and leaf_decided_view are replaced by the legacy
names number_of_timeouts and last_decided_view.

Not carried over: invalid_qc increments, previous_proposal_to_proposal_time,
internal_event_queue_len, validate_and_apply_header_duration and
update_leaf_duration -- tied to the legacy task architecture and covered
by the coordinator_* histograms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(new-protocol): track participation and serve node/participation API

The node/participation/* endpoints read ValidatorParticipation and
VoteParticipation from the legacy Consensus state, which stops updating
at the cutover, so the API would go permanently stale on the new
protocol.

Expose the two trackers from hotshot-types (visibility only, no behavior
change) and maintain them in the coordinator, mirroring the legacy update
sites:

- a validated proposal credits the view leader (proposal participation)
- a local view timeout debits the view leader
- each decided leaf's justify QC credits its signers (vote participation),
  rolling the tracked epoch forward from the QC epoch as legacy does;
  the decide loop runs oldest leaf first for this

ConsensusHandle now routes the four participation queries to the
coordinator via new ClientApi requests once the cutover is active, and to
the legacy handle before it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* cleanup

* add missing metrics

* remove comments, fix some participation metrics

* lint

* more memory and review comments

* trim comments

* remove dead code

* remove coordinator timing metrics

* use opt 1 for much smaller stack

* address review: structured warns, drop comments, move payload gc

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* trim comment

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* [Fast Finality] Stake Table Change Tests (#4651)

* test(example-types): support per-epoch quorum committees

Add a quorum committee schedule to StaticStakeTable, mirroring the
existing per-epoch DA committee mechanism: committees are keyed by
first-epoch and resolve to the greatest key <= the requested epoch,
falling back to the construction-time members. Leader lookup rotates
over the resolved committee. StrictMembership gains test-only helpers
to install a schedule and to register an epoch without driving the
epoch-root/DRB pipeline.

An empty schedule preserves the previous behavior exactly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(new-protocol): thread stake table schedules through the runner

Add StakeTableSchedule (per-epoch committees by node index) and a
schedule-aware mock membership constructor that installs the quorum and
DA committees and stamps each member's cliquenet connect info, so
apply_epoch can add scheduled joiners as peers. TestRunner gains a
stake_table_schedule field and per-node target_decisions overrides for
validators that stop deciding when their membership ends.

Without a schedule the runner behaves exactly as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(new-protocol): validator join/leave at epoch boundary

Two multi-node tests over a per-epoch stake table schedule (epoch 3 is
the first epoch that can differ; its table is registered when the
epoch-1 root decides):

- join: a node outside the epoch-1/2 committee starts mid-epoch-2,
  catches up, is added to the other nodes' cliquenet peers via its
  scheduled connect info, and leads its views in epoch 3.
- leave: a removed validator records no vote or propose action after
  its last member view, follows the chain through its one-epoch peer
  retention, and is cut off at the next boundary while the shrunken
  committee keeps deciding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(new-protocol): vote collector ignores removed validators

A vote from a validator that was removed from the quorum committee in a
later epoch does not count toward that epoch's certificate, while the
same node's vote still counts in an epoch where it is a member.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(new-protocol): drop review-redundant inline comments

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(new-protocol): hard-assert schedule/down_nodes incompatibility

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(example-types): rename TestDaCommittees to TestCommitteeSchedule

The type schedules both DA and quorum committees since it was reused for
per-epoch quorum schedules; rename it so the name no longer needs an
apology comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(node-testing): recognize NewDecide decides in wait_for_epochs

At NEW_PROTOCOL_VERSION decides arrive as CoordinatorEvent::NewDecide,
not LegacyEvent, so wait_for_epochs blocked forever on 0.6 networks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(node): validator exit activates at epoch boundary at v0.6

Full-application counterpart to the HotShot-level stake table change
tests: a 5-node network at NEW_PROTOCOL_VERSION with a real StakeTable
V3 on anvil. One validator sends deregisterValidator mid-run, must drop
out of node/validators/{epoch} at the activation epoch, and the reduced
committee must keep deciding across further boundaries.

Fills the TEST:e2e-epoch-activation gap in doc/stake-table-fast-finality.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Fix main. (#4682)

* fix(node): release coordinator network before legacy shutdown (#4681)

* fix(node): release coordinator network before legacy shutdown

Since #4670 the coordinator parks unstarted in ConsensusHandle's
Init state, and shut_down disposed of it only after the legacy
HotShot teardown. The parked coordinator owns the cliquenet network,
so its listener held the p2p port for the entire legacy drain, and a
node restarting on the same address failed to bind (EADDRINUSE).

Dispose of the new-protocol state before the legacy shutdown, moving
the value out explicitly: a non-binding pattern would keep it alive
to the end of scope, deferring the port release all the same.

Make test_state_reconstruction await the removed node's shutdown
before re-binding its coordinator port, like
test_merklized_state_catchup_on_restart already does.

Fixes the deterministic test-postgres (10) / test-sqlite (10) CI
failures in api::test::test_state_reconstruction::case_1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* just do the network shutdown

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* [new-protocol]: Bound block builder dedup state. (#4683)

Bound block builder dedup state.

The dedup bookkeeping kept transaction hashes in a `HashSet` used
for lookups, and in an arrival-ordered `VecDeque` of `(view, hashes)`
used for eviction. Eviction stopped at the first in-window deque
entry, so a manifest claiming a far-future view parked at the front
and blocked eviction of everything behind it, growing both structures
without bound.

Here we replace both structures with a single ordered map. A hash
is deduplicated while any in-window view lists it, regardless of
arrival order. Deduplication complexity changes from O(1) to O(n)
with n being the length of the window plus any future views that
arrived, i.e. n <= 41 by default.

The `Coordinator` now also ignores dedup manifests more than 30 views
ahead, as it already does for votes, bounding the map from above.

* fix(new-protocol): re-trigger child vote1 on parent reconstruction (#4678)

A replica's vote1 for view V+1 is gated on the parent block (view V)
being reconstructed. When that reconstruction completed, the post-apply
retry only re-ran maybe_vote_1 for the reconstructed view V, never the
child V+1 waiting on it. If V+1's proposal arrived a few ms before V
finished reconstructing, its single vote1 attempt failed the parent gate
and was never retried, collapsing the view to a timeout.

Drive maybe_vote_1(V+1) when block V is reconstructed. maybe_vote_1 is
idempotent, so this is a no-op when the child has already voted or is
otherwise not ready.

* [new-protocol] use consensus last decided view to skip proposal requests in proposal fetcher (#4686)

use consensus last decided view to skip proposal requests

* [Fast Finality] Help restarted nodes reach highest view (#4685)

* send view change evidence with timeout vote, and to help behind nodes

* review comments, do catchup before too far check, just send highest cert

* feat(types): client profile so espresso-types and light-client compile for zkVM targets (#4661)

* chore: ignore repo-local tmp directory

* refactor(hotshot-types): move Certificate1/2 aliases from new-protocol

- the aliases are pure compositions of hotshot-types items
- re-exports from new-protocol preserved, no downstream changes
- lets clients name Certificate1/2 without depending on new-protocol

* style(light-client): apply cargo-sort formatting

* feat(query-service-types): make web deps optional behind web feature

- tide-disco, surf-disco, hotshot-events-service only back error/status types
- new default-on web feature gates those items; data types stay unconditional
- default = ["sqlx", "web"] keeps current builds identical

* chore(typos): allow bootrap, a serialized config field name

* feat(types): add default-on node feature for SP1 client builds

- default-features = false compiles for riscv32im-succinct-zkvm-elf
- gate L1Client cluster, persistence traits, Fetcher L1 methods, proposer paths
- SeqTypes: NodeType holds under both cfgs; node builds unchanged
- trim alloy to minimal base features; node restores today's set
- espresso-utils: new default-on full feature, ser stays unconditional
- adapter, alloy-compat: direct alloy declarations with needed features only
- re-point hotshot::types re-exports to hotshot-types; drop unused deps
- testing implies node; hotshot-example-types becomes optional

* feat(light-client): gate host client behind default-on client feature

- new default-on client feature gates QueryServiceClient, FallbackClient,
  SqliteStorage and the query-service provider module
- Client and Storage traits, the LightClient state machine and consensus
  verification stay available with default-features = false for SP1 builds
- espresso-types and hotshot-query-service-types become direct declarations
  with default-features = false; client restores espresso-types/node and the
  query-service-types defaults (sqlx + web)
- testing implies client; every cargo hack feature combination and the
  riscv32im/riscv64im-succinct-zkvm-elf targets compile

* fix(utils): gate full-only test items behind full feature

- test_contract_send and its sol! contract reference full-gated items
  (alloy providers, tokio); compile fails under --no-default-features --tests
- gate the alloy/anyhow imports, sol! block, and test_contract_send behind
  full so cargo-features CI passes; keep the pure round-trip test unconditional

* fix(node): fail at startup when built without the node feature

- add espresso_types::assert_node_feature, called first in espresso-node,
  node-sqlite and dev-node entry points
- declare espresso-types/node explicitly in node and dev-node Cargo.toml
  so a node-less build fails at compile time

* refactor(types): move node cfg gates to function boundaries

- gate reload_stake whole-fn under node; all callers require the feature
- add Fetcher::initial_supply_or_fetch with fn-level cfg twins, deduping
  the read-or-fetch pattern in calculate_dynamic_block_reward and
  fetch_fixed_block_reward
- non-node twin panics on missing initial supply instead of returning Err
- split add_epoch_root into two fn-level cfg'd trait-method copies

* fix(types): repair no-default-features build after main merge

- EpochMembershipCoordinator now appears in the unconditional StateCatchup
  trait (fetch_leaf/try_fetch_leaf), move its import out of the node gate
- drop HSStakeTable import left unused by the same upstream change

* docs: document zkVM feature gates and non-node panics

- doc/cargo-features.md: node/web/full/client features, workspace-dep
  caveat, functions that panic without the node feature
- reference it from AGENTS.md
- allow "ser" (module name) in typos config

* ci: check zkVM client crates compile for the SP1 target

- justfile recipe check-sp1-target: cargo +succinct check for
  riscv64im-succinct-zkvm-elf on espresso-types and light-client
  with --no-default-features and the getrandom backend opt-out
- install-sp1 composite action: pinned sp1up (v6.3.1) with retries,
  mirroring install-foundry
- new check-sp1-target job in the cargo-features workflow

* ci(sp1): check the zkVM target via a probe crate

- bare cargo check -p espresso-types -p light-client fails on the zkVM
  target: getrandom 0.2 needs its custom feature enabled by a consumer,
  and transitive-dep features cannot be set on the command line
- sp1/target-check depends on both crates the way a guest does
  (default-features = false, light-client with rlp) plus the getrandom
  workaround; check-sp1-target now builds that crate

* refactor(query-service-types): unconditional error types via disco-types

- tide-disco 0.9.7 splits the server-free StatusCode/RequestError/Error
  trait into disco-types; depend on it unconditionally so the API error
  types compile on constrained targets without tide-disco/surf-disco
- web feature now gates only the events-service error conversion
- disco-types with default-features = false to drop http-types from
  no-web graphs

* refactor(features): make node/web/full/client features opt-in instead of default

* style(doc): format cargo-features.md with prettier

* test: fix remaining flaky tests from #4585 (#4689)

* test: fix remaining flaky tests from #4585

- demo: don't tear down on telemetry-collector failure (docker pull timeout)
- new-protocol runner: don't bind down-node ports at setup (AddrInUse on late start)
- legacy_cutover: permit first post-cutover view; keep deadlines under nextest ceiling
- builder: widen first-proposal deadline for loaded CI
- wait_for_epochs: fail loud with last decide on stream end

* test(hotshot): failure budget for combined network reup/half_dc epoch tests

CDN outage tests fail a few views during libp2p fallback handoff; give the
reup and epochs half_dc variants the same max_unexpected_view_failures: 5
as their cdn_crash/half_dc siblings (#4663 pattern).

* chore: update deployment info (#4679)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Add V4 and V5 message serialization test, V5 header serialization test (#4672)

test(types): add v5 reference serialization vectors

Add V5 (EpochRewardVersion) reference test vectors mirroring the
existing v1-v4 coverage:

- reference_tests: V5 header vector (exercises the new leader_counts
  field) plus a V5 serializer for the round-trip checks.
- message_compat_tests: v4 and v5 message compatibility tests.

Regenerate data/v4/messages.{bin,json}: the committed files embedded a
v3 header (they predated the v4 header and were never guarded by a
test), so the new test_v4_message_compat surfaced the drift.

* fix(ci): upgrade Claude review model to fable (#4696)

* chore(ci): use claude-fable-5 for review model
* chore(ci): use fable alias so review model auto-follows latest

* chore: switch claude review back to opus (#4697)

* fix(new-protocol): don't stall legacy-event forwarders pre-cutover (#4694)

* fix(new-protocol): don't stall legacy-event forwarders pre-cutover

Since #4670 the coordinator is parked until the upgrade view and nothing
services its client request queue. forward_legacy_epoch_changes sent a
bump_network_epoch request and awaited a response that a parked
coordinator never gives, blocking the forwarder at the first epoch
boundary. Its active receiver then pinned up to 10,000 events (the
external event channel capacity) on HotShot's broadcast stream, each
Decide holding Arc'd validated state: about 1.2 GB per node on any
network below V0_6, the 1.8 GB -> 7.8 GB epoch-reward memory-soak step.

Make the bridge submissions (timeout votes, legacy high QC, epoch
bumps) fire-and-forget try_send so a parked coordinator can never block
a forwarder, and gate epoch-bump forwarding on a decided upgrade
certificate targeting NEW_PROTOCOL_VERSION so networks that never cut
over queue nothing. Regression tests cover the parked-coordinator drain
and the gate.

* fix(new-protocol): gate all legacy bridge forwarders on the V0_6 cert

LegacyTimeoutVoteEmitted fires for any decided upgrade cert, so during
a non-V0_6 upgrade window (mainnet V0_4 -> V0_5 next) timed-out views
would queue SubmitTimeoutVote requests the parked coordinator never
drains; at 256 queued, the requests that matter at the real V0_6
boundary drop with ChannelFull. Share one cutover_decided predicate
across all three forwarders so nothing queues until the decided cert
targets NEW_PROTOCOL_VERSION.

* docs(new-protocol): trim bridge forwarder comments

* refactor(new-protocol): drop legacy epoch-change forwarding

Queued epoch bumps could never apply while the coordinator is parked,
and coordinator start supersedes them: startup apply_epoch dials the
cutover epoch from the seed, apply_epoch is monotone so stale bumps
no-op, and post-start the proposal-driven path owns epoch changes.
Delete the forwarder, its client method and request variant, and the
handler arm; retarget the parked-coordinator and gate tests to
forward_legacy_high_qc.

* refactor(new-protocol): rename bridge submitters with try_ prefix

* Restore epoch change notification before activation. (#4700)

Keep Cliquenet peer configuration up to date before the coordinator is
started.

* chore: fix flaky tests  (#4692)

* fix(new-protocol): re-broadcast timeout votes until the view advances

A node's view timer fired exactly once per view, so its TimeoutVote2
broadcast was single-shot. With a live set exactly at the TC2 threshold
(e.g. 7 nodes with 2 silenced at cutover), one lost broadcast deadlocked
consensus permanently: no re-send, no timer re-fire, TC2 unicast only to
the next leader. Observed as perm_silence_m2_m1 stalling for 240s with
zero decisions in both post-#4689 CI runs.

Re-arm the timer on fire so the vote is re-broadcast every timeout
period until the view advances. Collectors dedup by (view, signer);
participation and timeout metrics count once per view.

* test(new-protocol): permit TC2-skip of boundary views in happy-path cutover

Under start-time skew the cutover seed can be snapshotted before the QC
for the last legacy view lands, so the smooth-start precondition fails
and views 24/25 get TC2-skipped: legitimate behavior, same physics
permitted for the perm sweep in #4689. Smooth start stays deterministic
under src/tests/cutover.rs unit tests. Failed CI run 29425671834 with
node 0 skipping view 24.

* test(hotshot): failure budget for test_with_failures_2_with_epochs

Recovery gaps after the view-5 crash land on different views run to
run; the consistency task shut the test down on the first out-of-list
gap, which also starved the successful-view count (CI run 29425671834:
unexpected failed view 9, then only 9/20 successful views). Apply the
#4663 budget pattern; num_successful_views=20 remains the gate.

* fix(consensus): cutover QC handling and parent proposal fetch (#4702)

* fix(new-protocol): re-broadcast timeout votes until the view advances

A node's view timer fired exactly once per view, so its TimeoutVote2
broadcast was single-shot. With a live set exactly at the TC2 threshold
(e.g. 7 nodes with 2 silenced at cutover), one lost broadcast deadlocked
consensus permanently: no re-send, no timer re-fire, TC2 unicast only to
the next leader. Observed as perm_silence_m2_m1 stalling for 240s with
zero decisions in both post-#4689 CI runs.

Re-arm the timer on fire so the vote is re-broadcast every timeout
period until the view advances. Collectors dedup by (view, signer);
participation and timeout metrics count once per view.

* test(new-protocol): permit TC2-skip of boundary views in happy-path cutover

Under start-time skew the cutover seed can be snapshotted before the QC
for the last legacy view lands, so the smooth-start precondition fails
and views 24/25 get TC2-skipped: legitimate behavior, same physics
permitted for the perm sweep in #4689. Smooth start stays deterministic
under src/tests/cutover.rs unit tests. Failed CI run 29425671834 with
node 0 skipping view 24.

* test(hotshot): failure budget for test_with_failures_2_with_epochs

Recovery gaps after the view-5 crash land on different views run to
run; the consistency task shut the test down on the first out-of-list
gap, which also starved the successful-view count (CI run 29425671834:
unexpected failed view 9, then only 9/20 successful views). Apply the
#4663 budget pattern; num_successful_views=20 remains the gate.

* fix cutover qc handling

* fetch leaf if we only have a DA proposal in leaf and state map

---------

Co-authored-by: Mathis Antony <sveitser@gmail.com>
Co-authored-by: Mathis <mathis@espressosys.com>

* Add more channel length metrics. (#4701)

---------

Co-authored-by: Toralf Wittner <tw@dtex.org>
Co-authored-by: Abdul Basit <45506001+imabdulbasit@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Chengyu Lin <linmrain@gmail.com>
Co-authored-by: Mathis <mathis@espressosys.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mathis Antony <sveitser@gmail.com>
bfish713 added a commit that referenced this pull request Jul 21, 2026
#4702 already landed on release-ff via the merge of main in #4712, so this
backport resolves to the current release-ff tree (no net change).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants