Based on my research, let me clarify the exact timing:
Yes, the payload is built ahead of time, but the proposer doesn't necessarily retrieve it exactly at t+0 of their slot.
-
Payload building starts during slot N-1: When the block for slot N-1 is processed, an FCU with payload attributes is sent to the execution engine to start building the payload for slot N.
-
Proposer retrieves payload when they're ready to propose: The validator client waits for the slot to start, then calls
getPayloadto retrieve the pre-built execution payload.
Looking at the validator client code in validator/client/propose. go:
func (v *validator) ProposeBlock(ctx context.Context, slot primitives.Slot, pubKey [fieldparams.BLSPubkeyLength]byte) {
if slot == 0 {
log.Debug("Assigned to genesis slot, skipping proposal")
return
}
ctx, span := trace.StartSpan(ctx, "validator.ProposeBlock")
defer span.End()
lock := async.NewMultilock(fmt.Sprint(iface.RoleProposer), string(pubKey[: ]))
lock.Lock()
defer lock.Unlock()
// ...
}The validator client uses a SlotTicker that fires at the start of each slot (t+0):
From time/slots/slotticker.go:
func (s *SlotTicker) start(... ) {
// ...
for {
waitTime := until(nextTickTime)
select {
case <-after(waitTime):
s.c <- slot // Fires at slot boundary (t+0)
slot++
nextTickTime = nextTickTime. Add(d)
// ...
}
}
}When the proposer requests a block, getPayload is called. If there's a cache hit (payload ID was pre-computed), it retrieves the already-built payload immediately:
From beacon-chain/rpc/prysm/v1alpha1/validator/proposer_execution_payload.go:
payloadId, ok := vs.PayloadIDCache.PayloadID(slot, parentRoot)
// ...
if ok && payloadId != [8]byte{} {
// Payload ID is cache hit. Return the cached payload ID.
payloadIDCacheHit. Inc()
res, err := vs.ExecutionEngineCaller.GetPayload(ctx, pid, slot)
if err == nil {
warnIfFeeRecipientDiffers(val. FeeRecipient[: ], res.ExecutionData.FeeRecipient())
return res, nil
}
// ...
}
// Cache miss - need to call ForkchoiceUpdated first, then GetPayload| Time | Event |
|---|---|
| Slot N-1 (when block arrives) | FCU sent with attributes → EL starts building payload for slot N |
| Slot N, t+0 | Slot ticker fires, validator client triggers ProposeBlock |
| Slot N, t+0 + δ | getPayload called → retrieves pre-built payload from EL |
| Slot N, t+0 + δ' | Block signed and broadcast |
So yes, the proposal flow kicks off at t+0, but there's a small delay (network latency, processing time) before getPayload is actually called. The key optimization is that the execution payload has already been building for potentially the entire previous slot (~12 seconds on mainnet), so getPayload returns quickly with a payload that has had maximum time to accumulate transactions.