Mobile wallpaper 1Mobile wallpaper 2Mobile wallpaper 3
969 words
5 minutes
Geth(9) QA

Q1: How do the CL and EL collaborate to produce a block?#

Post-Merge division of labor#

Before the Merge, geth decided when to produce blocks (when mining succeeded). After the Merge, the CL decides “when and who,” the EL decides “what goes inside.” They communicate via the Engine API (JSON-RPC).

Three-step handshake#

CL (beacon client) EL (geth)
| |
| ① ForkchoiceUpdatedV3 |
| { headHash, payloadAttributes } |
| ───────────────────────────────────────→ |
| | Update canonical head
| | Start building payload
| { payloadId: 0x42 } |
| ←─────────────────────────────────────── |
| |
| ... ~6 seconds (waiting for slot) ... |
| |
| ② GetPayloadV4 |
| { payloadId: 0x42 } |
| ───────────────────────────────────────→ |
| | Stop building
| { executionPayload, blobsBundle } | Return best block
| ←─────────────────────────────────────── |
| |
| (CL signs and broadcasts block) |
| |
| ③ NewPayloadV4 |
| { executionPayload } |
| ───────────────────────────────────────→ |
| | Validate and insert block
| { status: VALID } |
| ←─────────────────────────────────────── |

Step 1: ForkchoiceUpdated — CL tells geth two things: (a) which block is the current canonical head, and (b) if payloadAttributes is present, “you are the proposer for this slot, start building.” The attributes carry CL-provided information: timestamp, coinbase, RANDAO randomness, withdrawals, beacon root.

Step 2: GetPayload — ~6 seconds later (slot time is 12s, CL fetches at the midpoint), CL retrieves the built block. Geth returns the best version constructed so far.

Step 3: NewPayload — After CL broadcasts the block, all nodes (including the proposer) use NewPayload to have geth validate and insert the block. Returns INVALID if validation fails.

”Empty first, fill later” strategy#

BuildPayload() uses a clever two-phase approach:

ForkchoiceUpdated arrives
├─ Immediately build an empty block (no txs, just header + system calls + withdrawals)
│ → Takes almost no time
└─ Start background goroutine, repeatedly build full blocks
├─ 0s: first full build (with transactions)
├─ 2s: rebuild (Recommit — new txs may have arrived)
├─ 4s: rebuild again
└─ 6s: CL calls GetPayload, return best version

Each rebuild only replaces the current best block if it has higher total fees.

Why build an empty block first? To guarantee the validator never misses a slot. Even if transaction execution is slow, the pool is empty, or something goes wrong, the empty block is always available. When CL calls GetPayload, it gets the full block if available, otherwise the empty block — never an error.

Build pipeline#

Each generateWork() call runs a four-step pipeline:

prepareWork()
├─ Build header (block number, gas limit, base fee, blob gas...)
├─ engine.Prepare() (set difficulty = 0)
├─ Create StateDB and EVM
└─ Execute system calls (store beacon root, parent block hash)
fillTransactions()
├─ Pull pending txs from the pool
├─ Sort by effective tip
└─ Execute one by one, fill into block
Collect CL requests (Prague+)
├─ Parse deposit logs (EIP-6110)
├─ Process withdrawal request queue (EIP-7002)
└─ Process consolidation request queue (EIP-7251)
engine.FinalizeAndAssemble()
├─ Process withdrawals (credit validator balances)
├─ Compute state root
└─ Assemble final block

Q2: How are transactions selected for inclusion in a block?#

fillTransactions() is the core of transaction selection. It works in three steps: filter, sort, commit one by one.

Step 1: Filter#

A PendingFilter pre-filters transactions from the pool:

filter := txpool.PendingFilter{
MinTip: miner's configured minimum tip,
BaseFee: this block's base fee,
BlobFee: this block's blob base fee,
}

Only transactions meeting the minimum tip and able to cover base fee and blob fee are returned. This avoids pulling thousands of transactions that can’t possibly be included.

Plain and blob transactions are fetched separately (two Pending() calls) because they have different budget constraints.

Step 2: Sort by effective tip#

Fetched transactions are sorted by effective tip — the amount the miner actually receives per unit of gas:

effectiveTip = min(gasTipCap, gasFeeCap - baseFee)

Sorting uses a max-heap, but with a key design: the heap contains only the first transaction (lowest nonce) from each account. This is because transactions from the same account must execute in nonce order.

Heap top (highest tip)
├─ alice nonce=5 tip=10 gwei ← in heap
│ alice nonce=6 tip=8 gwei ← waits until 5 executes
│ alice nonce=7 tip=15 gwei ← waits until 6 executes
├─ bob nonce=3 tip=7 gwei ← in heap
└─ charlie nonce=1 tip=5 gwei ← in heap

Two heap operations:

  • Shift() — current top transaction executed successfully. Replace it with the next transaction from the same account, re-heapify. (alice nonce=5 succeeds → nonce=6 enters heap)
  • Pop() — current top transaction failed. Remove the entire account from the heap. (all subsequent nonces depend on this transaction)

Additionally, priority addresses (configured via SetPrioAddresses()) have their transactions committed before normal transactions, guaranteeing preferential inclusion.

Step 3: Commit one by one#

commitTransactions() alternates between the two heaps (plain and blob), picking the highest-tip transaction, and executes them one by one:

for {
1. Check interrupt signals (new chain head, timeout, recommit)
2. Gas pool < 21000? → block full, stop
3. Compare both heap tops, pick the higher tip
4. Check three budgets:
├─ Gas budget: tx gas ≤ remaining gas pool?
├─ Blob budget: blob count ≤ MaxBlobsPerBlock?
└─ Block size: doesn't exceed MaxBlockSize?
5. Execute transaction (core.ApplyTransaction)
6. Based on result:
├─ Success → Shift() (take next from same account)
├─ Nonce too low → Shift() (skip, take next)
└─ Other error → Pop() (skip entire account)
}

If execution fails, state rolls back to the pre-transaction snapshot and gas pool is restored — failed transactions leave no trace in the block.

Interrupt mechanism#

The build loop can be interrupted early:

  • Recommit timeout (every 2 seconds) — stop current build, restart with new transactions
  • New chain head arrives — current build’s parent may be stale
  • GetPayload called — CL is requesting the block, stop building

This is why multiple build rounds happen within a single slot (12s) — rebuilding every 2 seconds gives newly arrived high-tip transactions a chance to be included in a newer version.

Geth(9) QA
https://kehaozheng.vercel.app/posts/chainethgeth/09_qa/
Author
Kehao Zheng
Published at
2026-04-18
License
CC BY-NC-SA 4.0

Some information may be outdated