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.
Goal
Make
Role::ProposerPreferencesduty-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) fromOperatorState.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 + 2slots, and acceptable message slots span up to four consecutive epochs at any instant: from wall-clock slotTthe acceptable range is[T - 2, T + 64], whose far edge is always inepoch(T) + 2and whose near edge reachesepoch(T) - 1during 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-759at8a508ae8), pinned bytest_proposer_preferences_ring_avoids_lookahead_collision(partial_signature.rs:4362-4484).The duty-count half does not comply.
OperatorStatekeeps onlycurr_epoch_duties/prev_epoch_dutieskeyed off a rollingmax_epoch(anchor/message_validator/src/duty_state.rs:194-198),get_duty_countreturns 0 for anything older thanmax_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:E+2setsmax_epoch = E+2and relabels epochE's count asE+1's; a later still-valid message forEthen reads count 0.Ordering::Lessarm conflates all older epochs intoprev_epoch_duties, soEandE+1share one bucket.Severity is low, which is why this was deferred: the role-8 limit is
SLOTS_PER_EPOCHand an epoch has onlySLOTS_PER_EPOCHslots 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_dutygating), and an entry cannot be evicted while its epoch is still inside the acceptance window: overwrites require a write atslot ± ring_len, and for both ring sizes (64 default, 128 for role 8) those aliases sit outside the live earliness + lateness union. So:get_duty_count(epoch)to count occupied ring entries whoseslot.epoch(slots_per_epoch) == epoch. Threadslots_per_epochin from the caller;validate_duty_countalready has it viaValidationContext(lib.rs:1126is the only call site).max_epoch,curr_epoch_duties,prev_epoch_duties, and the wholematchinset_signer_state_for_first_round(duty_state.rs:306-321); keep themax_slotupdate. Drop the now-unusedestimated_msg_epochparameter and update its callers.get_duty_countis read only byvalidate_duty_count, whose two call sites arepartial_signature.rs:236andconsensus_message.rs:469.Non-impacts to verify rather than assume: the
VoluntaryExitlimit 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 mostring_lenOptionchecks once per first message, only for roles withSomeduty limit.Acceptance criteria
get_duty_countreturns exact per-epoch counts for every epoch with slots inside the role's acceptance window, including all four concurrent role-8 epochs.E+2does not change the count reported for epochsEorE+1.OperatorStateno longer carries epoch-counter fields; counts have a single source of truth (the ring).Tests
EandE+1counted separately. Model the setup ontest_proposer_preferences_ring_avoids_lookahead_collision.cargo test -p message_validator,make cargo-fmt-check, andmake lint.Notes
consensus_state.go) plus a 34-slot ring that also fails the roots half; that is upstream's problem, out of scope here.8a508ae8.Issues are directionally correct, not prescriptive; verify symbols at PR time.