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