Eagerly sign sync committee messages on head events - #9675
Conversation
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
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MSiv1h1jtjqp8JE2AXbntQ
e5e0131 to
af6167b
Compare
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
af6167b to
31bb2ac
Compare
|
What drives the need for this PR? Any stakeholder needs more eager sync messages? |
dapplion
left a comment
There was a problem hiding this comment.
🤖 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%.
|
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 |
|
Some required checks have failed. Could you please take a look @shane-moore? 🙏 |
|
Thanks, I went through each point:
|
|
🤖 automated (dapplion's agent): second pass on 257a738. CI is fully green and 1.
2. 3. 4. Nits. Two things I checked and consider settled. The BN does not require None of the above is blocking. |
|
Some required checks have failed. Could you please take a look @shane-moore? 🙏 |
|
Thanks, went through each point:
|
| // 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; |
There was a problem hiding this comment.
Weird because clock only fails on system failures, so permanent failure is better
There was a problem hiding this comment.
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
|
This pull request has merge conflicts. Could you please resolve them @shane-moore? 🙏 |
|
@shane-moore, the PR has some merge conflicts, just flagging it. |
…-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
Proposed Changes
SyncCommitteeServicecurrently 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 currentslotor (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_serviceraces 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.SYNC_MESSAGE_DUE_BPS_GLOAS = 2500(Gloas time parameters) intoChainSpecwith a fork-awareget_sync_message_due_at_slot, so the timer fallback tightens to 25% of the slot at Gloas.tokio::broadcastwithBeaconNodeFallback::subscribe_to_head_events()so both services can consume them. The sharedhead_event_or_deadlinehelper also fixes a busy-spin in the attestation service when the head event channel closes.MockBeaconNodeand service tests on the shared VC harness.Additional Info
N/A
AI Assistance Disclosure
Tools used: Claude Code Fable 5