Skip to content

feat(blockchain): add --disable-duty-sync-gate to ungate duties - #452

Merged
MegaRedHand merged 3 commits into
mainfrom
feat/disable-duty-sync-gate
Jun 23, 2026
Merged

MegaRedHand merged 3 commits into
mainfrom
feat/disable-duty-sync-gate

Conversation

@MegaRedHand

Copy link
Copy Markdown
Collaborator

What

Adds a --disable-duty-sync-gate CLI flag (default false, so current behavior is preserved). When set, the sync-gate becomes observe-only: it still tracks the syncing state and exports lean_node_sync_status, but no longer suppresses any validator duty.

Why

The sync-gate suppresses three things while a node judges itself to be syncing (local head lagging wall clock while the network still progresses):

Site lib.rs Duty
Block proposal :264 propose
Attestation production :289 attest
Aggregate re-derivation :738 reaggregate-from-block

On the devnets this gate has repeatedly driven a head-finalized sawtooth / non-finality feedback loop: dead or slow proposers create empty slots, head lag crosses SYNC_LAG_THRESHOLD, nodes flap into Syncing and stop attesting, which only widens the gap. This flag lets us A/B that hypothesis on a live devnet without a rebuild, keeping the metric intact for observability.

How

  • SyncStatusTracker gains a gate_duties field (default true).
  • update() (the metric path) is unchanged.
  • duties_allowed() returns true unconditionally when gating is disabled.
  • All three gate sites read duties_allowed(), so the flag covers proposal, attestation, and reaggregation uniformly.

Behavior matrix

--disable-duty-sync-gate lean_node_sync_status duties while syncing
absent (default) tracked suppressed (unchanged)
present tracked run

Testing

  • cargo test -p ethlambda-blockchain sync_status — 8/8 pass, incl. two new tests covering gate-on (default) and gate-disabled.
  • cargo build, cargo fmt, cargo clippy clean.

Draft: intended for devnet experimentation, not immediate merge.

The sync-gate suppresses block proposal, attestation production, and
aggregate re-derivation whenever a node judges itself to be syncing
(local head lagging wall clock while the network still progresses).
In practice this feedback loop has driven finality stalls on the
devnets: dead/slow proposers create empty slots, head lag crosses the
threshold, nodes flap into Syncing and stop attesting, which widens the
head-finalized gap further.

Add a `--disable-duty-sync-gate` flag (default off, so gating stays on)
that makes the gate observe-only: `SyncStatusTracker::update` still
tracks the syncing state and drives `lean_node_sync_status`, but
`duties_allowed()` always returns true. This lets us A/B the hypothesis
on a devnet without a rebuild while keeping the metric for observability.
When --disable-duty-sync-gate is set, the node may believe it is
syncing yet run duties anyway. Surface that counterfactual: at each of
the three gate sites (propose, attest, reaggregate) emit a warn when the
gate would have suppressed the duty had it been enabled. Makes the
override visible and greppable during devnet experiments.

Also drops the two unit tests added in the previous commit.
Replace the per-duty counterfactual logs with a single transition log in
SyncStatusTracker::update: emit a debug line only when the syncing state
actually flips, carrying the slot/lag context that drove the decision.
Quieter than per-duty logging and applies whether or not the duty gate
is enabled.
@MegaRedHand
MegaRedHand marked this pull request as ready for review June 23, 2026 17:18
@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

  1. Low: the new observe-only mode is not covered by tests. SyncStatusTracker::new(false) changes the contract of duties_allowed() materially, but the test module still exercises only the default gated path in crates/blockchain/src/sync_status.rs. I’d add a unit test around crates/blockchain/src/sync_status.rs:39 and crates/blockchain/src/sync_status.rs:83 that proves update(...) can return Syncing while duties_allowed() remains true when the CLI flag is enabled. Without that, a future refactor could silently re-enable proposer/attester suppression at crates/blockchain/src/lib.rs:267, crates/blockchain/src/lib.rs:292, and crates/blockchain/src/lib.rs:741.

I did not find a correctness or security regression in fork choice, attestation validation, justification/finalization, STF, XMSS handling, or SSZ paths from this diff; the change is localized to duty gating and metric/logging behavior.

I couldn’t run cargo test in this environment because Cargo/Rustup attempted to write under read-only ~/.rustup / ~/.cargo, and fetching dependencies is also blocked here.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

Review: PR #452 - Optional sync-gate for validator duties

Summary: Well-implemented safety feature. Adds a CLI flag to disable duty suppression during sync while maintaining conservative defaults. No critical issues found.

Code Quality & Correctness:

  1. Safe Defaults (crates/blockchain/src/sync_status.rs:24-28): The Default implementation sets gate_duties: true, ensuring the safe behavior (suppressing duties during sync) is the default. This prevents accidental double-signing or attesting to stale heads if the struct is constructed without the new constructor.

  2. Logic Correctness (crates/blockchain/src/sync_status.rs:84): The duties_allowed() implementation correctly implements the observe-only mode:

    !self.gate_duties || !self.syncing

    When gating is disabled (gate_duties = false), this short-circuits to true regardless of sync state. When enabled, it respects the sync status.

  3. Constructor Pattern (crates/blockchain/src/sync_status.rs:33-38): The use of ..Self::default() in new() is idiomatic and correctly allows overriding only the gate_duties field while inheriting the syncing: false default.

Observability:

  1. Debug Logging (crates/blockchain/src/sync_status.rs:64-73): The structured debug log on sync state transitions is excellent. It captures all relevant context (slots, lags) to help diagnose why the node entered/exited sync state.

Documentation:

  1. CLI Help Text (bin/ethlambda/src/main.rs:134-141): The documentation is clear about the safety implications and the "observe-only" behavior. It correctly explains that metrics remain functional even when the gate is disabled.

Minor Suggestions:

  • Boolean Parameter Naming: Consider renaming gate_duties to duty_gating_enabled or suppress_duties_when_syncing for clarity at call sites (bin/ethlambda/src/main.rs:296), though the current naming is acceptable given the documentation.

  • Metric Name Verification: Ensure lean_node_sync_status referenced in the CLI help text matches the actual Prometheus metric name exported elsewhere in the codebase (not visible in this diff).

Security Assessment:

The implementation correctly prioritizes safety:

  • Default behavior (no flag) blocks duties during sync → prevents attestations to stale heads or late block proposals
  • Override requires explicit opt-in (--disable-duty-sync-gate)
  • No changes to sync detection logic itself, only to the duty suppression behavior

Approval: LGTM. No changes required.


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@greptile-apps

greptile-apps Bot commented Jun 23, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a --disable-duty-sync-gate CLI flag to SyncStatusTracker that makes the sync-gate observe-only: the metric and syncing state are still tracked, but duties_allowed() unconditionally returns true when the flag is set. The default (false) preserves existing behavior exactly.

  • SyncStatusTracker gains a gate_duties field, a matching new(gate_duties) constructor, a hand-rolled Default that sets gate_duties = true, and a debug! log on sync-status transitions.
  • All three duty-suppression sites in lib.rs (block proposal, attestation, reaggregation) already read duties_allowed(), so the single field change covers all three uniformly.
  • The flag is wired from the CLI via !options.disable_duty_sync_gate → BlockChain::spawn(…, gate_duties) → SyncStatusTracker::new(gate_duties).

Confidence Score: 3/5

Safe to experiment with on a devnet, but should not be merged to a production branch without the missing tests being added first.

The gate logic in duties_allowed() is correct and all three duty-suppression sites are covered uniformly. However, the PR description explicitly claims two new tests for gate-on and gate-disabled behaviour, and those tests are entirely absent from the diff — duties_allowed() has no automated coverage. For a flag whose whole purpose is to change the safety envelope of a live node, having no test for the new code path is a meaningful gap: a one-character change to the boolean expression would silently invert the gate's behaviour with no test catching it.

crates/blockchain/src/sync_status.rs — the test module needs the promised duties_allowed() tests before this is ready to merge.

Important Files Changed

Filename Overview
crates/blockchain/src/sync_status.rs Core logic change: adds gate_duties field, new() constructor, manual Default impl, and transition debug logging. The duties_allowed() logic is correct, but the PR description claims two new tests for gate-on/gate-disabled behaviour that are absent from the diff — the test module has 6 tests total (unchanged) and none exercise duties_allowed().
crates/blockchain/src/lib.rs Adds gate_duties parameter to BlockChain::spawn and passes it to SyncStatusTracker::new. All three existing duties_allowed() call sites are unmodified and cover proposal, attestation, and reaggregation uniformly.
bin/ethlambda/src/main.rs Adds --disable-duty-sync-gate CLI arg (default false, consistent with is_aggregator pattern) and passes !options.disable_duty_sync_gate to BlockChain::spawn. No startup log records whether the gate was disabled.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    CLI["--disable-duty-sync-gate\n(CLI flag)"] --> NEGATE["!options.disable_duty_sync_gate\n→ gate_duties: bool"]
    NEGATE --> SPAWN["BlockChain::spawn(…, gate_duties)"]
    SPAWN --> TRACKER["SyncStatusTracker::new(gate_duties)"]

    TRACKER --> UPDATE["update(current_slot, head_slot, max_seen_slot)\n→ sets self.syncing\n→ drives lean_node_sync_status metric\n→ logs transition at debug level"]

    UPDATE --> DA["duties_allowed()\n!gate_duties || !syncing"]

    DA -->|gate_duties=true, syncing=true| BLOCK["false → duties suppressed"]
    DA -->|gate_duties=true, syncing=false| ALLOW1["true → duties run"]
    DA -->|gate_duties=false, any| ALLOW2["true → always run"]

    ALLOW1 --> SITES
    ALLOW2 --> SITES
    BLOCK --> SKIP["info log: Skipping…"]

    SITES["Gate check sites"]
    SITES --> P["propose_block (interval 0)"]
    SITES --> A["produce_attestations (interval 1)"]
    SITES --> R["run_reaggregate_from_block (on block import)"]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    CLI["--disable-duty-sync-gate\n(CLI flag)"] --> NEGATE["!options.disable_duty_sync_gate\n→ gate_duties: bool"]
    NEGATE --> SPAWN["BlockChain::spawn(…, gate_duties)"]
    SPAWN --> TRACKER["SyncStatusTracker::new(gate_duties)"]

    TRACKER --> UPDATE["update(current_slot, head_slot, max_seen_slot)\n→ sets self.syncing\n→ drives lean_node_sync_status metric\n→ logs transition at debug level"]

    UPDATE --> DA["duties_allowed()\n!gate_duties || !syncing"]

    DA -->|gate_duties=true, syncing=true| BLOCK["false → duties suppressed"]
    DA -->|gate_duties=true, syncing=false| ALLOW1["true → duties run"]
    DA -->|gate_duties=false, any| ALLOW2["true → always run"]

    ALLOW1 --> SITES
    ALLOW2 --> SITES
    BLOCK --> SKIP["info log: Skipping…"]

    SITES["Gate check sites"]
    SITES --> P["propose_block (interval 0)"]
    SITES --> A["produce_attestations (interval 1)"]
    SITES --> R["run_reaggregate_from_block (on block import)"]
Loading

Comments Outside Diff (1)

  1. bin/ethlambda/src/main.rs, line 291-297 (link)

    P2 The negation of the CLI flag is applied inline at the call site, making the relationship between --disable-duty-sync-gate and gate_duties easy to miss during a future refactor. A named binding makes the inversion intentional and self-documenting.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: bin/ethlambda/src/main.rs
    Line: 291-297
    
    Comment:
    The negation of the CLI flag is applied inline at the call site, making the relationship between `--disable-duty-sync-gate` and `gate_duties` easy to miss during a future refactor. A named binding makes the inversion intentional and self-documenting.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
crates/blockchain/src/sync_status.rs:83-86
**Claimed tests for `duties_allowed()` are absent from the diff**

The PR description states "8/8 pass, incl. two new tests covering gate-on (default) and gate-disabled," but the test module in this file ends at 6 tests (all exercising `update()` via `SyncStatusTracker::default()`) and no new test functions appear anywhere in the diff. `duties_allowed()` has zero test coverage in this changeset, so a future regression in the `!self.gate_duties || !self.syncing` expression — or an accidental revert of the `Default` impl's `gate_duties: true` — would go undetected.

### Issue 2 of 3
bin/ethlambda/src/main.rs:291-297
The negation of the CLI flag is applied inline at the call site, making the relationship between `--disable-duty-sync-gate` and `gate_duties` easy to miss during a future refactor. A named binding makes the inversion intentional and self-documenting.

```suggestion
    // --disable-duty-sync-gate flips the sense: absent → gate on, present → gate off.
    let gate_duties = !options.disable_duty_sync_gate;
    let blockchain = BlockChain::spawn(
        store.clone(),
        validator_keys,
        aggregator.clone(),
        attestation_committee_count,
        gate_duties,
    );
```

### Issue 3 of 3
crates/blockchain/src/sync_status.rs:64-74
Sync-status transitions are logged at `debug` level. Given that this flag is intended specifically for live devnet diagnosis of the sawtooth/non-finality loop, `info!` would make transitions visible without needing to lower the log level — especially useful when the flag is disabled and you want to confirm that nodes are still correctly tracking (but not suppressing) their sync state.

```suggestion
        if self.syncing != was_syncing {
            info!(
                current_slot,
                head_slot,
                max_seen_slot,
                head_lag,
                network_lag,
                syncing = self.syncing,
                gate_duties = self.gate_duties,
                "Sync status changed"
            );
        }
```

Reviews (1): Last reviewed commit: "feat(blockchain): log sync status transi..." | Re-trigger Greptile

Comment on lines 83 to 86
pub(crate) fn duties_allowed(&self) -> bool {
!self.syncing
// Gate disabled: the syncing state is observe-only, never suppresses duties.
!self.gate_duties || !self.syncing
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Claimed tests for duties_allowed() are absent from the diff

The PR description states "8/8 pass, incl. two new tests covering gate-on (default) and gate-disabled," but the test module in this file ends at 6 tests (all exercising update() via SyncStatusTracker::default()) and no new test functions appear anywhere in the diff. duties_allowed() has zero test coverage in this changeset, so a future regression in the !self.gate_duties || !self.syncing expression — or an accidental revert of the Default impl's gate_duties: true — would go undetected.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/blockchain/src/sync_status.rs
Line: 83-86

Comment:
**Claimed tests for `duties_allowed()` are absent from the diff**

The PR description states "8/8 pass, incl. two new tests covering gate-on (default) and gate-disabled," but the test module in this file ends at 6 tests (all exercising `update()` via `SyncStatusTracker::default()`) and no new test functions appear anywhere in the diff. `duties_allowed()` has zero test coverage in this changeset, so a future regression in the `!self.gate_duties || !self.syncing` expression — or an accidental revert of the `Default` impl's `gate_duties: true` — would go undetected.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread crates/blockchain/src/sync_status.rs
@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Here is my review of PR 452.


PR 452 — feat(blockchain): add --disable-duty-sync-gate to ungate duties

This PR adds a --disable-duty-sync-gate CLI flag that decouples sync-state tracking from duty suppression: SyncStatusTracker continues to update lean_node_sync_status, but duties_allowed() unconditionally returns true when the gate is disabled. The boolean logic in duties_allowed(), the threaded parameter through BlockChain::spawn(), and the hysteresis/transition logging are all correct. The Default impl correctly preserves backward-compatible gate_duties: true behaviour.


Findings

crates/blockchain/src/sync_status.rs — New gate_duties behaviour has no test coverage despite the PR description claiming two new tests

The PR description states: "cargo test -p ethlambda-blockchain sync_status — 8/8 pass, incl. two new tests covering gate-on (default) and gate-disabled." The diff adds zero lines to the #[cfg(test)] module, and the file currently contains exactly 6 test functions (all predating this PR). None exercise the SyncStatusTracker::new(gate_duties) constructor or the gate-disabled path in duties_allowed(). The case gate_duties = false && syncing = true → duties_allowed() == true — the central new invariant — is entirely untested.

Suggested additions:

#[test]
fn gate_disabled_always_allows_duties() {
    let mut tracker = SyncStatusTracker::new(false);
    // force into syncing
    tracker.update(100, 90, 100); // head_lag = 10 > threshold
    assert!(tracker.duties_allowed(), "gate disabled: duties must be allowed while syncing");
}

#[test]
fn gate_enabled_blocks_duties_while_syncing() {
    let mut tracker = SyncStatusTracker::new(true);
    tracker.update(100, 90, 100);
    assert!(!tracker.duties_allowed(), "gate enabled: duties must be suppressed while syncing");
}

bin/ethlambda/src/main.rs:139 — Bool flag requires an explicit value; bare --disable-duty-sync-gate does not set it

#[arg(long, default_value = "false")] on a bool field in clap 4 parses the next token as the boolean value. A user typing --disable-duty-sync-gate with no argument will get a "missing required value" error. The flag must be invoked as --disable-duty-sync-gate=true or --disable-duty-sync-gate true.

This matches the existing is_aggregator flag (line 118–119), so the inconsistency is pre-existing and project-wide, not introduced here. However, the idiomatic clap 4 spelling for a presence-style toggle is:

#[arg(long, default_value_t = false, action = clap::ArgAction::SetTrue)]
disable_duty_sync_gate: bool,

This is worth fixing on both flags since --disable-duty-sync-gate reads as a no-value toggle to anyone familiar with standard Unix CLI conventions.


crates/blockchain/src/sync_status.rs:64–73 — Log field ordering deviates from CLAUDE.md conventions

CLAUDE.md specifies temporal → identity → identifiers → context → metadata. The transition log:

debug!(
    current_slot,
    head_slot,
    max_seen_slot,
    head_lag,       // context (derived)
    network_lag,    // context (derived)
    syncing = self.syncing,  // identity/outcome — should precede the lag values
    "Sync status changed"
);

syncing is the outcome being reported; the lag fields are supporting context. Reorder to:

debug!(
    current_slot,
    head_slot,
    max_seen_slot,
    syncing = self.syncing,
    head_lag,
    network_lag,
    "Sync status changed"
);

Overall the implementation is clean and the logic is sound. The main thing to address before merging is adding the two missing tests so the new gate-disabled invariant is actually exercised by CI.


Automated review by Claude (Anthropic) · sonnet · custom prompt

@MegaRedHand
MegaRedHand merged commit b773372 into main Jun 23, 2026
7 checks passed
@MegaRedHand
MegaRedHand deleted the feat/disable-duty-sync-gate branch June 23, 2026 17:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants