diff --git a/p2p/MessageValidation/Rules.md b/p2p/MessageValidation/Rules.md index 4959479..27cb04a 100644 --- a/p2p/MessageValidation/Rules.md +++ b/p2p/MessageValidation/Rules.md @@ -1060,6 +1060,199 @@ func (mv *MessageValidation) ValidatePartialSigMessagesByDutyLogic(peerID peer.I } ``` +### Gloas fork (ePBS) + +From `GLOAS_FORK_EPOCH`, the Gloas fork ([EIP-7732](https://eips.ethereum.org/EIPS/eip-7732), SSV's [SIP-94](https://github.com/ssvlabs/SIPs/pull/94)) makes these changes: +- it adds two validator-scoped duties: payload timeliness committee (PTC) attestation, and proposer preferences; +- the proposer's post-consensus packet also carries the execution-payload envelope's signature; +- validator registration is deprecated. + +This section lists the rules the fork adds or modifies, following SIP-94 ยง7. Every rule above still applies unless a row here modifies it. Message validation stays content-agnostic: it checks structure and metadata, never beacon-object content. + +#### New roles and partial signature types + +| Role (wire value) | Consensus messages | Partial signature types | Message slot | +| --------------------------------- | ------------------ | --------------------------------------------------------------------- | ------------------------ | +| `RolePTCAttester` (7) | Rejected | `PTCAttesterPartialSig` (7) | The PTC attestation slot | +| `RoleProposerPreferences` (8) | Rejected | `ProposerPreferencesPartialSig` (8), `RequestAuthPartialSig` (9) | The proposal slot | + +Both roles are validator-scoped, like validator registration and voluntary exit: `MsgID` carries the validator public key, and messages use the cluster's existing topic. + +A validator's preferences are emitted ahead of its proposal slot, anywhere in the proposer lookahead: the current epoch and the `MIN_SEED_LOOKAHEAD` epochs after it. `RequestAuthPartialSig` carries the builder request-auth round, which rides the same duty. + +#### Consensus: Duty Logic + +| Verification | Error | Classification | Explanation | +| ------------------------------------- | ----------------------------- | -------------- | -------------------------------------------------------------------------------------------------- | +| Invalid role for consensus (modified) | ErrUnexpectedConsensusMessage | Reject | SSVMessage.MsgID.Role must also not be PTCAttester or ProposerPreferences: neither duty runs QBFT. | + +#### Partial Signatures: Semantics + +| Verification | Error | Classification | Explanation | +| --------------------------------------------------- | ----------------------------------- | -------------- | ----------- | +| Role before the fork (new) | ErrInvalidRole | Reject | PTCAttester and ProposerPreferences messages must have `epoch(Slot) >= GLOAS_FORK_EPOCH`. The preferences emitted before the fork carry post-fork proposal slots, so they pass. | +| Registration at a Gloas slot (new) | ErrInvalidRole | Reject | ValidatorRegistration messages must have `epoch(Slot) < GLOAS_FORK_EPOCH`. Honest registrations always carry pre-fork slots. | +| Unknown type (modified) | ErrInvalidPartialSignatureType | Reject | PTCAttesterPartialSig, ProposerPreferencesPartialSig and RequestAuthPartialSig become known types. | +| Wrong type for role (modified) | ErrPartialSignatureTypeRoleMismatch | Reject | Adds:
PTCAttesterPartialSig for PTCAttester,
ProposerPreferencesPartialSig or RequestAuthPartialSig for ProposerPreferences. | +| Too many signatures for a validator role (modified) | ErrTooManyPartialSignatureMessages | Reject | For validator roles other than sync committee contribution, a packet carries one PartialSignatureMessage, except:
1 to 8 for RequestAuthPartialSig,
up to 2 for a Proposer PostConsensusPartialSig at a Gloas slot (the block root and, on the self-build path, the execution-payload envelope root).
This is checked here, not in the duty logic, so that an over-long packet is rejected before a duty-logic budget can ignore it. Which roots the entries carry is checked by the duty runner, not here. | +| Inconsistent validator index (new) | ErrInconsistentValidatorIndex | Reject | For validator roles, $\forall i$ PartialSignatureMessages.Message[i].ValidatorIndex must be the same. | + +#### Partial Signatures: Duty Logic + +| Verification | Error | Classification | Explanation | +| --------------------------------------- | ---------------------------------------------------- | -------------- | ----------- | +| Registration after the fork (new) | ErrValidatorRegistrationRetired | Ignore | ValidatorRegistration messages are ignored once the local wall-clock epoch is past `GLOAS_FORK_EPOCH`, whatever their slot. Registrations have no slot deadline, so one stamped with a pre-fork slot but arriving after the fork can only be a replay. The fork epoch itself still admits partials in flight from the last pre-fork slots. The rule ignores rather than rejects, because it depends on the receiver's clock, not on the message. | +| Already advanced slot (modified) | ErrSlotAlreadyAdvanced | Ignore | ProposerPreferences is exempt: a signer holds its whole lookahead at once, so a lower-slot message is a concurrent duty, not a stale one. Its lateness deadline bounds it instead. | +| No beacon duty (modified) | ErrNoDuty | Ignore | Adds:
for PTCAttester, the validator must hold a PTC assignment at `Slot`,
for ProposerPreferences, the validator must hold a proposer assignment at `Slot` (the proposal slot).
Both apply only once the node knows the duties for the slot's epoch (see [duty views](#duty-views)). | +| Invalid signature type count (modified) | ErrInvalidPartialSignatureTypeCount | Reject | Adds 1 PTCAttesterPartialSig for PTCAttester. ProposerPreferencesPartialSig and RequestAuthPartialSig are budgeted by distinct signing root instead (next two rows). | +| Preference root budget (new) | ErrSigningRootNotNew or ErrSigningRootBudgetExceeded | Ignore | ProposerPreferencesPartialSig allows up to 4 distinct signing roots per (`MsgID`, signer, `Slot`), so that a preference can be re-emitted when its inputs change, e.g. after a `dependent_root` reorg. A message is ignored when its root was already recorded, whichever peer relays it, or when its root is new but 4 are already recorded. The root is recorded only when the message is accepted. | +| Request-auth root budget (new) | ErrSigningRootNotNew or ErrSigningRootBudgetExceeded | Ignore | RequestAuthPartialSig allows up to 8 distinct signing roots per (`MsgID`, signer, `Slot`), and a root repeated within one packet counts once. A packet is ignored when it adds no new root, or when the recorded roots plus its new roots exceed 8. A packet that mixes recorded and new roots is accepted, and all of its new roots are recorded together. The two budgets are tracked separately. | +| Slot not in time for role (modified) | ErrEarlySlotMessage or ErrLateSlotMessage | Ignore | PTCAttester keeps the existing +3 slots.
ProposerPreferences is valid from the start of epoch `epoch(Slot) - MIN_SEED_LOOKAHEAD`, when the proposer becomes known, to `Slot` + 2. | +| Too many duties per epoch (modified) | ErrTooManyDutiesPerEpoch | Ignore | PTCAttester: 2 per epoch (a validator sits on one beacon committee per epoch, plus a reorg margin).
ProposerPreferences: `SLOTS_PER_EPOCH` per epoch (at most one proposal per slot).
Both are counted over the epoch of `Slot`. RequestAuthPartialSig rides the preference duty's slots and adds none. | + +#### Duty views + +A duty-assignment check (No beacon duty) applies only once the node has fetched the duties for the epoch of the message's slot. A not-yet-fetched epoch is tolerated rather than ignored, since the message can legitimately arrive first. + +A view fetched before the node's latest validator-set change is treated as not yet fetched, and so is one fetched before its latest detected `dependent_root` change for that epoch. The check stays skipped until a refresh completed after the change is installed. These roles broadcast one-shot partials: gossip's seen-cache suppresses an identical re-broadcast, so a wrongly ignored first copy can starve a short-lived duty. + +#### State retention + +The state behind these rules (recorded signing roots, duty counts) must be kept while any message it gates is still acceptable. For a message slot `S`, that is from the earliest acceptable arrival through `S`'s lateness deadline. + +For ProposerPreferences, that window runs from the start of epoch `epoch(S) - MIN_SEED_LOOKAHEAD` through `S + 2`. Acceptable slots then span up to three consecutive epochs, so duty counts must be kept for each of them. + +Evicting live state early re-opens the budgets above: a recorded root would be accepted and forwarded again as a new one. + +#### Code + +```go + +const ( + MinSeedLookahead = 1 // MIN_SEED_LOOKAHEAD + MaxRequestAuthEntries = 8 // entries per RequestAuthPartialSig packet + MaxProposerPreferencesDistinctRoots = 4 // per (MsgID, signer, Slot) + MaxRequestAuthDistinctRoots = 8 // per (MsgID, signer, Slot) +) + +var ( + ErrInconsistentValidatorIndex = Error{text: "validator index differs across partial signatures", reject: true} + ErrSigningRootNotNew = Error{text: "partial signature adds no new signing root"} + ErrSigningRootBudgetExceeded = Error{text: "signer's distinct signing-root budget for the slot is spent"} + ErrValidatorRegistrationRetired = Error{text: "validator registrations ended with the Gloas fork"} +) + +// ValidateGloasPartialSignatureSemantics holds the rules the Gloas fork adds to +// ValidatePartialSignatureMessageSemantics. It runs after the partial signature type is known to match the role. +func (mv *MessageValidation) ValidateGloasPartialSignatureSemantics(signedSSVMessage *types.SignedSSVMessage, partialSignatureMessages *types.PartialSignatureMessages) error { + role := signedSSVMessage.SSVMessage.MsgID.GetRoleType() + isGloas := mv.IsGloasAtSlot(partialSignatureMessages.Slot) + + // Rule: PTC attestation and proposer preferences do not exist before the fork + if (role == types.RolePTCAttester || role == types.RoleProposerPreferences) && !isGloas { + return ErrInvalidRole + } + + // Rule: validator registration is deprecated at the fork + if role == types.RoleValidatorRegistration && isGloas { + return ErrInvalidRole + } + + if role == types.RoleCommittee { + return nil + } + + if role != types.RoleSyncCommitteeContribution { + // Rule: a validator-role packet carries one PartialSignatureMessage, except: + // - 1 to MaxRequestAuthEntries for RequestAuthPartialSig + // - up to 2 for a Proposer PostConsensusPartialSig at a Gloas slot + limit := 1 + switch { + case role == types.RoleProposerPreferences && partialSignatureMessages.Type == types.RequestAuthPartialSig: + limit = MaxRequestAuthEntries + case role == types.RoleProposer && partialSignatureMessages.Type == types.PostConsensusPartialSig && isGloas: + limit = 2 + } + if len(partialSignatureMessages.Messages) > limit { + return ErrTooManyPartialSignatureMessages + } + } + + // Rule: every PartialSignatureMessage carries the same validator index + for _, psigMsg := range partialSignatureMessages.Messages { + if psigMsg.ValidatorIndex != partialSignatureMessages.Messages[0].ValidatorIndex { + return ErrInconsistentValidatorIndex + } + } + + return nil +} + +// ValidateGloasPartialSigDutyLogic holds the rules the Gloas fork adds to ValidatePartialSigMessagesByDutyLogic. +// The fork also modifies these existing rules: +// - MessageFromOldSlot: RoleProposerPreferences is exempt. +// - ValidPartialSigMessageCount: 1 PTCAttesterPartialSig for RolePTCAttester. ProposerPreferencesPartialSig and +// RequestAuthPartialSig are budgeted by ValidDistinctRootBudget instead. +// - ValidDutySlot: RolePTCAttester keeps the +3 slots. RoleProposerPreferences is valid from the start of epoch +// epoch(Slot) - MinSeedLookahead to Slot + 2. +// - ValidNumberOfDutiesPerEpoch: 2 for RolePTCAttester, SLOTS_PER_EPOCH for RoleProposerPreferences, counted over +// the epoch of Slot. +func (mv *MessageValidation) ValidateGloasPartialSigDutyLogic(signedSSVMessage *types.SignedSSVMessage, partialSignatureMessages *types.PartialSignatureMessages) error { + msgID := signedSSVMessage.SSVMessage.MsgID + role := msgID.GetRoleType() + slot := partialSignatureMessages.Slot + + // Rule: from the epoch after the fork, every validator registration is a replay + if role == types.RoleValidatorRegistration && mv.CurrentEpoch() > mv.GloasForkEpoch() { + return ErrValidatorRegistrationRetired + } + + // Rule: the validator must be assigned to the duty, once the duties for the slot's epoch are known and fresh + switch role { + case types.RolePTCAttester: + if mv.FreshDutiesKnown(types.BNRolePTCAttester, slot) && !mv.HasPTCDuty(msgID.GetSenderID(), slot) { + return ErrNoDuty + } + case types.RoleProposerPreferences: + if mv.FreshDutiesKnown(types.BNRoleProposer, slot) && !mv.HasProposerDuty(msgID.GetSenderID(), slot) { + return ErrNoDuty + } + } + + // Rule: distinct signing-root budgets for the proposer-preferences types + switch partialSignatureMessages.Type { + case types.ProposerPreferencesPartialSig: + return mv.ValidDistinctRootBudget(msgID, partialSignatureMessages, MaxProposerPreferencesDistinctRoots) + case types.RequestAuthPartialSig: + return mv.ValidDistinctRootBudget(msgID, partialSignatureMessages, MaxRequestAuthDistinctRoots) + } + + return nil +} + +// ValidDistinctRootBudget checks a packet against the signing roots recorded for (MsgID, signer, Slot, Type). +// Roots are recorded per signer, not per peer, and only when the message is accepted (see UpdateState). +func (mv *MessageValidation) ValidDistinctRootBudget(msgID types.MessageID, partialSignatureMessages *types.PartialSignatureMessages, budget int) error { + signer := partialSignatureMessages.Messages[0].Signer + recorded := mv.RecordedSigningRoots(msgID, signer, partialSignatureMessages.Slot, partialSignatureMessages.Type) + + newRoots := make(map[[32]byte]struct{}) + for _, psigMsg := range partialSignatureMessages.Messages { + if !recorded.Contains(psigMsg.SigningRoot) { + newRoots[psigMsg.SigningRoot] = struct{}{} + } + } + + if len(newRoots) == 0 { + return ErrSigningRootNotNew + } + if recorded.Len()+len(newRoots) > budget { + return ErrSigningRootBudgetExceeded + } + return nil +} +``` + ### Observations