Skip to content

fix(duties_tracker): validate proposer assignments against complete current/next duty views (SIP-94 §5/§7) #1142

Description

@shane-moore

Goal

Implement the SIP-94 §5/§7 receive-side guarantee for proposer-assignment validation without making proposer-duty freshness depend on Anchor's SSV validator snapshot.

Anchor already downloads the beacon node's complete proposer schedule. Retain that complete schedule for the current and next epoch, then validate Role::ProposerPreferences (8) assignments by the validator pubkey carried in the message ID. Return NoDuty only when a fetched, complete epoch view proves that the validator is not assigned at proposal_slot; an unfetched epoch remains unknown and is tolerated.

Expose the same pubkey-based assignment query for the §6 RoleEnvelopeProposer (9) arm in milestone 8.

Context / motivation

SIP-94 was updated for this in 0a8de9e, following iurii-ssv's devnet finding. A §5 preference partial is a deterministic one-shot gossip broadcast. Anchor derives the gossipsub message ID from the full message bytes and retains duplicate IDs for an epoch, so an identical rebroadcast cannot recover from an initial wrong drop.

The observed failure is:

  1. A receiver fetches proposer duties before a validator registration wave.
  2. The beacon node returns the complete epoch schedule, but Anchor filters it against NetworkState::validator_indices() at fetch time (duties_tracker/src/duties_tracker.rs:205-257).
  3. A newly registered validator is therefore absent from the stored vector even though its beacon proposer assignment already existed.
  4. is_epoch_known_for_proposers still returns true.
  5. The role-8 arm from feat: add Role::ProposerPreferences with message-validator fork-gate #1062 returns ValidationFailure::NoDuty -> MessageAcceptance::Ignore, permanently losing the honest partial.

The stale view is created by Anchor's local filtering, not by the beacon response. An epoch contains one proposer assignment per slot, so retaining the full response is small and makes the assignment view independent of SSV validator membership and index-update timing.

Pubkey lookup is also load-bearing. A newly registered validator can exist in NetworkState before its beacon index is populated: get_committee_info_by_validator_pk then returns committee membership with an empty validator_indices vector (database/src/state.rs:374-389). Role 8 must not turn that temporary index gap into UnexpectedFailure / REJECT when the complete proposer view can validate the assignment directly by pubkey.

Suggested approach

1. Retain complete current and next epoch proposer views (duties_tracker)

Refactor poll_beacon_proposers to fetch both current_epoch and current_epoch + 1, matching the role-8 proposer lookahead.

For each epoch:

  • call the existing proposer-duties endpoint;
  • store response.data unchanged, without filtering against network_state_rx or validator_indices();
  • preserve each successful epoch independently if the other request fails;
  • keep the existing historical pruning.

No database generation counter or validator-set invalidation protocol is needed: SSV registration changes no longer change the contents of a cached proposer view.

2. Add one atomic pubkey-based assignment query (DutiesProvider)

Keep the existing is_epoch_known_for_proposers and index-based is_validator_proposer_at_slot methods for the existing Role::Proposer path. Add:

fn proposer_assignment_at_slot(
    &self,
    slot: Slot,
    validator_pubkey: &PublicKeyBytes,
) -> Option<bool>;

Semantics:

  • Some(true): the epoch is fetched and the complete view assigns this pubkey at slot;
  • Some(false): the epoch is fetched and the complete view proves this pubkey is not assigned at slot;
  • None: the epoch is not fetched, so assignment is unknown and must be tolerated.

Compute the result under one proposer-map read. Do not split freshness and assignment into separate predicate calls.

3. Consume the tri-state result in the role-8 arm (message_validator)

In the Role::ProposerPreferences arm introduced by #1062:

  • extract the validator pubkey from DutyExecutor::Validator in the message ID;
  • query proposer_assignment_at_slot(slot, &validator_pubkey);
  • return ValidationFailure::NoDuty only for Some(false);
  • accept Some(true) and tolerate None;
  • do not require committee_info.validator_indices.first() for this role.

The validator is still required to exist in Anchor's registry and the signer is still required to belong to its committee through the earlier message-validation path. Only the proposer-assignment lookup stops depending on the locally resolved validator index.

Acceptance criteria

  • Proposer duties returned by the beacon node are retained even when their validator pubkeys or indices are absent from NetworkState.
  • Successful current and next epoch fetches are both stored; failure for one epoch does not discard a successful fetch for the other.
  • proposer_assignment_at_slot returns:
    • Some(true) for the assigned pubkey and slot;
    • Some(false) for an unassigned pubkey in a fetched epoch;
    • None for an unfetched epoch.
  • A role-8 message for an assigned, registered validator is accepted even when its local validator index is still unresolved.
  • A role-8 message against a fetched view where the pubkey is not assigned at proposal_slot returns NoDuty (IGNORE).
  • A role-8 message for an unfetched epoch is accepted (existing unknown-view tolerance).
  • Existing Role::Proposer (6), Role::SyncCommittee, and database behavior are unchanged.
  • The future RoleEnvelopeProposer (9) assignment arm can reuse proposer_assignment_at_slot without adding validator-set freshness state.

Tests

Use the tester-subagent before authoring or modifying tests.

  • duties_tracker:
    • stores the complete proposer response when NetworkState::validator_indices() is empty;
    • stores current and next epoch views;
    • preserves one successful epoch when the other fetch fails;
    • covers the Some(true) / Some(false) / None assignment states.
  • message_validator (extend feat: add Role::ProposerPreferences with message-validator fork-gate #1062's role-8 harness):
    • assigned pubkey + unresolved local index -> accepted;
    • fetched epoch + unassigned pubkey -> NoDuty;
    • assigned pubkey -> accepted;
    • unknown epoch -> accepted.
  • Regression-pin the existing index-based proposer path.

Verification: make cargo-fmt && make lint, then cargo test -p duties_tracker -p message_validator.

Notes

Issues are directionally correct, not prescriptive; verify symbols at PR time.

Metadata

Metadata

Assignees

Labels

epbsePBS / EIP-7732 / Gloas implementation

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions