Skip to content

Boole → stage convergence — final consolidation - #2941

Open
momosh-ssv wants to merge 195 commits into
stagefrom
integration/boole-convergence
Open

Boole → stage convergence — final consolidation#2941
momosh-ssv wants to merge 195 commits into
stagefrom
integration/boole-convergence

Conversation

@momosh-ssv

Copy link
Copy Markdown
Contributor

Boole → stage convergence — final consolidation

Merges the entire boole-fork protocol-fork layer into stage, assembled unit-by-unit on integration/boole-convergence. This is the consolidation merge — every unit below was already implemented, deep-reviewed, and merged into integration as its own PR; this PR lands them in stage as one coherent set.

What's included

Status / before merge

Follow-up (tracked, not in this PR)

  • Regenerate the one skipped Altair spec vector (sync committee aggregator selection proof) against a reconciled fork schedule.

momosh-ssv added 30 commits July 3, 2026 17:15
…T-LOAD-0)

Step 0 of the boole-fork -> stage convergence (base: integration/boole-convergence).

ssv-spec v1.2.3 removes RoleAggregator/RoleSyncCommitteeContribution (their wire
values 1/3 are left vacant; new RoleAggregatorCommittee=6) and renames
ValidatorConsensusData -> ProposerConsensusData (same SSZ shape). Stage referenced
the removed symbols across ~25 production files, so the pin bump alone cannot build.

Port boole's compat shim rather than pulling the runner merge forward:
- protocol/v2/types/{runner_role,consensus_data,partial_sig_message}.go: legacy
  roles pinned at their v1.2.2 wire values (RoleAggregator=1,
  RoleSyncCommitteeContribution=3) + fork-aware mapping helpers (boole-faithful).
- Every duty->runner-role call site uses RunnerRoleForValidatorDuty(duty, false)
  (literal false = legacy mapping; TODO(convergence unit 5) threads the real fork
  bit), so behavior is IDENTICAL to stage today: two separate legacy runners, same
  wire bytes.
- committee.go validateMessage: add the v1.2.3-required wrong-role rejection
  (spectypes.CommitteeWrongRoleErrorCode) -- the one intended behavior change.
- spectest: skip the 528 AggregatorCommittee-scoped vectors (525 msg-processing/
  valcheck/construction + 3 SyncCommitteeAggregatorProof) that require the
  AggregatorCommitteeRunner (unit 5). The 3 SyncCommitteeAggregatorProof skips are
  a temporary coverage regression (green on v1.2.2) -- TODO-tagged. Also handles
  the v1.2.3 Committee.Runners -> CommitteeRunners fixture rename (legacy fallback).

Wire-compat verified: no surviving role renumbered, 1/3 not recycled,
ProposerConsensusData SSZ byte-identical to v1.2.2 ValidatorConsensusData.
Deep-reviewed (behavior identity, wire compat, skip scoping): APPROVE.
go build/vet clean; qbft + ssv spec-tests green; golangci-lint 0 issues.
Unit 1 of the boole-fork -> stage convergence (stacks on FRONT-LOAD-0 / #2924).

Adds boole's fork layer to networkconfig, boole-faithful (the worktree-vs-boole
diff on networkconfig/ reduces to exactly the intentional naming delta):
- ssv.go: NextDomainType + SSVForks{Boole phase0.Epoch} (was empty) + marshal/
  unmarshal handling (absent next_domain_type defaults to DomainType; 4-byte check).
- network.go: DomainTypeAtSlot, CurrentDomainType, BooleFork, BooleForkAtEpoch,
  BooleForkAtSlot, InBooleTransitionWindow + private window helpers.
- Per-network values: prod nets (mainnet/holesky/holesky-stage/hoodi/hoodi-stage/
  sepolia) pin Boole=MaxUint64 (inert); testnet/local-testnet use 0. NextDomainType
  = DomainType+1 everywhere.
- test-network.go: TestNetwork Boole default + SSV_TEST_BOOLE_FORK pre/post env
  flip; test-network_alan.go (alan_spec builds pin MaxUint64).
- Ports boole's fork tests (TestDomainTypeAtSlot, transition-window tests, marshal
  tests).

Naming rule (converge toward stage): beacon.go keeps stage's ForkAtEpoch; boole's
BeaconForkAtEpoch rename is intentionally NOT ported.

The API is deliberately DORMANT on this branch (verified: zero consumers outside
networkconfig/) until units 2 (networking) and 5 (runner) wire it.

Deep-reviewed APPROVE: upgrade contract safe (ConfigLock compares only
NetworkName/UsingLocalEvents/UsingSSVSigner; StorageName unchanged -> no
boot-mismatch on upgrade). Carry-forward before wiring BooleFork (unit 2/5):
a custom-network YAML omitting `forks` deserializes to Boole=0 (fork-at-genesis)
-- resolve (require explicit forks, or default absent to MaxUint64) before the
first unit that consumes BooleFork.

go build clean; networkconfig tests green; golangci-lint 0 issues.
…gateAndProof

v1.2.3 changed testingutils.TestingPhase0AggregateAndProof from a value to a
func(aggregatorIndex phase0.ValidatorIndex) *phase0.AggregateAndProof; invoke it so
SignBeaconObject receives an ssz.HashRoot. ssvsigner is a separate Go module, so
`go build ./...` from the main module never compiled it — which is why this slipped
past local validation and only CI (lint/ssv/ssvsigner) caught it.
…gateAndProof

v1.2.3 changed testingutils.TestingPhase0AggregateAndProof from a value to a
func(aggregatorIndex phase0.ValidatorIndex) *phase0.AggregateAndProof; invoke it so
SignBeaconObject receives an ssz.HashRoot. ssvsigner is a separate Go module, so
`go build ./...` from the main module never compiled it — which is why this slipped
past local validation and only CI (lint/ssv/ssvsigner) caught it.
…tisfy deadcode

CommitteeRunnerRoleForBeaconRole, CommitteeSignerBucketForBeaconRole (+ its
CommitteeSignerBucket type/consts) and the duplicate types.RunnerRoleToString
were ported ahead of their consumers. deadcode analyses reachability from
./cmd/ssvnode and flagged all three as unreachable. The used RunnerRoleToString
lives in protocol/v2/message/msg.go; the committee-bucket helpers land with the
aggregator-committee trace wiring in a later unit. Removing them here keeps
front-load-0 to spec-bump + reachable compat shim only.
…tisfy deadcode

CommitteeRunnerRoleForBeaconRole, CommitteeSignerBucketForBeaconRole (+ its
CommitteeSignerBucket type/consts) and the duplicate types.RunnerRoleToString
were ported ahead of their consumers. deadcode analyses reachability from
./cmd/ssvnode and flagged all three as unreachable. The used RunnerRoleToString
lives in protocol/v2/message/msg.go; the committee-bucket helpers land with the
aggregator-committee trace wiring in a later unit. Removing them here keeps
front-load-0 to spec-bump + reachable compat shim only.
…max (5048)

v1.2.3 raised the PartialSignatureMessages.Messages SSZ list bound from 1512 to
5048, so the stale test's 1513-element message now encodes fine and the expected
encode error never fired. Append past 5048 and assert the new bound.
…max (5048)

v1.2.3 raised the PartialSignatureMessages.Messages SSZ list bound from 1512 to
5048, so the stale test's 1513-element message now encodes fine and the expected
encode error never fired. Append past 5048 and assert the new bound.
…-pin

convergence(step-0): ssv-spec v1.2.3 + boole compat shim
…-fork-api

convergence(unit 1): networkconfig fork API
Consolidated into main session. Broad draft (network/ + qbft/runner spill);
preserved wholesale before rebasing onto integration/boole-convergence.
…on done

Port boole-fork's slot-gated receive-side checks, stage-idiomatically (uint64 subnets,
not boole's Subnet type):
- validateTopicAtSlot: expected topic = Alan or Boole per BooleForkAtSlot(slot); exact
  match, byte-mirrors p2pNetwork.BroadcastAtSlot (committee + validator roles).
- validateDomainAtSlot: exact match vs netCfg.DomainTypeAtSlot(slot).
- Called after decode (slot known) from consensus (Height) and partial (Slot) validators.
- Pre-decode: committeeChecks topic block removed; signed_ssv_message domain check relaxed
  to a {DomainType, NextDomainType} allowlist (exact per-slot enforced later).

Production compiles/vets/deadcode clean. TEST MIGRATION PENDING: message/validation
fixtures still use base-name topics + static Alan domain; TestNetwork defaults Boole=0
(post-fork, matches boole-fork), so ~90 subtests need fork-aware fixtures mirroring
boole-fork's validation_test.go (full topics + fork-aware domains).
…fork coverage

Mirror boole-fork's test structure:
- Fixtures passed topic BASE names ('114'); fork-aware validation (like production
  pMsg.GetTopic()) needs FULL names ('ssv.v2.114'). Wrap CommitteeTopicID(...)[0] in
  GetTopicFullName at all committee-topic sites (the old lenient committeeChecks hid this).
- Default TestNetwork to pre-Boole-fork (Boole=MaxUint64) so Alan-oriented fixtures stay
  valid; post-fork is opt-in via SSV_TEST_BOOLE_FORK=post or explicit per-test configs.
- validateTopicAtSlot Alan branch reuses commons.CommitteeTopicID (== SubnetTopicID(
  AlanCommitteeSubnet), keeps it reachable for deadcode, byte-matches publish side).
- Add explicit post-fork Boole receive tests: committee consensus accepted on the Boole
  topic, and rejected on an Alan topic / with an Alan domain at a post-fork slot.

Default suite green (pre-fork); build/vet(main+ssvsigner)/deadcode all clean.
…mmetry

Deep review found committeeRole() returned RoleCommittee only, while the publish side
(BroadcastAtSlot) and messageLateness already treat RoleAggregatorCommittee as committee-
backed, and origin/boole-fork's committeeRole() includes it. Left as-is, once unit 5 starts
producing RoleAggregatorCommittee, 100% of those messages would hit the validator lookup
and be rejected (ErrUnknownValidator). Align committeeRole() with boole-fork (no runtime
effect in unit 2 — role not produced yet — but removes the half-wired asymmetry).

Tests: TestCommitteeRole locks the publish/receive symmetry; add a pre-fork reverse-domain
subtest (Boole domain on a pre-fork slot -> ErrWrongDomain) to pin the slot-exact domain
check symmetrically. build/vet/deadcode/golangci-lint(message/validation) all clean.
Networking review found no blockers/majors; the transition-window subscription state
machine, topic derivation, peer scoring, and qbft/runner ripple are correct and match
origin/boole-fork. Two defensive items:
- BooleCommitteeSubnet now returns UnknownSubnetId on an empty committee instead of
  panicking on committee[0] (real shares are non-empty; guards a future-caller footgun).
- Document persistentSubnets' concurrency invariant: written only during single-threaded
  startup before the goroutine readers act on it. A -race-verified mutex is deferred to a
  follow-up (the p2p integration tests that would exercise it can't run in this sandbox).
CI (first run of the real networking gate) surfaced two real bugs the sandbox couldn't:
- Double-prefixed topics (ssv.v2.ssv.v2.64): the draft ported the full-topic-passing
  callers but not boole-fork's controller.go change, so topicsCtrl re-wrapped an already-
  full topic. Ported boole-fork's controller (Subscribe/Unsubscribe/Broadcast/Peers/Topics/
  Close use topic names verbatim; callers pass full Alan/Boole names). This also fixes
  ParseTopicSubnet in buildPeerTrimScores, which needs the full name.
- TestBuildPeerTrimScores panicked (nil NetworkConfig) then mismatched: buildPeerTrimScores
  needs NetworkConfig for fork-aware subnets; the trim test built p2pNetwork without it and
  its mock controller returned base topic names. Set cfg.NetworkConfig=TestNetwork and made
  the mock present full topic names (base stored, wrapped outward) to match production.

Also gofmt helpers_test.go (lint). build/vet/deadcode/golangci-lint + message/validation +
network/commons + network/topics + trim tests all green. Full-libp2p tests (SubscribeBroadcast,
TestTopicManager) still can't run in sandbox — CI is the gate.
Follow-up to the controller de-wrap: two network/topics tests still used base topic names.
- msg_validator_test.go: fed the validator base topics ('114'); production pubsub delivers
  full names, which fork-aware validateTopicAtSlot expects. Wrap in GetTopicFullName.
- controller_test.go (TestTopicManager): committeeTopic() returned a base name, so with the
  now-verbatim controller, Subscribe/Broadcast used base topics while getCount looked up full
  -> count never matched. Ported boole-fork's shape: committeeTopic returns the full topic;
  drop the now-redundant GetTopicFullName wraps; Peers(committeeTopic(pk)).

TestMsgValidator + build/gofmt/deadcode/golangci-lint(network/topics) green locally.
TestTopicManager needs real libp2p/mdns (hangs in sandbox) -> ported to match boole-fork
exactly; CI is the gate.
…ENDING)

Progress on exporter2 convergence (stacked on unit-2):
- Ported boole-fork's migration_9 (4 files) + registered in migrations.go.
- KEY DISCOVERY: exporter packages diverged — stage keeps the duty-trace model in
  exporter/traces (package traces), boole in exporter. So this is an ADAPTATION, not a
  file-copy: adapted migration_9 to use traces.* instead of exporter.*.
- Added Role spectypes.RunnerRole to traces.CommitteeDutyTrace (matches boole; only that
  one type gained Role).

NOT DONE (build currently broken): SSZ encoding for traces.CommitteeDutyTrace needs regen —
RunnerRole is int32 which sszgen can't encode; boole's generated code casts to uint64
(ssz.MarshalUint64(dst, uint64(c.Role))) and shifts all subsequent field offsets by 8.
Next: port boole-fork's proven CommitteeDutyTrace SSZ methods from exporter/model_encoding.go
into exporter/traces/model_encoding.go (offset-critical — verify via migration_9 round-trip
test), then the 15 conflict files + new api files (exporter/api/{decided/stream,msg,
query_handlers}, api/types.go).
…sets correct)

Data-format core of the exporter2 convergence, verified:
- Ported boole-fork's model_encoding.go into exporter/traces (package traces) — the structs
  are identical modulo package + the new Role field, so boole's proven, offset-correct SSZ
  encoding drops in (RunnerRole marshaled as uint64; all subsequent field offsets shift +8).
- Added Role to traces.CommitteeDutyTrace + its DeepCopy.
- migration_9 (+test) adapted to the traces package; added the CommitteeRunnerRoleToPrefix
  helper (+ role-prefix consts) to exporter/store.
- migration_9 round-trip TEST PASSES → confirms the SSZ-offset data-format risk is handled.

go build ./... + deadcode + gofmt clean. REMAINING: the broader role-aware exporter/store
keying (read/write paths) + the 15 conflict files (api handlers/node/server, broadcaster,
operators/recipients) + 6 net-new api files, then validation + code-reviewer-deep + PR.
…tCommitteeDuty/Duties)

Port boole-fork's role-aware committee-duty key scheme onto stage's
exporter/store/store.go (adapted to the traces package): committee-duty
keys now include a runner-role byte via CommitteeRunnerRoleToPrefix, and
Save/GetCommitteeDuty(ies) take an explicit spectypes.RunnerRole param
(GetCommitteeDuties defaults to both Committee+AggregatorCommittee when
no roles given).

Update every caller across stage's dutytracer package (which has no
boole-fork equivalent) to pass spectypes.RoleCommittee explicitly, since
AggregatorCommittee-producing runners are gated to a later unit:
- exporter/dutytracer/collector.go: two late-SaveCommitteeDuty sites,
  both already gated on RoleCommittee message paths
- exporter/dutytracer/eviction.go: periodic SaveCommitteeDuties dump
- exporter/dutytracer/store.go: DutyTraceStore interface + disk-fallback
  reads in Collector.GetCommitteeDuties/GetCommitteeDuty
- exporter/dutytracer/store_metrics.go: metrics-wrapping passthroughs
- test mocks/harnesses: dutytracer/collector_test.go mockDutyTraceStore,
  dutytracer/store_test.go direct store assertions, operator/node_query_test.go
Stage's exporter/v1/api stamped a STATIC domain on all exported participant data;
boole computes DomainTypeAtSlot(msg.Slot) per-message, so a batch spanning the Boole
fork gets wrong domains/MsgIDs for post-fork messages. Ported boole's fix:
- ParticipantsAPIData / NewParticipantsAPIMsg now take *networkconfig.Network and derive
  the domain per-message via netCfg.DomainTypeAtSlot(msg.Slot).
- Threaded netCfg through all callers: operator/node.go, exporter/v1/api/query_handlers.go,
  exporter/v1/api/decided/stream.go (+ its NewStreamPublisher/NewDecidedListener ctors and
  the cli/operator/node.go wiring), and updated query_handlers_test.go.

Full matrix green: build/vet(main+ssvsigner)/deadcode/gofmt/golangci-lint + exporter + api
tests. NOTE: aggregator-committee query-layer (Collector cache-keying) intentionally deferred
to unit 5; api/server.go + the old exporter/api/* path NOT ported (stage is ahead).
…iew nit)

Deep review found the stored trace's Role relied on RoleCommittee being the zero value
matching the 0x00 key byte, rather than an explicit assignment. Safe today, but a unit-5
footgun (the AggregatorCommittee path must set 0x06). Set Role=RoleCommittee explicitly at
both collector build sites to remove the implicit dependency.
Convergence of cli/operator/node.go. Most of unit 4's anticipated scope is already
stage-ahead: #2866 (node.go split) and #2891 (ApplyDefaults config-in-code) exist on stage
and NOT on boole; exporter fixedSubnets=AllSubnets default is present (node.go:364); the
#2914 teardown race is re-solved via ctx-cancel-before-close (documented in close()). The
only genuine fork delta: applyDynamicMaxPeers counted Alan subnets only. Now mirrors boole —
counts each validator's Boole committee subnet, plus its Alan subnet before/through the
transition (!booleFork), using the SSVShare.BooleCommitteeSubnet/AlanCommitteeSubnet helpers
added in unit 2. This is the peer-budget heuristic; resolves the nit deferred from unit-2 review.

build ./... + vet(main+ssvsigner) + deadcode + gofmt + golangci-lint(cli) + cli tests green.
Foundation for unit 5. Ports boole's fork-gated QBFT proposer selection:
- protocol/v2/qbft/round_robin_proposer.go: RoundRobinProposer changes signature to
  (height, round, committee, netCfg) and adds ETH-epoch variability + committee sorting;
  old impl renamed RoundRobinProposerPreBooleFork. Co-lands round_robin_proposer_test.go
  (pre-fork matches stage logic; post-fork epoch offset; committee-order independence).
- operator/validator/controller.go buildController: fork-gate the ProposerF —
  BooleForkAtSlot(state.Height) ? RoundRobinProposer(new) : RoundRobinProposerPreBooleFork.
  Uses ssvtypes.OperatorIDsFromOperators (already present). Runner construction stays for 5c.
- message/validation/consensus_validation.go leader check: fork-gate identically; rename
  the unit-2 helper roundRobinProposer -> roundRobinProposerPreBooleFork so validation and
  the actual proposer agree per-fork (else post-fork proposals rejected as 'not leader').
- validation_test.go: pre-fork leader test renamed; the unit-3 post-fork acceptance test
  now builds a proposal signed by the fork-aware leader (stronger).

Foundation only: aggregator-committee helpers (value_check checker, msg/runner_role role
bits) deferred to their consumers in 5b/5c to avoid deadcode. Full matrix green:
build ./... + vet(main+ssvsigner) + deadcode + gofmt + golangci-lint + qbft/message-validation/
operator-validator tests.
Ports boole-fork's additive runner changes for group 1: fork-aware
domain selection (DomainTypeAtSlot) on partial-sig broadcasts (#2915),
recoverableReconstructError tagging so a recoverable post-quorum
BLS-reconstruction failure isn't recorded as a failed duty (#2918/#2919
class) in both AggregatorRunner and CommitteeRunner, AggregatorCommitteeDuty
switch-cases in currentDutySlot/State (marshal) and the RoleAggregatorCommittee
branches in runner_validations.go (dead until the runner itself lands in a
later unit, but these are reachable switch/if branches, not unused symbols),
consolidation of VerifyBeaconPartialSignature into BaseRunner.verifyBeaconPartialSignature/
resolveDuplicateSignature, and misc test-kit deduplication (shared
runnerTestKit replaced by inlined per-file setup + standalone sortBySubnet
unit test per PR #2899 review).

Deliberately NOT ported (out of unit-5b scope):
- aggregator_committee.go / aggregator_committee_test.go: the new
  AggregatorCommitteeRunner itself is deferred to unit 5c.
- networkconfig Beacon.ForkAtEpoch -> BeaconForkAtEpoch rename: that
  lives in the networkconfig package (outside this unit's file list);
  kept the existing ForkAtEpoch name, which is functionally identical.
- NewVRSubmitter(ctx, ...) constructor/Start-merge refactor in
  validator_registration.go: its only caller (cli/operator/node.go) is
  outside this unit's scope and still uses the old 4-arg signature +
  explicit .Start(ctx) call; porting the rename would break that build.
  validator_registration.go and its test are left untouched.
- Redundant per-runner GetShare() overrides on AggregatorRunner/
  ProposerRunner/SyncCommitteeAggregatorRunner: BaseRunner.GetShare()
  (already present) provides identical behavior via promotion.

faultyAggregateSubmitBeacon (needed by
aggregator_postconsensus_classification_test.go) is defined locally in
that file since its original home, aggregator_committee_test.go, is
deferred to 5c; flagged with a comment for dedup when that file lands.

Build: go build ./protocol/v2/ssv/runner/... && go vet ./protocol/v2/ssv/runner/...
Test:  go test ./protocol/v2/ssv/runner/... -count=1  (ok)
Also verified: go build ./... (full repo), gofmt -l (clean), deadcode.sh (clean)
Ports boole-fork's additive scheduler changes: threads the real Boole
fork bit through RunnerRoleForValidatorDuty (resolves the
"TODO(convergence unit 5)" markers in scheduler.go and
observability/log/fields/fields.go) instead of a literal false, and
gates the legacy Alan-era execution paths in AttesterHandler
(executeAggregatorDuties) and SyncCommitteeHandler (processExecution)
behind !netCfg.BooleForkAtSlot so they stop firing once Boole activates
(the post-fork successor is the AggregatorCommitteeHandler, deferred -
see below).

Widens baseHandler (and every handler embedding it) from a bare
*networkconfig.Beacon to *networkconfig.Network (renamed
beaconConfig -> netCfg) because BooleForkAtSlot only exists on Network;
this is the prerequisite for the fork-bit threading above and ripples
through all of operator/duties/*.go plus a one-line companion fix in
operator/node.go and fee-recipient-adjacent duties.SchedulerOptions
construction (the only two callers of the renamed
SchedulerOptions.BeaconConfig/duties.NewScheduler field).

fields.Duties gained a runnerRole callback parameter (fork-aware
role resolution for log lines); adapted its 3 call sites
(attester.go, proposer.go, scheduler.go). Dropped the unrelated
fields.Subnet/FieldSubnet addition that rode along in the same
upstream file diff - its only consumers are network/p2p/*, outside
this unit's scope, and it was flagged by deadcode.sh as unreachable.

Deliberately NOT ported (out of unit-5b scope, coupled to the
AggregatorCommitteeRunner wiring deferred to a later unit):
- CommitteeHandler.isAggregator / NewAggregatorCommitteeHandler /
  newCommitteeDuty and widening committeeDuty.duty from
  *spectypes.CommitteeDuty to spectypes.Duty: the scheduler would then
  hand AggregatorCommitteeDuty values to DutyExecutor.ExecuteCommitteeDuty,
  but that interface's sole real implementer,
  operator/validator.Controller.ExecuteCommitteeDuty, is out of scope
  and still only accepts *spectypes.CommitteeDuty - widening the
  interface without it would break operator/node.go's
  duties.DutyExecutor(opts.ValidatorController) conversion.
- DutyExecutor.ExecuteCommitteeDuty(duty spectypes.Duty) widening in
  scheduler.go/executor_noop.go/scheduler_mock.go, for the same reason
  (those two files are consequently untouched).
- fields.BuildCommitteeDutyID gaining a role parameter: it has 5
  call sites across 4 packages, 3 of them out of scope
  (operator/validator/controller.go, protocol/v2/ssv/validator/*);
  kept the 3-arg signature and scheduler.go's existing call.
- ExecuteCommitteeDuties/loggerWithCommitteeDutyContext role
  recomputation via RunnerRoleForDuty: inert without the widened duty
  type (always resolves to RoleCommittee today), so skipped rather than
  adding dead-weight complexity.
committeeDuty gained no new accessor methods; committee_test.go/
scheduler_test.go were adapted to use committeeDuty.duty.ValidatorDuties
directly (an accessor method would only be called from tests, which
deadcode.sh flags as unreachable from the cmd/ssvnode entrypoint).

Build: go build ./... && go vet ./...
Test:  go test ./operator/duties/... ./observability/... ./operator/... -count=1  (ok)
Also verified: gofmt -l (clean), deadcode.sh (clean)
Ports boole-fork's additive RoleAggregatorCommittee handling in the
message-validation pipeline:
- signed_ssv_message.go: renamed validRole -> validRoleAtSlot(role, slot),
  now fork-gated: RoleAggregatorCommittee only valid post-Boole, the
  deprecated Alan RoleAggregator/RoleSyncCommitteeContribution only
  valid pre-Boole. The role check moved out of validateSSVMessage (which
  runs before the slot is known) into validateConsensusMessageSemantics/
  validatePartialSignatureMessageSemantics, once the message is decoded
  far enough to know its slot.
- validation.go: restored the domain-allowlist check validateSSVMessage
  used to do inline as its own validateDomainAllowlist method (called
  right after validateSSVMessage in handleSignedSSVMessage), and inlined
  the one-line committeeChecks wrapper into a direct belongsToCommittee
  call - both needed since validateSSVMessage lost those two checks.
- common_checks.go, partial_validation.go, consensus_validation.go:
  RoleAggregatorCommittee threaded through dutyLimit, maxRound,
  validPartialSigMsgType/partialSignatureTypeMatchesRole, and the
  partial-sig message/duplicate-index limits (widened from the
  RoleCommittee-only min(2V, V+SyncCommitteeSize) formula to a
  role-parameterized min(scSubnets*V, V+scSubnets*SyncCommitteeSize)
  covering AggregatorCommittee's wider fan-out); replaced `role !=
  spectypes.RoleCommittee` checks with the already-existing
  mv.committeeRole(role) helper so both committee roles are covered.
- consensus_validation.go: switched the full-data-hash check from the
  local protocol/v2/qbft.HashDataRoot ([32]byte, no error) to the spec's
  qbft.HashDataRoot ([32]byte, error) - byte-for-byte identical
  (sha256.Sum256, error always nil) at the pinned ssv-spec v1.2.3, so
  this is a behavior-neutral dedup; added ErrFullDataHash for the (dead)
  error path.
- seen_msg_types.go, errors.go: added spectypes.AggregatorCommitteePartialSig
  to the pre-consensus message-type set; renamed
  ErrTripleValidatorIndexInPartialSignatures ->
  ErrTooManyEqualValidatorIndicesInPartialSignatures (same check, V<=2,
  for RoleCommittee; the generalized name reflects the new
  role-parameterized limit above); added ErrFullDataHash.
- logger_fields.go: addDutyIDField now also builds committee-style duty
  IDs for RoleAggregatorCommittee; uses the existing
  ssvtypes.RunnerRoleToString (added the function to
  protocol/v2/types/runner_role.go - it was already needed here to
  stringify the deprecated Alan RoleAggregator/RoleSyncCommitteeContribution
  values, which spectypes.RunnerRole.String() doesn't know about).
- protocol/v2/types/runner_role.go: added RunnerRoleToString only (the
  CommitteeSignerBucket/CommitteeRunnerRoleForBeaconRole additions that
  rode along in the same upstream file are exporter/observability-domain
  and out of this unit's scope).

Deliberately NOT ported (out of unit-5b scope):
- committee_info.go's committeeInfoFromCommittee/committeeInfoFromShare
  rename and new CommitteeInfo.subnet/subnetAlan (commons.Subnet-typed)
  fields, and validation.go's corresponding switch to
  committeeInfo.subnet.BooleTopic()/.subnetAlan.AlanTopic(): both ride
  on network/commons's new `type Subnet uint64` (with BooleTopic/AlanTopic
  methods), a ~150-line rework of network/commons/subnets.go that also
  touches network/p2p/*, registry/storage, and protocol/v2/types/ssvshare.go
  - all out of scope. validateTopicAtSlot already computes the fork-aware
  expected topic correctly via the existing
  commons.BooleCommitteeSubnet()/commons.GetTopicFullName(commons.CommitteeTopicID())
  functions (added by an earlier unit), so this is a pure implementation-detail
  refactor with no behavior gap - left untouched.
- const.go's wholesale switch from hand-computed max-message-size
  constants to github.com/ssvlabs/ssv-spec/.../maxmsgsize-derived ones:
  unrelated to fork-awareness, touches the wire message-size-limit
  logic, and carries real risk if ported without independent scrutiny -
  out of scope for this unit.
- fields.BuildCommitteeDutyID gaining a role parameter (kept the 3-arg
  call in logger_fields.go): see unit-5b group-2 commit for why - it has
  5 call sites across 4 packages, 3 of them out of scope.
- common_checks_test.go: boole-fork deletes it, presumably folded into
  its validation_test.go rewrite; kept it since committeeRole() itself
  is unchanged and it's cheap, focused, still-valid coverage.
- The ~900-line validation_test.go rewrite: deeply coupled to the two
  skipped refactors above (commons.Subnet, const.go maxmsgsize) via
  shared test fixtures/helpers. Instead, fixed the 2 fallout spots in the
  existing test file: renamed one assertion to
  ErrTooManyEqualValidatorIndicesInPartialSignatures, and updated the
  "invalid role" test to key off a real validator pubkey instead of a
  synthetic committee ID (the role check moved past
  getCommitteeAndValidatorIndices/belongsToCommittee, so the old
  fixture now fails earlier with "unknown validator" instead of
  reaching the role check). No new dedicated tests were added for the
  new RoleAggregatorCommittee validation branches themselves.

Build: go build ./message/validation/... && go vet ./message/validation/...
Test:  go test ./message/... ./protocol/v2/types/... -count=1  (ok)
Also verified: go build ./... (full repo), gofmt -l (clean), deadcode.sh (clean)
…eview nit)

The fork bit is now threaded for real (group 2); the TODO is obsolete.
Port protocol/v2/ssv/runner/aggregator_committee.go from boole-fork in its final
#2919-correct form (terminal/recoverable classify closure, errCh drain, code-81
recoverable classification), plus its test suite. Dedup faultyAggregateSubmitBeacon
with the classification test file per unit5b's forward-looking NOTE.

Dependencies pulled in to make the runner compile and function:
- ssv.NewAggregatorCommitteeChecker (value_check.go), from the original
  aggregator-committee PR (c8c54ee), including its accompanying reorder of
  the WrongBeaconRoleType/DutyEpochTooFarFuture checks.
- beacon.BeaconNode: add IsAggregator/GetAggregateAttestation (client.go +
  regenerated mocks), and their GoClient implementation (aggregator.go),
  reusing the existing attestedDataRoot/computeAttestationDataRoot helpers
  already present in this base. Kept the existing ForkAtEpoch name (boole
  renamed it to BeaconForkAtEpoch as an unrelated, out-of-scope rename) and
  left checkPtrResponse/nil-check style alone (separate infra #2905 unit).
- protocol/v2/testing/temp_testing_beacon_network.go: BeaconNodeWrapped gains
  IsAggregator/GetAggregateAttestation delegating to the spec TestingBeaconNode
  (previously fell through to a nil embedded beacon.BeaconNode interface and
  panicked). Left the unrelated Alan-spec-test SyncCommitteeSubnetID file-split
  out of scope.
- protocol/v2/ssv/testing/{runner,validator}.go: wire AggregatorCommitteeRunner
  into the test harness construction paths (both fixed-share and share-map
  variants) and BaseValidator's role map.

Build: go build ./protocol/v2/ssv/... ./protocol/v2/blockchain/beacon/... ./beacon/...
Test: go test ./protocol/v2/ssv/... ./beacon/goclient/... -count=1 (all pass)
Widen Committee (protocol/v2/ssv/validator/committee.go) to run both
CommitteeDuty and AggregatorCommitteeDuty side by side: parallel
Queues/AggregatorQueues and Runners/AggregatorRunners maps, role-routed
queueContainer lookup, ProcessMessage pre-consensus routing for the
aggregator-committee partial-sig subtype, and runnerForDuty/runnerForRole/
createRunner/extractValidatorDuties helpers. committee_queue.go and timer.go
route enqueue/timeout events to the role-specific queue via the new
queueContainer wrapper. committee_observer.go adds aggregator/sync-committee-
contribution root tracking (saveAggregatorRoots/saveSyncCommContribRoots) and
its own resolveDuplicateSignature/verifyBeaconPartialSignature (copied from
BaseRunner, since CommitteeObserver lives outside the runner package).

Resolves the three unit5b "TODO(convergence unit 5): thread real fork bit"
markers in duty_executor.go and validator.go's StartDuty, and the aggregator
runner-construction spec-test skip.

Threads the committeeDuty.duty widening (*spectypes.CommitteeDuty ->
spectypes.Duty) end to end: operator/duties/committee.go gains
isAggregator/NewAggregatorCommitteeHandler/newCommitteeDuty plus
validatorDuties()/appendValidatorDuty() duty-type-switch helpers;
operator/duties/scheduler.go's DutyExecutor.ExecuteCommitteeDuty and
loggerWithCommitteeDutyContext follow; the scheduler now also registers
NewAggregatorCommitteeHandler alongside NewCommitteeHandler. Regenerated
scheduler_mock.go. operator/validator/controller.go's ExecuteCommitteeDuty
widens to match; SetupCommitteeRunners' CommitteeRunnerFunc signature widens
to (spectypes.Duty, ...) (runner.Runner, error) but its closure still only
constructs CommitteeRunner for now — the AggregatorCommitteeDuty case is
deferred to group 3, which is what makes NewAggregatorCommitteeRunner
reachable (confirmed via deadcode.sh: only NewAggregatorCommitteeRunner and
NewAggregatorCommitteeChecker remain flagged, as expected at this stage).

fields.BuildCommitteeDutyID gains a role parameter (formats via
FormatRunnerRole instead of a hardcoded "COMMITTEE" prefix) so committee vs
aggregator-committee duty IDs are distinguishable; all call sites updated
(message/validation, operator/duties, operator/validator, protocol/v2/ssv/
validator). protocol/v2/ssv/queue/observability.go gains the
AggregatorCommitteeQueueMetricType constant needed by the new queue.

Deliberately NOT ported (out of scope, from unrelated bundled history):
- committee.go's local `CommitteeWrongRoleErrorCode = 85` override and
  committee_queue.go's "drop_reason" -> "ssv.queue.drop_reason" attribute
  rename (kept base's existing spectypes.CommitteeWrongRoleErrorCode and
  "drop_reason" instead).
- qbft controller's identifier -> identifierFn (fork-aware QBFT msg ID)
  refactor and the associated protocol/v2/ssv/spectest/msg_processing_type.go
  rewrite (IdentifierFn, SetAggregators, adjustActualErrorForRunner, etc.) —
  applied only the minimal CommitteeRunnerFunc signature fix needed to keep
  spectest compiling, since the full rewrite depends on the out-of-scope
  qbft controller change.
- ssv_mapping_test.go's "TODO(convergence unit 5): also read
  AggregatorCommitteeRunners" is left as-is (non-blocking; no fixture in
  this suite currently exercises RoleAggregatorCommittee runners).

Fixed one JSON-encoding regression this widening caused in
committee_msg_processing_type.go: the reference committee built from a
state-comparison fixture didn't initialize the new AggregatorRunners map, so
its root diverged from the always-non-nil map the Committee constructor
produces (nil vs {} in JSON) — TestSSVMapping's committee-runner-state-root
comparisons were failing; initializing it to an empty map fixed all of them.

Build: go build ./... ; go vet ./...
Test: go test ./protocol/v2/ssv/... ./operator/duties/... ./operator/validator/...
      ./message/validation/... ./observability/... -count=1 (all pass)
deadcode.sh: only NewAggregatorCommitteeRunner + NewAggregatorCommitteeChecker
flagged (expected until group 3 wires controller construction)
Comment thread migrations/migration_9_committee_duty_role_field.go
Comment thread migrations/migration_9_committee_duty_role_field.go
Comment thread message/validation/validation.go
Comment thread registry/storage/validatorstore.go Outdated
badger's transaction limit is ~15% of MemTableSize (~9.6MB under the
DefaultOptions this repo opens with), and a single CommitteeDutyTrace can
reach ~4MB via ProposalData — so a 5000-record count cap alone can blow
ErrTxnTooBig. Flush at 2MB of accumulated payload as well, and stop
implying the batching bounds keys on badger (GetAll materializes them
up front).
migration_9's source model is frozen, but its write side marshals the
live traces.CommitteeDutyTrace and derives the key byte from the live
role→prefix map. Pin both with a golden-bytes fixture so a later shape
change fails loudly and directs the finder to freeze the target into a
V2 model instead of silently emitting the newest format.
…-base#2

The rationale and blast radius for skipping the validator-index check on
committee roles are settled in ssvlabs/knowledge-base#2; leave a pointer
at the relaxation site so it survives the merge.
Committee.Indices carries one entry per share, positionally aligned with
Shares — a metadata-less share contributes a literal 0 until beacon sync
fills its real index. Both the committee duty limit and validation's
len()==0 branch rely on that cardinality, so filtering the zeros would
tighten against legitimate traffic mid-sync. Say so at the field, and
mark the ErrNoValidators branch as the defensive assertion it is
(currently unreachable: empty committees are deleted outright).
…ator index

Committee-role message validation deliberately does not assert that a
partial-signature validator index belongs to a validator this operator
holds a share for (knowledge-base#2), so a duplicate partial signature
carrying an unheld index reached resolveDuplicateSignature and
dereferenced b.Share[idx].Committee on a nil *Share — crashing the whole
node from a single authenticated committee message. Look the share up
once, drop the unverifiable entry when it is absent. Adds RoleAggregator-
Committee as a second role exposed to this path; the RoleCommittee
exposure predates the fork work.

Regression test seeds a duplicate for an unheld index and asserts no panic.
… uniqueness

holesky's naive Boole domain (Alan+1 = {0,0,5,3}) was byte-identical to
hoodi's live Alan DomainType, and holesky-stage's ({0,0,0x31,0x14}) to
hoodi-stage's — the testnets share an ad-hoc 0x05/0x31 lane with adjacent
Alan domains, so Alan+1 walks onto the neighbour. DomainType feeds
NewMsgID and the handshake network-ID filter, so post-Boole holesky would
be protocol-indistinguishable from pre-Boole hoodi. Move both holesky
Boole domains clear of the neighbour's cluster (safe: Boole is unscheduled
on holesky, so NextDomainType is not live). Add TestBuiltinNetworkDomains-
AreUnique to enforce global uniqueness of every {Alan, Boole} domain going
forward — the existing config tests only asserted DomainType equality.

The specific replacement bytes (0x22 / 0x23) are a proposal; the domain
registry owner should confirm them.
…artial-sig cap

Post-consensus emits one partial signature per Aggregators/Contributors
entry (GetAggregateAndProofs / GetSyncCommitteeContributions are
one-per-entry, matched by committee/subnet index rather than position).
Message validation caps partial signatures at 5 per validator index and
rejects the whole message beyond it. Two gaps:

- Honest overflow: a validator holding several sync-committee indices in
  one subnet was appended to Contributors once per index, so a validator
  with >5 indices in a subnet made its own post-consensus message
  self-reject. Dedup Contributors by (validator, subnet) and Aggregators
  by (validator, committee index) at build time — bounding honest traffic
  to <=4 sync subnets + 1 aggregator = 5 per index, exactly the cap.

- Byzantine amplification: spec Validate() permits duplicate entries, so a
  malicious proposer could make every honest operator sign data that emits
  duplicate partial sigs and gets rejected network-wide, draining their
  gossip score. Reject duplicate (validator, committee index) within each
  set in the value check, so honest operators refuse the proposal at QBFT
  time. Deduped independently per set: an aggregator committee index and a
  contributor subnet id share a namespace but are distinct assignments.
…ss allowance

The Alan topic set was retired one slot after the fork
(booleSubsequentWindowSlots = 1), but message validation keeps accepting
messages for a pre-fork slot for maxStoredSlots (SlotsPerEpoch +
LateSlotAllowance) slots. A committee duty for the last pre-fork slot
whose QBFT is still advancing rounds publishes its decided and
post-consensus messages on the Alan topic keyed by that pre-fork slot —
after F+1 the whole fleet has unsubscribed and de-whitelisted Alan, so
those messages are dropped by the subscription filter before validation
sees them and the duty is silently lost.

Derive the subsequent window from SlotsPerEpoch + a lateness margin that
mirrors message/validation.LateSlotAllowance, so Alan stays subscribed as
long as those late pre-fork messages remain valid.
…ittee root computation

expectedPreConsensusRoots and expectedPostConsensusRootsAndBeaconObjects
swallowed DomainData/root-compute errors with a Debug log and continued.
Those errors come from the beacon node (DomainData) or from malformed
already-decided data — never a per-validator 'no duty' condition, and
DomainData fails identically for every validator in the slot. Swallowing
them left the expected-root maps empty, so pre-consensus concluded the
duty 'not required' with a success-flavoured log (dropping every
aggregation for the slot), and post-consensus fell through to
ErrNoValidDutiesToExecute, which terminates the queue consumer on what is
a transient error.

Both callers already markDutyFailed and return on a non-nil error, so
propagate the wrapped error instead of continuing. Drop the now-unused
logger parameter from both helpers.
…ittee roles

GetCommitteeDecideds loops over the committee runner roles and continues
on a miss, but only recognized the package-local ErrNotFound. A disk miss
wraps exporter/store.ErrNotFound instead, so the loop aborted on the first
role's miss rather than trying the next — a validator whose only trace at
that slot is an aggregator-committee duty would report nothing once a
caller passes more than one role. Match both sentinels.
The role-aware committee scan uses the slot+role prefix, so obj.Key is the
committee ID (32 bytes). A pre-migration legacy key whose committee ID
starts with the RoleCommittee byte 0x00 aliases into the scan and leaves a
shorter remainder, so UnmarshalSSZ fails and poisons the whole slot's
result with an error. Such keys can only appear after a post-migration
downgrade writes fresh legacy keys (migration_9 is forward-only). Skip any
entry whose trimmed key is not committee-ID length so a rollback degrades
to invisible stragglers rather than permanent per-slot query errors.
The committee round-timer routing msg ID used CurrentDomainType() (current
wall-clock slot) while every other MsgID in the fork cutover uses
DomainTypeAtSlot(dutySlot). Only GetRoleType() is read from this ID
downstream, so this is a consistency fix rather than a behavior change, but
it stops a pre-fork duty still running after the fork from keying its
timer events under the wrong domain.
…gators

A GetAggregateAttestation failure dropped the aggregator from the proposal
and set anyErr, but the loss was effectively silent: message replay never
recovers it (pre-consensus quorum is reported only once, so a retried
message never re-reaches this fetch), and post-consensus derives its
expectation from the decided data, so the duty was later reported as a
full success with the validator's aggregation missing.

Retry the fetch in place with a short bounded, context-aware backoff — the
realistic failure is a transient beacon hiccup at the 2/3-into-slot fetch,
and fetches are already deduped per committee index so this runs at most
once per index. When retries are exhausted, log the dropped aggregator at
WARN so the loss is visible rather than folded into a success conclusion.

Does not restructure the post-consensus success derivation; a genuine
sustained beacon outage still drops the aggregator (nothing can fetch it),
but it is now surfaced.
…tted-duty roots

Two aggregator-committee robustness fixes, sharing a generalized
retryBeaconFetch helper (extracted from the aggregate-attestation retry):

- Sync-committee contribution fetch (processSyncCommitteeSelectionProof)
  had the same shape as the aggregate path: a transient GetSyncCommittee-
  Contribution failure left the validator's selection unchecked, so the
  duty hung to slot end (message replay never re-reaches the fetch — quorum
  is reported once). Retry it in place with the same bounded backoff; the
  caller already logs the give-up.

- HasSubmittedAllDuties re-derived the expected-root maps by calling
  expectedPostConsensusRootsAndBeaconObjects again, re-issuing O(validators)
  DomainData calls per post-consensus message. Since that derivation can now
  fail on a transient beacon error, a hiccup at the final check mislabeled a
  fully-submitted duty as 'partial success'. Pass in the maps ProcessPost-
  Consensus already computed instead (mirrors CommitteeRunner).
…xecutor ID

validRole was replaced by the slot-dependent validRoleAtSlot, called only
after getCommitteeAndValidatorIndices. So a role outside the union of all
forks' valid roles, carried on a message whose executor ID does not resolve
to a known validator, took the unknown-validator path and returned
ErrUnknownValidator (Ignore) instead of ErrInvalidRole (Reject) — a lost
punishment signal for malformed traffic.

Add a fork-independent validRoleUnion check in validateSSVMessage (which
runs before the executor ID is resolved); validRoleAtSlot still narrows
valid roles per fork afterward. Regression test covers the unknown-executor
-ID case that previously degraded to Ignore.
validateBeaconDuty asserts per-validator duty assignment only for
RoleProposer and the pre-Boole RoleSyncCommitteeContribution; RoleCommittee
and RoleAggregatorCommittee fall through unchecked. This is deliberate, not
an oversight: committee roles are exempt from per-validator duty assertions
for the same reason the ValidatorIndex check is relaxed (knowledge-base#2)
— operators are not assumed synced on each other's validator sets, so
asserting a per-validator duty would reject legitimate messages from nodes
mid-sync and penalize the peers they need to catch up from.

The pre-Boole SCC role had the assertion because it was a per-validator
role; AggregatorCommittee inherits that traffic post-fork as a committee
role, so the drop is by design. Record it so it is not re-filed as a
regression. Residual spam is insider-only and bounded by dutyLimit.
…orting startup

A single legacy 'cd' value that fails UnmarshalSSZ aborted the whole
migration, and migration failure aborts node startup — so one bit-rotted
record among the tens of millions a mainnet exporter can hold would brick
the node with no clean operator remedy. These are observability duty
traces, not validator-critical state, and a record that won't decode is
already unreadable, so skipping it loses nothing recoverable.

Log the unreadable record loudly (ERROR, with its key) and continue,
counting skips in the completion log. The marshal of the converted trace is
left fatal: a failure there would indicate a conversion bug rather than
input corruption, and must not be silently skipped. Test seeds a corrupt
record beside a good one and asserts the migration completes.
qbft.Config.Domain was set to the static (Alan) DomainType at runner
construction and read only by GetSignatureDomainType(), which is declared
on the local IConfig interface but invoked nowhere — not in our controller/
instance, not in ssv-spec's qbft. Production signing-domain selection flows
through the fork-aware IdentifierFn. So the field was inert but latent: a
fork value frozen to Alan that any future caller of GetSignatureDomainType
would sign with post-fork, wrong, uncaught. Deriving it fork-aware is
meaningless (it is set once at construction, spanning the fork, and nothing
reads it), so delete the field, the interface method, and its two
production plus two test construction sites.
iurii-ssv
iurii-ssv previously approved these changes Jul 29, 2026
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.

4 participants