Skip to content

Eagerly sign sync committee messages on head events - #9675

Open
shane-moore wants to merge 19 commits into
sigp:unstablefrom
shane-moore:feat/eager-sync-committee-messages
Open

Eagerly sign sync committee messages on head events#9675
shane-moore wants to merge 19 commits into
sigp:unstablefrom
shane-moore:feat/eager-sync-committee-messages

Conversation

@shane-moore

@shane-moore shane-moore commented Jul 21, 2026

Copy link
Copy Markdown
Member

Proposed Changes

SyncCommitteeService currently waits for the sync message due point every slot, even when the head block arrived seconds earlier. Per the Altair honest validator spec, a sync committee member should produce the message "either when (a) the validator has received a valid block from the expected block proposer for the current slot or (b) get_sync_message_due_ms() milliseconds has transpired since the start of the slot", whichever comes first. This applies the head-event trigger that #7892 added for attestations to sync committee messages:

  • sync_committee_service races a head event against its due timer. Head-triggered slots sign the event's block root (the head monitor only forwards non-optimistic heads) and publish shortly after block arrival instead of at the due point.
  • Wires SYNC_MESSAGE_DUE_BPS_GLOAS = 2500 (Gloas time parameters) into ChainSpec with a fork-aware get_sync_message_due_at_slot, so the timer fallback tightens to 25% of the slot at Gloas.
  • Converts head events from mpsc to tokio::broadcast with BeaconNodeFallback::subscribe_to_head_events() so both services can consume them. The shared head_event_or_deadline helper also fixes a busy-spin in the attestation service when the head event channel closes.
  • Adds sync committee endpoints to MockBeaconNode and service tests on the shared VC harness.

Additional Info

N/A

AI Assistance Disclosure

Tools used: Claude Code Fable 5

shane-moore and others added 7 commits July 18, 2026 00:08
Publish sync committee messages as soon as a non-optimistic head event
for the current slot arrives, instead of always sleeping to the due
point, mirroring the attestation service change from sigp#7892.
Contributions remain delayed to their point in the slot.

- Convert the head event channel to tokio broadcast so multiple services
  can subscribe via BeaconNodeFallback::subscribe_to_head_events.
- Share the head-event-vs-deadline select (including the degrade to
  timer-only on channel close) between the attestation and sync
  committee services via beacon_head_monitor::head_event_or_deadline.
  This also fixes the attestation service busy-looping if the head
  event channel ever closed.
- If a head event arrives before sync duties are computed, wait until
  the sync message deadline and check once more, reusing the event root.
- Make sync message timing fork-aware via
  ChainSpec::get_sync_message_due_at_slot (SYNC_MESSAGE_DUE_BPS_GLOAS).
- Extend MockBeaconNode with sync duty, head root, sync message pool and
  subscription mocks, and add sync committee service tests on the shared
  ValidatorClientHarness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSiv1h1jtjqp8JE2AXbntQ
Three fixes from adversarial review of the eager sync message loop:

- Derive next slot and its start offset from a single clock read
  (next_slot_with_duration), so a slot boundary passing between reads
  cannot anchor the timer to one slot's start with another slot's due
  point (previously ~1s early for one slot at the Gloas transition).
- Pass the triggering slot into spawn_contribution_tasks instead of
  re-reading the clock, keeping the signed slot, dedupe marking, and
  duty lookup consistent with a single slot value.
- Discard the head event root when the missing-duties retry sleeps to
  the deadline, falling back to a fresh non-optimistic head lookup, as
  the attestation service's deadline retry does. The event root may be
  stale after sleeping most of a slot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSiv1h1jtjqp8JE2AXbntQ
Take the slot for sync message signing from the trigger itself: the
validated event slot for the head event arm, or the armed next slot for
the timer arm. Previously the loop re-read the clock after triggering,
so a slot boundary (or backwards clock movement, which SlotClock
permits) between the event's validation and the re-read could sign slot
N+1 with slot N's root and, worse, mark N+1 as handled so the real N+1
head event was suppressed by the dedupe check.

With the trigger-derived slot there are no post-trigger clock reads: a
late event signs for its own slot (or is skipped by the expired-slot
guard), and the next slot's messages are never suppressed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSiv1h1jtjqp8JE2AXbntQ
Move sync_message_due_bps_gloas next to sync_message_due_bps in the
gnosis constructor, which places both pairs of due keys adjacent to
their base values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSiv1h1jtjqp8JE2AXbntQ
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSiv1h1jtjqp8JE2AXbntQ
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSiv1h1jtjqp8JE2AXbntQ
@chong-he chong-he added val-client Relates to the validator client binary ready-for-review The code is ready for review labels Jul 30, 2026
@shane-moore
shane-moore force-pushed the feat/eager-sync-committee-messages branch from e5e0131 to af6167b Compare August 3, 2026 22:28
Merges cleanly. The attestation loop now pairs upstream's fork-aware
`attestation_deadline()` with this branch's shared `head_event_or_deadline()`
helper; the sync side keeps `get_sync_message_due_at_slot()`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019xd8HZnvqcMotvkuEQGh8W
@shane-moore
shane-moore force-pushed the feat/eager-sync-committee-messages branch from af6167b to 31bb2ac Compare August 3, 2026 22:35
@dapplion

dapplion commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

What drives the need for this PR? Any stakeholder needs more eager sync messages?

@dapplion dapplion left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Agent-assisted review (Claude Code). Five notes, ordered by how much they matter.

validator_client/validator_services/src/sync_committee_service.rs:266
The attestation path compares the head event root against the beacon node that sent it. This path does not. Is this deliberate?

validator_client/validator_services/src/sync_committee_service.rs:145
next_slot is always slot_of(now) + 1, so a slot that the loop is already inside gets no deadline. Only a head event covers it.

validator_client/validator_services/src/sync_committee_service.rs:193
spawn_contribution_tasks now returns Ok(()) on every path, so this crit! is unreachable and the latch advances for slots that signed nothing.

validator_client/validator_services/src/sync_committee_service.rs:794
100ms of real time is not enough for BLS signing and two HTTP requests, so this test can fail on a loaded runner.

consensus/types/src/core/chain_spec.rs:4068
CONTRIBUTION_DUE_BPS_GLOAS (5000) is still not connected, so at Gloas the client aggregates at 66.67% of the slot, not 50%.

@shane-moore

Copy link
Copy Markdown
Member Author

Anchor motivated this PR. It combines attestation and sync message signing into one gossip batch. Because of that, an eager attestation waits for lh to start the sync message duty

This PR starts that duty from the same head event. anchor can then finish the batch and return the attestation sooner. This gives it more time to reach aggregators before 6 seconds

Altair spec compliance is a nice bonus too

@mergify

mergify Bot commented Aug 4, 2026

Copy link
Copy Markdown

Some required checks have failed. Could you please take a look @shane-moore? 🙏

@mergify mergify Bot added waiting-on-author The reviewer has suggested changes and awaits thier implementation. and removed ready-for-review The code is ready for review labels Aug 4, 2026
@mergify mergify Bot added ready-for-review The code is ready for review and removed waiting-on-author The reviewer has suggested changes and awaits thier implementation. labels Aug 4, 2026
@shane-moore

Copy link
Copy Markdown
Member Author

Thanks, I went through each point:

  1. This is deliberate. The attestation path makes a second request for AttestationData, so it verifies that response still matches the event root. The sync path needs only the root already supplied by the non-optimistic head event, so there is no second value to compare. Re-fetching the head would add a round trip without independently validating the event. No change.

  2. Correct for the slot already in progress when the service starts. Every subsequent slot is armed as the next slot and receives its deadline, and the previous implementation had the same startup behavior. Covering the startup slot with a timer would change restart behavior because Lighthouse has no persistent record of whether it already sent a sync message for that slot. No change.

  3. Good catch on the dead Result and crit!. Fixed in e1b9bb4. spawn_contribution_tasks now returns (), the unreachable critical branch is removed, the latch is named last_processed_slot, and the trace is emitted only after task submission. The latch intentionally records the scheduled head-or-deadline attempt, not confirmed signing. Reopening the slot would introduce a later same-slot retry policy, and retrying after task submission could duplicate signing.

  4. Agreed, fixed in 257a738. The helper now polls for up to five seconds at 10 ms intervals and still returns as soon as the expected messages arrive.

  5. Agreed that the Gloas contribution deadline is still unwired. I am keeping it out of this PR because it is a pre-existing, separate inner-slot timing path that requires ChainSpec/Config plumbing, validator-monitor updates, and its own tests. This PR remains scoped to eagerly triggering unaggregated sync messages. The broader timing work is tracked by Inner slot timing refactor for Gloas/EIP-7732 #8615.

@dapplion

Copy link
Copy Markdown
Collaborator

🤖 automated (dapplion's agent): second pass on 257a738. CI is fully green and cargo nextest run -p validator_services --lib is 37/37 locally — the new sync tests take ~23s each, but so does every other test in that binary, so that is ValidatorClientHarness construction cost rather than anything this PR added. Four notes.

1. get_sync_message_due_at_slot is wired on the VC side only. Three beacon node callers still use the fork-blind get_sync_message_due():

  • beacon_node/beacon_chain/src/validator_monitor.rs:1539 — grades sync message delay against 33.33%, so post-Gloas it silently under-reports lateness.
  • beacon_node/beacon_chain/src/light_client_optimistic_update_verification.rs:74 and light_client_finality_update_verification.rs:78 — these reject with Error::TooEarly when an update arrives before the due point. Post-Gloas the VC publishes at 25% while the receiving BN gate still expects 33.33%.

GLOAS_FORK_EPOCH is u64::MAX in every config so nothing breaks today, but this PR is what creates the split between the two values. Worth a tracking issue next to #8615 rather than leaving it implicit.

2. validator_client/validator_services/src/sync_committee_service.rs:142last_processed_slot starts at None, so the first head event processes the in-progress slot no matter how late it arrives. At ~10s into a 12s slot, delay_until_slot_offset(contribution_due) saturates to ZERO and the contribution task fires immediately, publishing a contribution well past its due point. Not harmful, but it produces useless traffic and error logs. It also means the startup slot is covered by head events, just badly, which cuts against the earlier reply on this point. The guard at :236 only catches a slot that has fully rolled over, not one whose contribution deadline has already passed.

3. sync_committee_service.rs:172 — a backwards clock step (NTP correction) makes current_slot <= last_processed_slot hold until real time catches back up, and the service signs nothing for that window. The previous loop fired unconditionally every slot, so this failure mode is new. Bounded and rare.

4. Nits. get_sync_message_due_at_slot departs from the existing ChainSpec::get_attestation_due::<E>(slot) naming for an identically shaped helper. sync_committee_service.rs:225 uses positional {} formatting where the rest of the file uses structured %slot. .unwrap_or(Duration::ZERO) at :211 conflates "slot expired" with "due now" — downstream handles it, but an explicit early return would read better.

Two things I checked and consider settled. The BN does not require beacon_block_root to name a known block (sync_committee_verification.rs:470-540), so publishing the event root to a BN that has not yet imported it is accepted — the earlier reply about there being no second value to cross-check holds, and the missing BN affinity relative to the attestation path does not matter here. And the mpsc→broadcast switch correctly removes head-of-line blocking in the head monitor; recv() is cancel safe, so the select! in head_event_or_deadline loses no events, and the Lagged branch is handled.

None of the above is blocking.

@mergify

mergify Bot commented Aug 10, 2026

Copy link
Copy Markdown

Some required checks have failed. Could you please take a look @shane-moore? 🙏

@mergify mergify Bot added waiting-on-author The reviewer has suggested changes and awaits thier implementation. and removed ready-for-review The code is ready for review labels Aug 10, 2026
@shane-moore

Copy link
Copy Markdown
Member Author

Thanks, went through each point:

  1. Agreed, filed Use fork-aware sync message due point in beacon node callers #9784. The light client TooEarly gates are the behavior-relevant callers: the spec defines them via get_sync_message_due_ms(), which Gloas modifies, so post-Gloas we'd drop and downscore spec-compliant peers publishing between 25% and 33.33% of the slot. Left out of this PR since they're BN gossip validation with their own fork-boundary question, noted in the issue.

  2. Fair correction to my earlier point 2: the head-event path does cover the startup slot, including arbitrarily late. Fixed in df5a158. spawn_contribution_tasks now skips a slot whose contribution deadline has already passed, so a late first head event no longer publishes un-aggregatable messages or fires the contribution task into crit! logs. Only the startup slot could reach this in practice, since in steady state the deadline timer processes each slot before a late head event can. Added late_head_event_past_contribution_deadline_is_skipped to cover it.

  3. Agreed the failure mode is new, but I think the window it matters in is nearly empty: NTP daemons slew steady-state offsets and only step at boot (e.g. chrony makestep), and at boot the latch is fresh anyway, so there's rarely a latched slot to stall on. Re-signing across a step-back (the old behavior) also only pays off when the pre-step messages were invalid-early; in the other direction it just re-publishes messages BNs reject as non-current, trading a quiet gap for error noise. Given that, keeping the latch semantics matching latest_attested_slot in the attestation service seems right. No change.

  4. Fixed the positional formatting and replaced the unwrap_or(Duration::ZERO) with an explicit expired-slot return in 09296ab. On naming: the short name is occupied by the fork-blind get_sync_message_due() the BN callers still use, so the rename folds into Use fork-aware sync message due point in beacon node callers #9784, where that getter gets removed.

@mergify mergify Bot added ready-for-review The code is ready for review and removed waiting-on-author The reviewer has suggested changes and awaits thier implementation. labels Aug 10, 2026
// Skip the event on a clock read failure rather than returning `None`,
// which callers treat as a terminal error that disables head monitoring.
let Some(current_slot) = slot_clock.now() else {
continue;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Weird because clock only fails on system failures, so permanent failure is better

@shane-moore shane-moore Aug 13, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I feel you on that, done in b095400

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.

it also fails pre-genesis, but I think we already wait for genesis in the attestation service/sync committee service. that consideration might just affect the comment

@shane-moore shane-moore Aug 17, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

good point, fixed the wording in 367bb6f

@mergify

mergify Bot commented Aug 17, 2026

Copy link
Copy Markdown

This pull request has merge conflicts. Could you please resolve them @shane-moore? 🙏

@mergify mergify Bot added waiting-on-author The reviewer has suggested changes and awaits thier implementation. and removed ready-for-review The code is ready for review labels Aug 17, 2026
@NikhilSharmaWe

Copy link
Copy Markdown

@shane-moore, the PR has some merge conflicts, just flagging it.

shane-moore and others added 2 commits August 24, 2026 07:47
…-committee-messages

# Conflicts:
#	consensus/types/src/core/chain_spec.rs
#	validator_client/validator_services/src/sync_committee_service.rs
Replaces next_slot_with_duration with the sync_message_deadline helper
that landed on unstable, restoring its unit test and the defensive
pre-genesis fallback, and mirroring attestation_deadline in the
attestation service.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012YyYbMaMeNuxVq3Bv4a4FA
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

val-client Relates to the validator client binary waiting-on-author The reviewer has suggested changes and awaits thier implementation.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants