Add early RANDAO pre-consensus emission SIP - #101
Conversation
iurii-ssv
left a comment
There was a problem hiding this comment.
LGTM overall, the suggestions below are mostly minor clarifications.
Found while implementing the receiver rules in Anchor: existing client pipelines run contextual checks before signature verification, and a strict signatures-first order would drop the duplicate-count shield in front of RSA. Structural and canonical-form checks now run first and define the Early RANDAO candidate; for candidates the signature-vs-contextual order is implementation-defined, short-circuiting only on non-retaining verdicts, and any outcome that retains, accepts, forwards, or mutates state requires a valid operator signature (invalid signature plus Unknown duty is a pinned REJECT). Checks are staged: RSA gates retention and forwarding, BLS gates consumption. Vectors added for the pinned and flexible cases.
Pins receiver-divergence points found while mapping the rules onto Anchor's duty tracker and quarantine design. Known now requires the epoch's complete fetched proposer schedule (a locally filtered subset is not Known, so an honest share cannot be IGNOREd as Known-unassigned by a partial view). Retention is admission-conditioned rather than unconditional; the byte-identical occupied-key case keeps the original entry, full-capacity eviction has the incoming candidate compete under an exact total order (the unreachable fourth tiebreak is dropped), and promotion is restated via promotion candidates so an entry cannot both promote and fail revalidation. Vectors added for each case.
The summary said the SIP activates at the Gloas-aligned SSV fork, but the ePBS SIP (ssvlabs#94, checked at d5197bc) defines no SSV-network fork; its validation rules gate on epoch(msg.slot) >= GLOAS_FORK_EPOCH. This SIP now matches that model explicitly: activation is Ethereum-gated by the epoch(S) >= GLOAS_FORK_EPOCH conjunct, the SSV fork-equality conjunct is window-boundary protection only (it keeps the 2-slot emission window from spanning any scheduled SSV fork), and a new S >= EARLY_RANDAO_LEAD conjunct resolves the genesis underflow normatively, surfaced by the slot-zero unit test in the Anchor implementation. Vectors spell out the boundary outcomes.
|
Pushed some commits tightening the validation contract, from some findings while implementing the receiver rules in anchor:
Test vectors added for each change. |
The predicate required the SSV fork at S - EARLY_RANDAO_LEAD to equal the SSV fork at S, making the first two slots of every SSV fork activation epoch ineligible. It protects against nothing. Every fork-sensitive artifact of a partial signature (gossip topic, domain, role gating) is derived from the message's stamped slot rather than from emission time, so a message emitted inside a window that spans a fork activation is published, validated, and consumed entirely under the fork active at S. The SIP already relied on this for the Ethereum boundary, where the first slot of GLOAS_FORK_EPOCH is eligible and its window starts pre-fork; forbidding the same thing for SSV forks was inconsistent. Eligibility is now S >= EARLY_RANDAO_LEAD and epoch(S) >= GLOAS_FORK_EPOCH, a pure function of the slot and the Ethereum fork schedule.
MatheusFranco99
left a comment
There was a problem hiding this comment.
Good work @shane-moore !!
First review batch :)
|
|
||
| **Summary** | ||
|
|
||
| Operators may emit their existing block-proposal RANDAO partial signature up to 2 slots before the proposal slot, so the cluster reconstructs `randao_reveal` before the slot starts instead of inside the post-Gloas ~3s block-production budget. No new duty, role, message kind, domain, topic, or container: the existing Proposer-duty `RandaoPartialSig` message is emitted earlier, stamped with the proposal slot as today. Changes are confined to message-validation timing, ordering, and duty-handling rules plus a bounded receive-side buffer. Activates at `GLOAS_FORK_EPOCH`, the same Ethereum gate the ePBS SIP uses for its own validation rules; the dependency on the ePBS SIP is activation coupling only. |
There was a problem hiding this comment.
up to 2 slots before the proposal slot
Hmm, just by curiosity, why 2 slots, exactly?
Also, is there an edge case, like first epoch slot, in which one can't do 2 slots or even 1 in advance?
And if so how should them behave?
Possibly, clear edge-cases are the 1st and 2nd slots of the fork, right?
There was a problem hiding this comment.
Good catch. The previous wording compressed three separate points.
EARLY_RANDAO_LEAD = 2 is the maximum earliness receivers must support, not an assertion that gossip needs two slots. The window gives a producer an opportunity during S - 2 and another during S - 1 if the earlier local publication attempt fails. Conforming clients may emit at different points in that window. Receivers accumulate shares asynchronously and reconstruct at 2f+1, so different schedules remain compatible, but the latest share needed for quorum determines when the reveal becomes available. We did not select a longer receiver window because it would increase reveal and stale-duty exposure without an identified need.
S >= EARLY_RANDAO_LEAD is only a genesis underflow guard for calculating S - 2. It excludes absolute slots 0 and 1, not the first two slots of every epoch or the Gloas activation epoch.
Let F be the first slot of a non-genesis GLOAS_FORK_EPOCH. There is intentionally no activation warm-up:
S = Fis eligible, and its producer window opens atslot_start(F - 2).S = F + 1is eligible, and its window opens atslot_start(F - 1).- Target slots before
Fremain ineligible.
The separate 500 ms producer delay remains guidance inside those windows. Because receiver timing also includes the clock tolerance, receiver validation and quarantine must be active no later than receiver-local slot_start(F - 2) - EARLY_RANDAO_CLOCK_TOLERANCE.
Commit c859af6 now makes this target-slot activation model explicit in the summary, rationale, qualifying-message rules, producer behavior, and cross-client test expectations. We also aligned Anchor's receiver, quarantine, and producer issues so implementations do not accidentally introduce a current-epoch gate.
There was a problem hiding this comment.
Correction to part of my earlier reply: the activation model described there is unchanged, but the two-slot value is no longer used to prescribe an S - 2 attempt followed by an S - 1 retry.
It remains the maximum receiver window. The recommended producer attempt is now exactly S - 1; the 500 ms offset and coordinated second pre-slot attempt were removed.
Addressed in b29db38.
|
|
||
| - No new duty: RANDAO always has an in-slot consumer (the Proposer duty), and a separate duty would still need the in-slot pre-consensus as its fallback. This differs from `ProposerPreferences` (defined in the ePBS SIP, [#94](https://github.com/ssvlabs/SIPs/pull/94)), which must reconstruct before the slot and is mutable. | ||
| - The 2-slot window is chosen for operational margin, not gossip latency. The window is the fork-pinned receiver rule; the emission moment inside it is producer policy, tunable post-fork without a protocol change. | ||
| - The per-(signer, slot) duplicate limit stays 1. Each logical partial has exactly one valid byte encoding, gossip message identifiers are content-derived, and gossip layers deduplicate before validation, so a second copy either never reaches validation or is the receiver's first copy. |
There was a problem hiding this comment.
gossip layers deduplicate before validation, so a second copy either never reaches validation or is the receiver's first copy.
Just to add: byzantine duplicates with a different signature for the same (duty, signer) won't be dropped by the naive gossip hash deduplication, but it will be by our app message validation logic that accepts only 1 partial beacon signature per signer per type (pre- or post- consensus) and per duty.
There was a problem hiding this comment.
Good point. A byte-identical copy shares the original content-derived gossip ID, but changing the inner partial signature and re-signing the envelope normally produces a different gossip ID. That variant reaches application validation, where the existing one-message limit for the (MessageID, signer, slot) key and pre-consensus or post-consensus class stops it.
I narrowed the rationale to distinguish those cases. The duplicate limit itself does not need to change.
Addressed in c13987a.
| - No new duty: RANDAO always has an in-slot consumer (the Proposer duty), and a separate duty would still need the in-slot pre-consensus as its fallback. This differs from `ProposerPreferences` (defined in the ePBS SIP, [#94](https://github.com/ssvlabs/SIPs/pull/94)), which must reconstruct before the slot and is mutable. | ||
| - The 2-slot window is chosen for operational margin, not gossip latency. The window is the fork-pinned receiver rule; the emission moment inside it is producer policy, tunable post-fork without a protocol change. | ||
| - The per-(signer, slot) duplicate limit stays 1. Each logical partial has exactly one valid byte encoding, gossip message identifiers are content-derived, and gossip layers deduplicate before validation, so a second copy either never reaches validation or is the receiver's first copy. | ||
| - Because gossip layers never unmark a seen message, an IGNORE of an early partial permanently discards that operator's share for the window. The Unknown-duty retention rule and the dedicated clock tolerance narrow that failure class for honest shares without eliminating it; the residual cases are documented in *Duty assignment* (stale-`Known` reorg), the retention admission rule (refresh-pending losses), and Security Considerations. |
There was a problem hiding this comment.
never unmark a seen message
I think they do so, but I also think we are just fine in here since it should be a window of 1 or 2 epochs. We just need to confirm.
There was a problem hiding this comment.
Confirmed — they do expire, but with a wide margin: go-ssv's msgIDCacheTTL is 6.4 min (~32 slots) against this SIP's 24 s window. So "never unmark" is stronger than what implementations do; the conclusion holds, and stating the margin would be more convincing than the absolute.
There was a problem hiding this comment.
Confirmed, thanks. I replaced both absolute claims, "never unmark" and "a few seconds," with the narrower statement that gossip duplicate-cache lifetimes can exceed the entire useful Early RANDAO window. The recovery wording now says the in-slot copy may be suppressed, since restart and local cache state matter.
Addressed in a939402.
| | Constant | Value | | ||
| | -------- | ----- | | ||
| | `EARLY_RANDAO_LEAD` | 2 slots | | ||
| | `EARLY_RANDAO_CLOCK_TOLERANCE` | 1000 ms | | ||
| | `MAX_QUARANTINED_MESSAGES` | 4096 | |
There was a problem hiding this comment.
I think it could be useful to add a third column for a short description for each so the reading flow provides more intuition, what do you think?
For example EARLY_RANDAO_CLOCK_TOLERANCE appears only after some time in the document.
| - Proposer-role `MessageID`; type `RandaoPartialSig`; exactly one `PartialSignatureMessage` entry; | ||
| - `slot` = the proposal slot `S`; signed object `SSZUint64(epoch(S))` under `DOMAIN_RANDAO`, domain epoch `epoch(S)`; | ||
| - canonical SSZ; deterministic BLS share signature; deterministic RSA (PKCS#1 v1.5) operator signature; exactly one outer signer, equal to the embedded operator ID; | ||
| - eligibility predicate: `S >= EARLY_RANDAO_LEAD` and `epoch(S) >= GLOAS_FORK_EPOCH` (the Ethereum Gloas fork epoch, as used by the ePBS SIP, [#94](https://github.com/ssvlabs/SIPs/pull/94)). |
There was a problem hiding this comment.
S >= EARLY_RANDAO_LEAD
Hmm, I got confused here... the slot number should be above 2? Or it was more in the sense:
- First enabled slot ever:
$S - \text{[GLOAS FORK EPOCH.first slot]} \geq 2$ ? - Or distance to duty slot:
$0 \leq S (duty.slot) - now.slot \leq 2$ ?
There was a problem hiding this comment.
Answered this together with the two-slot and fork-boundary questions here. In short, S >= EARLY_RANDAO_LEAD is only the absolute-slot genesis guard; the allowed distance from the current time is enforced separately by the producer and receiver timing rules.
| - canonical SSZ; deterministic BLS share signature; deterministic RSA (PKCS#1 v1.5) operator signature; exactly one outer signer, equal to the embedded operator ID; | ||
| - eligibility predicate: `S >= EARLY_RANDAO_LEAD` and `epoch(S) >= GLOAS_FORK_EPOCH` (the Ethereum Gloas fork epoch, as used by the ePBS SIP, [#94](https://github.com/ssvlabs/SIPs/pull/94)). | ||
|
|
||
| The predicate is a pure function of `S`, evaluated identically by producers and receivers, never re-evaluated against wall-clock time. Activation is Ethereum-gated by `epoch(S) >= GLOAS_FORK_EPOCH`, matching the ePBS SIP's own validation gating; the `S >= EARLY_RANDAO_LEAD` conjunct keeps the producer's emission window well defined and excludes the first `EARLY_RANDAO_LEAD` slots at genesis. The SSV fork schedule plays no part, and an emission window that spans an SSV fork activation is fine: every fork-sensitive artifact of a partial signature (gossip topic, domain, role gating) is derived from the message's stamped slot rather than from the moment it was emitted, so such a message is published, validated, and consumed entirely under the fork active at `S`. The same holds for a window spanning the Ethereum fork boundary, which this predicate likewise permits. Containers violating the canonical form fall to existing structural rules (REJECT); BLS-share validity is not evaluated during message validation (see Message validation). Messages failing the predicate, and all non-randao messages, keep today's validation unchanged. |
There was a problem hiding this comment.
the first
EARLY_RANDAO_LEADslots at genesis
Ah, got it. But does it really make sense to do so? Shouldn't it be the first two of the activation epoch?
There was a problem hiding this comment.
Covered this in the consolidated response here. We are keeping the genesis guard and not excluding the first two activation-epoch slots: F and F + 1 are eligible target slots, including their pre-fork emission windows.
|
|
||
| For a locally known proposer duty at eligible slot `S`, an operator: | ||
|
|
||
| - MAY broadcast its qualifying randao partial at any wall-clock time in `[slot_start(S - EARLY_RANDAO_LEAD), slot_start(S))`; |
There was a problem hiding this comment.
But what about the first slot of the fork?
Is there a warm-up, or, for this case, it would end up just sending on its slot and that's it?
There was a problem hiding this comment.
Covered this in the consolidated response here. There is no warm-up: target slot F may emit from F - 2, so receiver support must be active before wall-clock Gloas.
| - MAY broadcast its qualifying randao partial at any wall-clock time in `[slot_start(S - EARLY_RANDAO_LEAD), slot_start(S))`; | ||
| - MUST NOT broadcast it before `slot_start(S - EARLY_RANDAO_LEAD)` by its own clock; | ||
| - SHOULD delay emission at least 500 ms past `slot_start(S - EARLY_RANDAO_LEAD)` (assumed maximum pairwise honest clock disparity: 1 s); | ||
| - SHOULD still execute the existing in-slot emission at `S` unconditionally; a running origin's identical re-publish is absorbed by its own gossip layer (expected, not an error), and a restarted origin's re-publish aids recovery; |
There was a problem hiding this comment.
Wait, so it always also try to emit again once the slot starts?
I think there could be a problem here since re-creating a message and BLS-signing it takes time. Only if we have it cached...
But maybe a flag about the message being published well or not is enough, no? What do you think?
There was a problem hiding this comment.
Yes, the ordinary in-slot path remains a SHOULD regardless of the recorded early outcome.
A local published flag is not enough to suppress it. Local enqueue, gossip publication, and peer receipt are distinct states, and successful local publication does not prove that the intended peers received the share.
The revised text also makes clear that a completed reconstruction MAY be returned immediately to the in-slot consumer without waiting for repeated signing or publication. Implementations MAY reuse cached valid signing artifacts or schedule emission independently.
Addressed in b29db38.
|
|
||
| - MAY broadcast its qualifying randao partial at any wall-clock time in `[slot_start(S - EARLY_RANDAO_LEAD), slot_start(S))`; | ||
| - MUST NOT broadcast it before `slot_start(S - EARLY_RANDAO_LEAD)` by its own clock; | ||
| - SHOULD delay emission at least 500 ms past `slot_start(S - EARLY_RANDAO_LEAD)` (assumed maximum pairwise honest clock disparity: 1 s); |
There was a problem hiding this comment.
Hmm, sorry I got lost in here. What is the purpose of doing that? Trying to let the receiver accept the msg due to the possibility of it having an earlier clock time and still being in the duty slot - 3? We can align msg validation for that rather than the logic (as we already do if I'm not mistaken)
There was a problem hiding this comment.
You are right. The 500 ms value was additional producer-side slack, while EARLY_RANDAO_CLOCK_TOLERANCE = 1000 ms already handles the assumed clock disparity at the legal receiver boundary. Once the recommended emission moved to S - 1, the 500 ms offset no longer served a protocol purpose.
I removed it. The producer now SHOULD make its first early attempt at slot_start(S - 1), while the two-slot receiver window and 1000 ms receiver tolerance remain unchanged. I am open to another recommended lead if you think there is a better choice.
Addressed in b29db38.
iurii-ssv
left a comment
There was a problem hiding this comment.
Added some potential clarifications to consider
| - No new duty: RANDAO always has an in-slot consumer (the Proposer duty), and a separate duty would still need the in-slot pre-consensus as its fallback. This differs from `ProposerPreferences` (defined in the ePBS SIP, [#94](https://github.com/ssvlabs/SIPs/pull/94)), which must reconstruct before the slot and is mutable. | ||
| - The 2-slot window is chosen for operational margin, not gossip latency. The window is the fork-pinned receiver rule; the emission moment inside it is producer policy, tunable post-fork without a protocol change. | ||
| - The per-(signer, slot) duplicate limit stays 1. Each logical partial has exactly one valid byte encoding, gossip message identifiers are content-derived, and gossip layers deduplicate before validation, so a second copy either never reaches validation or is the receiver's first copy. | ||
| - Because gossip layers never unmark a seen message, an IGNORE of an early partial permanently discards that operator's share for the window. The Unknown-duty retention rule and the dedicated clock tolerance narrow that failure class for honest shares without eliminating it; the residual cases are documented in *Duty assignment* (stale-`Known` reorg), the retention admission rule (refresh-pending losses), and Security Considerations. |
There was a problem hiding this comment.
Confirmed — they do expire, but with a wide margin: go-ssv's msgIDCacheTTL is 6.4 min (~32 slots) against this SIP's 24 s window. So "never unmark" is stronger than what implementations do; the conclusion holds, and stating the margin would be more convincing than the absolute.
Operators may emit the existing Proposer-duty RANDAO partial signature up to 2 slots before the proposal slot, so clusters can reconstruct
randao_revealbefore the slot starts instead of inside the post-Gloas ~3s block-production budget.No new duty, role, message kind, domain, topic, or container is introduced. Protocol changes are limited to target-slot activation, message-validation timing, message-kind-scoped ordering, and complete-schedule duty handling. Receivers MAY additionally retain an otherwise-valid Unknown-duty candidate locally, but retention is optional and does not change the initial IGNORE gossip verdict.
The rules activate from the stamped proposal slot
S: they apply whenepoch(S) >= GLOAS_FORK_EPOCH, including when the permitted window for the first Gloas slots begins before the fork in wall-clock time. The SSV fork schedule is not an input.Rationale and design discussion: #100
Addresses: ssvlabs/ssv-spec#373