Skip to content

Instantly share code, notes, and snippets.

@raulk
Created January 22, 2026 18:04
Show Gist options
  • Select an option

  • Save raulk/224b7d30acda2ae2a1a8f5e2c8caf278 to your computer and use it in GitHub Desktop.

Select an option

Save raulk/224b7d30acda2ae2a1a8f5e2c8caf278 to your computer and use it in GitHub Desktop.

Based on my research, let me clarify the exact timing:

Payload Building vs Retrieval Timing

Yes, the payload is built ahead of time, but the proposer doesn't necessarily retrieve it exactly at t+0 of their slot.

The Flow:

  1. 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.

  2. Proposer retrieves payload when they're ready to propose: The validator client waits for the slot to start, then calls getPayload to 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)
        // ...
        }
    }
}

Actual Payload Retrieval

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

Summary Timeline

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment