Skip to content

Commit c92e3bf

Browse files
shariqnaiyertcoratgerclaude
authored
feat: add validator sync-lag duty gate (leanEthereum#708)
* feat: add validator sync-lag duty gate * chore: inline logging * refactor(validator): harden sync-lag gate per consensus + py review Address review findings on the sync-lag duty gate. Decision logic - Replace peer-reported head-slot signal with local-store evidence: the freshest slot across blocks already validated into the store. Drops the unauthenticated peer.status.head.slot path entirely. - Add NETWORK_STALL_THRESHOLD (= 2 * SYNC_LAG_THRESHOLD) distinct from the local threshold so jitter at the local boundary cannot also trip the network-wide branch. - Add HYSTERESIS_BAND so a closed gate reopens only when lag drops to threshold - band, preventing slot-over-slot flap. - Persist gate state on the service; log only on state transitions instead of every query. - Saturate the future-head case at zero lag rather than trusting the chain unconditionally. API and types - duty parameter typed as Literal["block", "attestation"]. - Split _duties_skipped_lag into _blocks_skipped_lag and _attestations_skipped_lag, owned by the run loop. Attribution flattened so wrong-interval slots never tick the gate counter. - Drop redundant int(slot) casts where Slot arithmetic suffices. - Remove PeerManager.get_network_head_slot and its tests now that no caller remains. Tests - Replace patch.object(PeerManager, ...) mocks with real PeerManager and store manipulation, matching repo policy. - New helpers preserve the block-map key-equals-root invariant. - Add hysteresis flap test, split-counter test, transition-only log assertion. Use substring checks instead of exact log strings. Documentation - Constants, gate method, properties, fields, and inline comments rewritten per /doc rules: structured Why/Effect/Decision matrix labels, one idea per line, no function or variable names in prose, concrete numbers throughout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(validator): use first-person voice in threshold rationale Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c460769 commit c92e3bf

3 files changed

Lines changed: 510 additions & 11 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Validator duty-gate thresholds.
2+
3+
Informative, not normative:
4+
5+
- Shape when this node signs.
6+
- Do not change what consensus accepts.
7+
- Clients may diverge without breaking interop.
8+
"""
9+
10+
from typing import Final
11+
12+
SYNC_LAG_THRESHOLD: Final[int] = 4
13+
"""Slot lag past which the local view is too stale to sign.
14+
15+
Why:
16+
We justify and finalize within a handful of slots.
17+
A 4-slot lag is one full justification window behind real time.
18+
A vote from that view lands on a subtree the network has left.
19+
"""
20+
21+
NETWORK_STALL_THRESHOLD: Final[int] = 8
22+
"""Slot lag past which the whole network is treated as stalled.
23+
24+
Why:
25+
Set to twice the local threshold (8 = 2 * 4).
26+
Ordinary jitter at the local boundary must not trip this branch.
27+
28+
Effect:
29+
Even the freshest locally validated block is 8 slots behind.
30+
The cause is a streak of skipped proposals, not this node lagging.
31+
Duties stay live so the chain can advance through the gap.
32+
"""
33+
34+
HYSTERESIS_BAND: Final[int] = 2
35+
"""Slot band that holds the gate closed near the threshold.
36+
37+
Why:
38+
Without a band a single late gossip block flips the decision.
39+
Slot-over-slot flips would stutter the attestation stream.
40+
41+
Effect:
42+
Once closed, the gate reopens only when lag drops to 4 - 2 = 2.
43+
"""

src/lean_spec/subspecs/validator/service.py

Lines changed: 156 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
from lean_spec.subspecs.xmss.containers import Signature
5555
from lean_spec.types import Bytes32, Slot, Uint64, ValidatorIndex
5656

57+
from .constants import HYSTERESIS_BAND, NETWORK_STALL_THRESHOLD, SYNC_LAG_THRESHOLD
5758
from .registry import ValidatorEntry, ValidatorRegistry
5859

5960
logger = logging.getLogger(__name__)
@@ -111,6 +112,15 @@ class ValidatorService:
111112
_attested_slots: set[Slot] = field(default_factory=set, repr=False)
112113
"""Slots for which we've already produced attestations (prevents duplicates)."""
113114

115+
_blocks_skipped_lag: int = field(default=0, repr=False)
116+
"""Block proposals skipped because the local view was too stale."""
117+
118+
_attestations_skipped_lag: int = field(default=0, repr=False)
119+
"""Attestations skipped because the local view was too stale."""
120+
121+
_duty_gate_closed: bool = field(default=False, repr=False)
122+
"""Hysteresis flag. True while signing is silenced."""
123+
114124
async def run(self) -> None:
115125
"""
116126
Main loop - check duties every interval.
@@ -168,7 +178,10 @@ async def run(self) -> None:
168178
#
169179
# Check if any of our validators is the proposer.
170180
logger.debug("ValidatorService: checking block production for slot %d", slot)
171-
await self._maybe_produce_block(slot)
181+
if self._is_synced_for_duties(slot, "block"):
182+
await self._maybe_produce_block(slot)
183+
else:
184+
self._blocks_skipped_lag += 1
172185
logger.debug("ValidatorService: done block production check for slot %d", slot)
173186

174187
# Re-fetch interval after block production.
@@ -191,22 +204,41 @@ async def run(self) -> None:
191204
slot,
192205
slot in self._attested_slots,
193206
)
194-
if interval >= Uint64(1) and slot not in self._attested_slots:
207+
# Decide whether this iteration owes an attestation.
208+
#
209+
# Two conditions:
210+
#
211+
# - Interval has reached the attestation slot (>= 1).
212+
# - This slot has not already been attested.
213+
#
214+
# Why split eligibility from the sync gate: the skip counter
215+
# must only tick on real misses, never on wrong-interval
216+
# iterations.
217+
needs_attestation = interval >= Uint64(1) and slot not in self._attested_slots
218+
if needs_attestation:
195219
logger.debug(
196220
"ValidatorService: producing attestations for slot %d (interval %d)",
197221
slot,
198222
interval,
199223
)
200-
await self._produce_attestations(slot)
201-
logger.debug("ValidatorService: done producing attestations for slot %d", slot)
202-
self._attested_slots.add(slot)
203-
204-
# Prune old entries to prevent unbounded growth.
224+
# Apply the sync gate.
205225
#
206-
# Keep only recent slots (current slot - 4) to bound memory usage.
207-
# We never need to attest for slots that far in the past.
208-
prune_threshold = Slot(max(0, int(slot) - 4))
209-
self._attested_slots = {s for s in self._attested_slots if s >= prune_threshold}
226+
# Invariant: a gated slot stays out of the attested set.
227+
# If the node catches up before the slot ends, the next
228+
# iteration retries the duty.
229+
if self._is_synced_for_duties(slot, "attestation"):
230+
await self._produce_attestations(slot)
231+
logger.debug("ValidatorService: done producing attestations for slot %d", slot)
232+
self._attested_slots.add(slot)
233+
234+
# Prune old entries to bound memory.
235+
#
236+
# Keep only slots at or after (current slot - 4).
237+
# Older slots are no longer attestable.
238+
prune_threshold = Slot(max(0, int(slot) - 4))
239+
self._attested_slots = {s for s in self._attested_slots if s >= prune_threshold}
240+
else:
241+
self._attestations_skipped_lag += 1
210242

211243
# Intervals 2-4 have no additional validator duties.
212244

@@ -498,6 +530,104 @@ def _sign_with_key(
498530
self.registry.add(updated_entry)
499531
return updated_entry, signature
500532

533+
def _is_synced_for_duties(
534+
self,
535+
slot: Slot,
536+
duty: Literal["block", "attestation"],
537+
) -> bool:
538+
"""Decide whether duties may run for the given slot.
539+
540+
Combines local lag and local-store stall evidence with
541+
hysteresis. Returns False only when the local view is stale
542+
relative to a network that is otherwise making progress.
543+
544+
Args:
545+
slot: Wall-clock slot for which a duty would run.
546+
duty: Tag for the transition log.
547+
548+
Returns:
549+
True when duties should run, False to silence them.
550+
"""
551+
store = self.sync_service.store
552+
head_block = store.blocks.get(store.head)
553+
554+
# No head: nothing to compare against, let downstream code no-op.
555+
if head_block is None:
556+
return True
557+
558+
head_slot = head_block.slot
559+
560+
# Saturate at zero lag when the head is ahead of wall clock.
561+
#
562+
# Why:
563+
# Local clock drift is normal. Unconditional trust would let
564+
# a chain 100 slots in the future bypass every check.
565+
lag = 0 if head_slot >= slot else int(slot - head_slot)
566+
567+
# Local stall evidence from the block map.
568+
#
569+
# Why:
570+
# Only blocks with valid signatures enter the map, so the
571+
# freshest entry is an authenticated lower bound on the
572+
# network tip. A stale max here means the network is not
573+
# producing.
574+
max_seen_slot = max(
575+
(b.slot for b in store.blocks.values()),
576+
default=head_slot,
577+
)
578+
network_lag = 0 if max_seen_slot >= slot else int(slot - max_seen_slot)
579+
network_stalling = network_lag > NETWORK_STALL_THRESHOLD
580+
581+
# Decision matrix:
582+
#
583+
# - Network stalling: keep signing, reopen if currently closed.
584+
# - Gate closed: reopen only when lag drops to 4 - 2 = 2.
585+
# - Gate open: close as soon as lag crosses 4.
586+
if network_stalling:
587+
allow = True
588+
if self._duty_gate_closed:
589+
self._duty_gate_closed = False
590+
logger.info(
591+
"Validator duty gate reopened: network stall detected. "
592+
"duty=%s slot=%d head_slot=%d lag=%d max_seen_slot=%d network_lag=%d",
593+
duty,
594+
int(slot),
595+
int(head_slot),
596+
lag,
597+
int(max_seen_slot),
598+
network_lag,
599+
)
600+
elif self._duty_gate_closed:
601+
# Hysteresis: reopen only well below the threshold.
602+
allow = lag <= SYNC_LAG_THRESHOLD - HYSTERESIS_BAND
603+
if allow:
604+
self._duty_gate_closed = False
605+
logger.info(
606+
"Validator duty gate reopened: local view caught up. "
607+
"duty=%s slot=%d head_slot=%d lag=%d",
608+
duty,
609+
int(slot),
610+
int(head_slot),
611+
lag,
612+
)
613+
else:
614+
# Open gate: close once the local threshold is crossed.
615+
allow = lag <= SYNC_LAG_THRESHOLD
616+
if not allow:
617+
self._duty_gate_closed = True
618+
logger.info(
619+
"Validator duty gate closed: local view is stale. "
620+
"duty=%s slot=%d head_slot=%d lag=%d max_seen_slot=%d network_lag=%d",
621+
duty,
622+
int(slot),
623+
int(head_slot),
624+
lag,
625+
int(max_seen_slot),
626+
network_lag,
627+
)
628+
629+
return allow
630+
501631
def stop(self) -> None:
502632
"""
503633
Stop the service.
@@ -521,3 +651,18 @@ def blocks_produced(self) -> int:
521651
def attestations_produced(self) -> int:
522652
"""Total attestations produced since creation."""
523653
return self._attestations_produced
654+
655+
@property
656+
def blocks_skipped_lag(self) -> int:
657+
"""Block proposals skipped because the local view was too stale."""
658+
return self._blocks_skipped_lag
659+
660+
@property
661+
def attestations_skipped_lag(self) -> int:
662+
"""Attestations skipped because the local view was too stale."""
663+
return self._attestations_skipped_lag
664+
665+
@property
666+
def duty_gate_closed(self) -> bool:
667+
"""True while the sync-lag gate is silencing duties."""
668+
return self._duty_gate_closed

0 commit comments

Comments
 (0)