fix: skip sync committee participation while node is optimistic - #9752
Conversation
An optimistic validator must not sign sync committee messages, since it has not fully verified the execution payload of the head block it would sign over. Attestations and sync committee contributions are already gated on optimistic execution status, but the base sync committee message — built in the validator client from the head root, with no beacon-node produce endpoint to gate — was not. Expose the node's optimistic status from SyncingStatusTracker and skip sync committee duties in SyncCommitteeService when the node is optimistic. Spec: https://github.com/ethereum/consensus-specs/blob/v1.6.1/sync/optimistic.md#participating-in-sync-committees 🤖 Generated with AI assistance Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| // An optimistic validator MUST NOT participate in sync committees, since it has not | ||
| // fully verified the execution payload of the head block it would sign over. | ||
| // https://github.com/ethereum/consensus-specs/blob/v1.6.1/sync/optimistic.md#participating-in-sync-committees | ||
| if (this.syncingStatusTracker.isNodeOptimistic() === true) { |
There was a problem hiding this comment.
do we need to modify the validator client for this? are we doing this for attestations too?
There was a problem hiding this comment.
Double-checked the attestation path — the gate is entirely BN-side: produceAttestationData → notOnOptimisticBlockRoot(targetRoot) throws NodeIsSyncing (503), and the VC just surfaces that error (no VC-side optimistic check). That works because attestations must fetch their data from a BN produce endpoint, so there's a natural place to 503.
Sync committee is asymmetric:
- Contributions already have that chokepoint —
produceSyncCommitteeContributioncalls the samenotOnOptimisticBlockRootand 503s. Already handled BN-side, no VC change needed. - The base
SyncCommitteeMessagehas no produce endpoint — the VC builds it itself from the head root (ChainHeaderTracker.getCurrentChainHead/getBlockRoot) and only calls the BN to submit (POST /pool/sync_committees). There's no produce call in that path to return 503 from. A 503 at submit-time is after we've already signed, and would also reject relayed messages; adding a dedicated produce endpoint just to gate feels heavier than the check itself.
So unless you'd rather add a produce endpoint, the base message has to be gated VC-side — though note the BN already reports the head's optimistic status (getBlockRoot's execution_optimistic meta + the head SSE event), so it's really "make the VC honor what the BN already tells it," not new consensus logic.
That said, you're right that the current spot is wrong, and Codex caught two real bugs with it:
- Selection proofs (
DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF) are signed insidegetDutiesAtSlotbefore this gate, so an optimistic validator still signs them. - Node-level
is_optimisticfrom the periodic poll isn't the head actually being signed (can flip optimistic during the block wait).
I'll revise to gate per-head-root (checked after the block wait) and cover selection-proof signing too, dropping the node-level check. Good to keep it VC-side, or would you prefer a BN produce endpoint?
There was a problem hiding this comment.
Done in bfeee38. I kept this VC-side because the base SyncCommitteeMessage path still has no BN produce endpoint: the VC now checks the cached node optimistic state before getDutiesAtSlot so selection proofs are not signed while the node is already optimistic, and it rechecks the actual head/root after waitForBlockSlot before signing or publishing messages/contributions. Attestations and sync committee contributions remain BN-gated through their produce endpoints.
nflaig
left a comment
There was a problem hiding this comment.
@lodekeeper not sure why we fix this on vc side, isn't it better to have the beacon node return 503? please double check how we do this for attestations
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6cdb222e51
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // An optimistic validator MUST NOT participate in sync committees, since it has not | ||
| // fully verified the execution payload of the head block it would sign over. | ||
| // https://github.com/ethereum/consensus-specs/blob/v1.6.1/sync/optimistic.md#participating-in-sync-committees | ||
| if (this.syncingStatusTracker.isNodeOptimistic() === true) { |
There was a problem hiding this comment.
Gate optimism before signing selection proofs
When isNodeOptimistic() is true, this guard runs only after getDutiesAtSlot(slot) has already called signSyncCommitteeSelectionProof for each subnet, so an optimistic validator still signs over DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF. That violates the same optimistic-sync rule this check is meant to enforce, and in distributed aggregation mode those partial proofs can also be submitted before this return is reached. Move the optimism gate ahead of duty proof generation, or make duty lookup avoid producing selection proofs while optimistic.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed — getDutiesAtSlot → getSelectionProofs signs over DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF (and, in distributed-aggregation mode, submits the partial selections) before this gate runs, so an optimistic validator still signs the selection proofs. Good catch. Will move the optimistic gate ahead of duty/selection-proof generation so nothing is signed while optimistic.
There was a problem hiding this comment.
Fixed in bfeee38. The optimistic-node check now runs before getDutiesAtSlot, so we do not sign sync committee selection proofs or submit distributed selections when the node is already known optimistic.
| // An optimistic validator MUST NOT participate in sync committees, since it has not | ||
| // fully verified the execution payload of the head block it would sign over. | ||
| // https://github.com/ethereum/consensus-specs/blob/v1.6.1/sync/optimistic.md#participating-in-sync-committees | ||
| if (this.syncingStatusTracker.isNodeOptimistic() === true) { |
There was a problem hiding this comment.
Recheck optimism after waiting for the block
If /eth/v1/node/syncing was non-optimistic at slot start but the node imports the slot block optimistically before waitForBlockSlot(slot) resolves, this early check still allows produceAndPublishSyncCommittees to sign and publish the new optimistic head from ChainHeaderTracker. The optimistic status needs to be checked against the head that is actually about to be signed, or rechecked after the block wait and before signing.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid — the node-level is_optimistic from the periodic /eth/v1/node/syncing poll isn't necessarily the head that gets signed after waitForBlockSlot, so there's a window where a freshly-imported optimistic head slips through. Will switch to checking the optimistic status of the actual head root being signed, after the block wait, rather than the early node-level check.
There was a problem hiding this comment.
Fixed in bfeee38. After waitForBlockSlot, the VC resolves the head/root it is about to sign and skips the sync committee message/contribution flow if that head is optimistic, closing the slot-start status race.
nflaig
left a comment
There was a problem hiding this comment.
in principle this seems correct to me, we should not cast these messages while the EL is syncing/optimistic, I haven't reviewed the code in detail
wemeetagain
left a comment
There was a problem hiding this comment.
This makes sense to me.
This can't be done on the BN side since there is no data beyond a block root needed to sign over that can be gated by the BN.
|
Thanks, acknowledged. This resolves the BN-vs-VC placement question for me: the base The follow-up in |
| // Step 2. If an attestation was produced, make an aggregate. | ||
| // First, wait until the `CONTRIBUTION_DUE_BPS` of the slot | ||
| const syncContributionDueMs = this.config.getSyncContributionDueMs(fork); | ||
| await sleep(syncContributionDueMs - this.clock.msFromSlot(slot), signal); |
There was a problem hiding this comment.
@lodekeeper shouldn't we also skip signing DOMAIN_CONTRIBUTION_AND_PROOF like done in produceAndPublishAggregates if an optimistic head arrives during this period?
There was a problem hiding this comment.
Good catch, yes. produceSyncCommitteeContribution gates the contribution block root, but it does not cover the case where a new optimistic head arrives during the wait between DOMAIN_SYNC_COMMITTEE signing and DOMAIN_CONTRIBUTION_AND_PROOF signing.
Fixed in 75383738c6: after the contribution wait, the VC now resolves the current head again and returns before producing/signing contributions if that head is optimistic. I also added a unit test for that race and updated the PR body so the contribution-and-proof path is explicitly covered.
| const contributionHead = await this.getHeadForSyncCommitteeMessage(slot); | ||
| if (contributionHead.executionOptimistic) { | ||
| this.logger.debug("Skipping sync committee contributions while head is optimistic", {slot}); | ||
| return; | ||
| } |
There was a problem hiding this comment.
this is about aggregated messages isn't it? so not sure it's needed here, we can still aggregate even if node is optimistic, what we want to avoid is signing messages that we haven't validated properly ourselves due to node being optimistic
@lodekeeper can you check how other clients handle this
There was a problem hiding this comment.
Checked other clients and I agree with this distinction.
- Lighthouse fetches a non-optimistic head root once, signs sync committee messages for that root, then later produces/signs aggregates for the same root. It does not recheck the current head before aggregate signing.
- Nimbus has the same shape:
getHeadBlockRootrejects optimistic responses before message signing, then contribution production uses that saved root after the contribution delay. - Teku also carries
lastSignatureBlockRootfrom the message duty into the aggregation duty. - Prysm is a bit stricter in its REST VC path because it asks for the current head root when producing the contribution, and that helper rejects optimistic heads, but its BN produce endpoint also rejects optimistic mode before returning contribution data.
So the extra check I added was over-broad for Lodestar's flow: if we already signed DOMAIN_SYNC_COMMITTEE for a non-optimistic beaconBlockRoot, we can still ask the BN to aggregate for that same root even if the current head later becomes optimistic. The BN produceSyncCommitteeContribution path remains the right place to reject an optimistic requested root before the VC signs DOMAIN_CONTRIBUTION_AND_PROOF.
Adjusted in 43907237ef: removed the second current-head check before contribution production, changed the unit test to assert aggregation still uses the previously validated root, and updated the PR body.
|
🎉 This PR is included in v1.46.0 🎉 |
Motivation
While debugging a
lodestar-besunode onglamsterdam-devnet-7that was following the chain optimistically (CL at head, EL still backfilling), the validator client was observed publishingSyncCommitteeMessages even though/eth/v1/node/syncingreportedis_optimistic=true:The optimistic sync spec says an optimistic validator MUST NOT participate in sync committees:
Attestations (
produceAttestationData) are already gated on optimistic execution status in the beacon-node API. Sync committee participation is asymmetric: the base sync committee message is built in the validator client from the head root (ChainHeaderTracker/getBlockRoot) and submitted directly, so the validator client must honor the beacon node's optimistic status before signing that root.Description
SyncingStatusTracker.isNodeOptimistic(), derived from the last successful/eth/v1/node/syncingpoll the tracker already performs every slot. Returnsundefinedwhen the status is unknown, so callers don't over-suppress.SyncCommitteeService.runSyncCommitteeTasks, skip sync committee duties before fetching duties when the node is already optimistic, soDOMAIN_SYNC_COMMITTEE_SELECTION_PROOFis not signed while optimistic.DOMAIN_SYNC_COMMITTEEsigning if that head is optimistic.beaconBlockRoot; the beacon-nodeproduceSyncCommitteeContributionendpoint remains responsible for rejecting optimistic block roots before the VC signsDOMAIN_CONTRIBUTION_AND_PROOF.TODO/PENDINGcomment invalidator/index.tswith a pointer to the new VC-side gate.Steps to test
Added unit coverage in
packages/validator/test/unit/services/syncCommittee.test.tsasserting that optimistic status suppresses selection-proof signing and sync committee message signing, while contribution aggregation still uses the previously validated block root. Existing non-optimistic sync committee behaviour is unchanged.