Skip to content

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

@shane-moore

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 current_slot >= GLOAS_FORK_EPOCH. 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 §4 removes the pre-fork relay-builder mechanism (along with blinded blocks); post_validator_register_validator becomes a no-op at the BN under Gloas. A Gloas-aware skip on the Anchor side avoids the wasted RPC call and operator-log noise.

Context

Per SIP-94 §4 (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 the BN ignores those calls. 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 §4: under Gloas, the relay-builder registration mechanism is
    // removed; the BN ignores post_validator_register_validator. Skip the
    // periodic publish to avoid wasted 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 §4 (relay-builder removal) 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

Blocked by the LH-bump milestone (M0) for ChainSpec::fork_name_at_slot, ForkName::gloas_enabled, and the gloas_fork_epoch field on ChainSpec. Independent of #1062, #1063, #1064 — can land in parallel with the rest of the M5 arc.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions