Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions p2p/MessageValidation/Rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<br> PTCAttesterPartialSig for PTCAttester,<br> 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:<br> 1 to 8 for RequestAuthPartialSig,<br> 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).<br> 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:<br> for PTCAttester, the validator must hold a PTC assignment at `Slot`,<br> for ProposerPreferences, the validator must hold a proposer assignment at `Slot` (the proposal slot).<br> 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.<br> 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).<br> ProposerPreferences: `SLOTS_PER_EPOCH` per epoch (at most one proposal per slot).<br> 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

Expand Down