Skip to content

feat(validator_store): fork-gate RegistrationService at GLOAS_FORK_EPOCH #1065

Description

@shane-moore

Goal

Insert a Gloas short-circuit in RegistrationService::start_validator_registration_service's spawning loop (anchor/validator_store/src/registration_service.rs:60-92) so that the periodic register_validators call is skipped once the current slot's fork is Gloas (fork_name_at_slot(slot).gloas_enabled()). The service stays spawned; the periodic loop sleeps to the next epoch instead of issuing a wasted BN call. Pre-Gloas behavior unchanged.

Do NOT delete the service. Pre-Gloas testnets and the Gloas transition window still need it. SIP-94 §5 deprecates the ValidatorRegistration duty at the Gloas fork, its purpose replaced by SignedProposerPreferences; the pre-fork relay-builder mechanism it fed is gone along with blinded blocks (§4), so registrations are vestigial under Gloas. Note the pinned LH BN does NOT actually no-op post_validator_register_validator (verified: no Gloas gate in the handler at beacon_node/http_api/src/validator/mod.rs:579; it still updates the proposer-preparation cache and forwards to the builder regardless of fork, unlike prepare_beacon_proposer); the call still runs against a relay flow that no longer feeds block production, which is exactly why Anchor should stop issuing it. A Gloas-aware skip on the Anchor side avoids the wasted RPC call and operator-log noise.

Context

Per SIP-94 §5 (ssvlabs/SIPs#94), the pre-Gloas relay-builder registration flow is replaced under Gloas by the new proposer_preferences p2p mechanism (handled by #1062, #1063, #1064). The legacy RegistrationService continues to call post_validator_register_validator against the BN every slot; under Gloas those registrations no longer feed block production (the relay-builder flow is gone, though the pinned LH BN still accepts the call). Anchor should short-circuit the periodic loop so:

  • Pre-Gloas testnets continue to publish registrations unchanged (Boole and earlier).
  • The transition window (Anchor running post-LH-bump but pre-Gloas-activation on a given network) continues to publish.
  • Once the slot clock crosses GLOAS_FORK_EPOCH, the periodic loop sleeps without invoking the registration path.

LH's proposer_preferences_service.rs:77-87 and payload_attestation_service.rs:88-100 already use this pattern (with inverted polarity, skip-when-not-Gloas). For RegistrationService, the polarity is reversed: skip when Gloas is enabled.

Suggested approach

anchor/validator_store/src/registration_service.rs:

Location Change
start_validator_registration_service (around :70-85) Inside the if let Some(slot) = self.inner.slot_clock.now() branch, add a Gloas fork-gate that sleeps to the next epoch and continues if spec.fork_name_at_slot::<S::E>(slot).gloas_enabled().

Sketch (gate sits before the existing inner.register_validators(slot).await call):

if let Some(slot) = self.inner.slot_clock.now() {
    // SIP-94 §5: the ValidatorRegistration duty is deprecated at the Gloas
    // fork (the relay-builder mechanism it fed is gone with blinded blocks,
    // §4). The BN still accepts post_validator_register_validator, but the
    // registration no longer feeds block production, so skip the periodic
    // publish to avoid a vestigial RPC + operator-log noise. The service
    // stays spawned so pre-Gloas networks continue unchanged.
    if spec.fork_name_at_slot::<S::E>(slot).gloas_enabled() {
        let sleep_duration = self
            .inner
            .slot_clock
            .duration_to_next_epoch(S::E::slots_per_epoch())
            .unwrap_or_else(|| slot_duration * S::E::slots_per_epoch() as u32);
        sleep(sleep_duration).await;
        continue;
    }

    let inner = self.inner.clone();
    let executor = inner.executor.clone();
    let future = async move {
        if let Err(e) = inner.register_validators(slot).await {
            error!(error = ?e, "Error during validator registration");
        }
    };
    executor.spawn(future, "validator_registration");
} else {
    error!("Slot clock can not return current slot");
}

spec is already in scope (cloned at the top of start_validator_registration_service); no struct changes to Inner required. S::E is the generic already used elsewhere in the file (e.g., S::E::slots_per_epoch() inside register_validators).

Testability

The register_validators function isn't called when the gate skips, so a unit test that exercises the gate directly is awkward (the spawning loop runs forever). Extract the gate decision into a small pure helper so it can be asserted without a runtime:

fn should_skip_for_gloas<E: EthSpec>(spec: &ChainSpec, slot: Slot) -> bool {
    spec.fork_name_at_slot::<E>(slot).gloas_enabled()
}

Tests assert this helper directly (no async, no slot clock, no BN mock).

Acceptance criteria

  • Pre-Gloas slot: register_validators is invoked from the spawning loop unchanged (existing behavior).
  • Gloas slot: the spawning loop sleeps to the next epoch without invoking register_validators or making any BN call.
  • Service stays spawned across the fork transition; the loop re-evaluates the gate each iteration.
  • In-code comment references SIP-94 §5 (duty deprecation) so a future reader understands why the gate is there.
  • cargo check --workspace green; existing RegistrationService tests unaffected.

Tests to deliver (against the extracted helper):

#[test]
fn should_skip_for_gloas_returns_true_after_fork() {
    let mut spec = ChainSpec::mainnet();
    spec.gloas_fork_epoch = Some(Epoch::new(0)); // active from genesis
    assert!(should_skip_for_gloas::<MainnetEthSpec>(&spec, Slot::new(0)));
}

#[test]
fn should_skip_for_gloas_returns_false_before_fork() {
    let mut spec = ChainSpec::mainnet();
    spec.gloas_fork_epoch = Some(Epoch::new(100_000)); // far future
    let pre_fork_slot = Slot::new(32); // epoch 1
    assert!(!should_skip_for_gloas::<MainnetEthSpec>(&spec, pre_fork_slot));
}

#[test]
fn should_skip_for_gloas_returns_false_when_unscheduled() {
    let mut spec = ChainSpec::mainnet();
    spec.gloas_fork_epoch = None; // never
    assert!(!should_skip_for_gloas::<MainnetEthSpec>(&spec, Slot::new(100_000)));
}

Risks

  • Do not delete the service. Pre-Gloas testnets and transition-window operators still need it. Fork-gating to a sleeping no-op preserves backward compatibility; deleting would break Holesky / Hoodi / any non-Gloas network Anchor is run against.
  • Gating choice (gloas_enabled() vs explicit epoch comparison). Using LH's gloas_enabled() helper (consensus/types/src/fork/fork_name.rs:205) mirrors how LH gates its own Gloas-aware code in proposer_preferences_service.rs, payload_attestation_service.rs, block_service.rs, and elsewhere. If LH refactors the helper, Anchor inherits the change via the LH bump. Acceptable; no Anchor-side abstraction required.
  • Sleep duration unwrap. duration_to_next_epoch returns Option; on None (slot-clock failure) the fallback is slot_duration * slots_per_epoch, matching LH's proposer_preferences_service.rs:81-83 fallback shape.

Dependencies

All required LH symbols are already in the current pin (81d576943; re-verify against Cargo.toml): ChainSpec::fork_name_at_slot, ForkName::gloas_enabled, and gloas_fork_epoch: Option<Epoch>. These predate the proposer-preferences work, so no LH bump is needed. Independent of #1062, #1063, #1064; can land in parallel with the rest of the milestone.

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