Skip to content

feat(validator_store): implement sign_proposer_preferences (SingleValidator partial-sig) #1063

Description

@shane-moore

Goal

Implement the sign_proposer_preferences LH trait method on AnchorValidatorStore, filling the existing TODO(gloas) stub (anchor/validator_store/src/lib.rs:3367-3374), as a SingleValidator-mode partial-signature collection (no QBFT): sign the supplied ProposerPreferences under DOMAIN_PROPOSER_PREFERENCES with the validator's BLS share and reconstruct via the signature collector. The trait signature is (validator_pubkey: PublicKeyBytes, preferences: ProposerPreferences) -> SignedProposerPreferences (validator_client/validator_store/src/lib.rs:217 at LH pin 81d576943, PR #1125's pin; re-verify the live pin in Cargo.toml before trusting line refs); the preferences arrive fully built from the LH ProposerPreferencesService, so no duty or config derivation happens in this method. Mirrors sign_validator_registration_data / sign_voluntary_exit in shape, and LH's own implementation (lighthouse_validator_store/src/lib.rs:1490 at the pin) for the domain and signing flow.

Context

Per SIP-94 §5, each operator independently derives ProposerPreferences { dependent_root, proposal_slot, validator_index, fee_recipient, target_gas_limit } and signs it; reconstruction succeeds only when 2f + 1 operators converge on one signing root. No QBFT: target_gas_limit (operator config) and dependent_root (per-operator BN observation) determine each operator's root locally.

Collection slot = proposal_slot (SIP-94 §5/§7; supersedes the earlier slot_clock.now() design in this issue's history). The slot passed to collect_signature becomes the PartialSignatureMessages.slot stamped on the wire (SignatureMetadata built at lib.rs:461 with its slot field at :469; wire stamp slot: metadata.slot at signature_collector/src/lib.rs:312-316), and SIP-94 §5 pins that slot to proposal_slot itself: one runner per proposal slot, matching ssv-spec's validatePartialSigMsgForSlot (msg.Slot == duty.DutySlot()). go-ssv parity: protocol/v2/ssv/runner/proposer_preferences.go:40,80,199 (one sub-runner per proposal slot; "duty.Slot is the proposal slot the preference targets, which is also the slot carried on the [wire]"). Message validation accepts the future slot via the role's earliness allowance and validates proposer assignment against it (#1062).

The collector retention story improves under this design. Collectors are reaped once for_slot < current_slot - SIGNATURE_COLLECTOR_RETAIN_SLOTS(1) (signature_collector/src/lib.rs:50,423-427), so a collector keyed to a future proposal_slot lives from creation until the proposal slot passes, and operators no longer race a 1-slot arrival window for lookahead emissions (the failure mode the earlier send-slot design had to work around). Collectors key on (signing_root, validator_index) (signature_collector/src/lib.rs:375-381), so a dependent_root re-emission for the same proposal slot creates a fresh collector under its new root; operators need only sign on the same trigger (epoch boundary or the shared re-emission trigger, §5).

Suggested approach

  1. Look up the validator metadata and cluster for validator_pubkey (the same lookup sign_validator_registration_data / sign_voluntary_exit use). The preferences argument arrives fully built from the LH ProposerPreferencesService (proposer_preferences_service.rs:141-190 at the pin: dependent_root and proposal_slot from the duties map, fee_recipient and target_gas_limit from proposal_data(pubkey)); duty and config derivation is that service's layer, tracked under feat(client): spawn LH ProposerPreferencesService with AnchorValidatorStore backend #1064, and SIP-94 §5's byte-for-byte agreement requirement applies there. (The container field is now target_gas_limit, matching consensus-specs and SIP-94; older pins named it gas_limit.)
  2. Compute the signing domain via the 2-arg wrapper self.get_domain(epoch, Domain::ProposerPreferences) (lib.rs:277-284) with epoch = preferences.proposal_slot.epoch(E::slots_per_epoch()): the domain epoch is keyed on proposal_slot so the signature verifies against the fork of the slot being proposed, including the §5 pre-fork emission case (Gloas domain while the chain is pre-fork). Same derivation as LH's reference implementation.
  3. signing_root = preferences.signing_root(domain).
  4. Collect with the duty's slot: collect_signature(PartialSignatureKind::ProposerPreferences, Role::ProposerPreferences, CollectionMode::SingleValidator, &validator, &cluster, signing_root, preferences.proposal_slot), following the RANDAO call shape at lib.rs:2550-2558. Do NOT stamp slot_clock.now() and do NOT stamp epoch-start; the wire slot is the duty's proposal_slot (see Context).
  5. Return the reconstructed SignedProposerPreferences to the caller (the LH ProposerPreferencesService spawned in feat(client): spawn LH ProposerPreferencesService with AnchorValidatorStore backend #1064 handles submission).

Acceptance criteria

  • The broadcast PartialSignatureMessages.slot equals preferences.proposal_slot (assert via the collector/manager path, not just the call site).
  • Domain epoch equals epoch(proposal_slot); a preference for a first-Gloas-epoch slot signed pre-fork uses the Gloas fork domain.
  • Kind is PartialSignatureKind::ProposerPreferences, role Role::ProposerPreferences, SingleValidator mode.
  • No slashing-protection database read or write (registration-style non-slashable duty).
  • Re-emission for the same proposal_slot with a changed dependent_root produces a different signing root and a fresh collector; both reconstruct independently when quorums exist.
  • A lookahead emission (proposal slot up to (1 + min_seed_lookahead) epochs ahead) reconstructs even when partial sigs arrive several slots after emission (collector alive until proposal_slot + 1).
  • A missing quorum fails per-validator with a bounded collection timeout; it must never hang the caller indefinitely, so one failed validator cannot stall sibling validators' signing or publication (see Risks: batch liveness).
  • Collection failures are surfaced cause-neutrally (e.g. threshold not reached / timeout). The wire carries only the signing root, so telemetry must not claim attribution to target_gas_limit vs dependent_root divergence; either can only be listed as a possible cause.
  • No duty or config derivation inside validator_store: the signed message equals the supplied preferences verbatim (the method only looks up validator/cluster, computes domain and root, and collects).
  • No change to sign_validator_registration_data (registration keeps its send-slot semantics pre-Gloas).

Risks

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions