Skip to content

fix(message_validator): derive per-epoch duty counts from the slot ring (SIP-94 §7 retention) #1271

Description

@shane-moore

Goal

Make Role::ProposerPreferences duty-count retention comply with SIP-94 §7 by deriving per-epoch duty counts from the existing slot ring and deleting the two-bucket epoch counters (max_epoch, curr_epoch_duties, prev_epoch_duties) from OperatorState.

Context / motivation

SIP-94 §7 now requires validation state (recorded signing roots, duty counts) to be retained for a message's full validity window (added in d03a2ed, from this review comment). For role 8 the window is (1 + MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH + 2 slots, and acceptable message slots span up to four consecutive epochs at any instant: from wall-clock slot T the acceptable range is [T - 2, T + 64], whose far edge is always in epoch(T) + 2 and whose near edge reaches epoch(T) - 1 during an epoch's first two slots.

Anchor already complies on the recorded-roots half: the role-scoped ring is 128 slots for role 8 (stored_slot_count, anchor/message_validator/src/lib.rs:750-759 at 8a508ae8), pinned by test_proposer_preferences_ring_avoids_lookahead_collision (partial_signature.rs:4362-4484).

The duty-count half does not comply. OperatorState keeps only curr_epoch_duties / prev_epoch_duties keyed off a rolling max_epoch (anchor/message_validator/src/duty_state.rs:194-198), get_duty_count returns 0 for anything older than max_epoch - 1 (duty_state.rs:218-224), and the writer relabels buckets on epoch advance (duty_state.rs:306-321). With four concurrent role-8 epochs this mis-tracks two ways:

  • An accepted message for epoch E+2 sets max_epoch = E+2 and relabels epoch E's count as E+1's; a later still-valid message for E then reads count 0.
  • The Ordering::Less arm conflates all older epochs into prev_epoch_duties, so E and E+1 share one bucket.

Severity is low, which is why this was deferred: the role-8 limit is SLOTS_PER_EPOCH and an epoch has only SLOTS_PER_EPOCH slots behind first-message-per-slot gating (validate_duty_count, lib.rs:1112-1147), so the fail-open direction admits nothing a correct counter would not. The fail-closed direction (conflated bucket reaching the limit and IGNORE'ing honest preferences) needs one validator holding 32+ proposal slots across two epochs: unreachable on mainnet or hoodi, but reachable on small devnets where one validator proposes a large share of slots. There the dropped preference means no builder bids for the slot and a fall-through to self-build, which can be misread as a protocol bug during testing.

Suggested approach

Derive counts from ring occupancy instead of widening the buckets. The ring already records exactly one occupied entry per counted duty slot (is_first_message_for_duty gating), and an entry cannot be evicted while its epoch is still inside the acceptance window: overwrites require a write at slot ± ring_len, and for both ring sizes (64 default, 128 for role 8) those aliases sit outside the live earliness + lateness union. So:

  • Rewrite get_duty_count(epoch) to count occupied ring entries whose slot.epoch(slots_per_epoch) == epoch. Thread slots_per_epoch in from the caller; validate_duty_count already has it via ValidationContext (lib.rs:1126 is the only call site).
  • Delete max_epoch, curr_epoch_duties, prev_epoch_duties, and the whole match in set_signer_state_for_first_round (duty_state.rs:306-321); keep the max_slot update. Drop the now-unused estimated_msg_epoch parameter and update its callers.
  • The counters have one reader and one writer, so this is the entire surface: get_duty_count is read only by validate_duty_count, whose two call sites are partial_signature.rs:236 and consensus_message.rs:469.

Non-impacts to verify rather than assume: the VoluntaryExit limit uses an external duty-provider count, not these counters (lib.rs:1170); QBFT round-change writes (set_signer_state_for_round_change) do not change slot occupancy, so QBFT-role counts are unaffected; the scan is at most ring_len Option checks once per first message, only for roles with Some duty limit.

Acceptance criteria

  • get_duty_count returns exact per-epoch counts for every epoch with slots inside the role's acceptance window, including all four concurrent role-8 epochs.
  • Accepting a role-8 message for epoch E+2 does not change the count reported for epochs E or E+1.
  • OperatorState no longer carries epoch-counter fields; counts have a single source of truth (the ring).
  • Duty-limit behavior for every other role is unchanged.
  • No change to verdict classifications, the 4-root dedup rule, or ring sizing.

Tests

  • New: an E+2-then-E scenario asserting counts stay per-epoch (the relabeling case above), and a conflation regression for E and E+1 counted separately. Model the setup on test_proposer_preferences_ring_avoids_lookahead_collision.
  • Existing duty-count and ring tests must pass unmodified: derived counts equal the old counters in every two-epoch scenario.
  • Run cargo test -p message_validator, make cargo-fmt-check, and make lint.

Notes

Issues are directionally correct, not prescriptive; verify symbols at PR time.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions