From a777919f1cebb429ee27b97741cac14e53f5c740 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Fri, 15 May 2026 09:23:33 -0700 Subject: [PATCH 1/4] fix(consensus): align FCU timestamp to next 1s boundary to eliminate empty blocks The sequencer was dispatching engine_forkchoiceUpdatedWithAttributes immediately after sendTxs, whose duration is proportional to tx workload. For heavy payloads (ecrecover at 150M gas), this left the FCU arriving with <75ms until block_timestamp, below flashblocks' leeway window, causing 'FCU arrived too late' and deposit-only empty blocks. Fix: sleep until the next whole-second Unix boundary before dispatching the FCU. block_timestamp is set to boundary+1s, so the builder always sees exactly ~1000ms of building time minus FCU RPC latency, regardless of sendTxs duration. The getBuiltPayload sleep is changed to sleep until boundary+1s rather than a fixed 1s, so total block period is unchanged. Tested before/after with ecrecover simulator payload at 150M gas, 20 blocks: Before: 1 empty block (block #11, 1 tx, deposit only) After: 0 empty blocks (all 20 blocks full at 352 txs) --- .../network/consensus/sequencer_consensus.go | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/runner/network/consensus/sequencer_consensus.go b/runner/network/consensus/sequencer_consensus.go index f5ca8ac..2e2ed3c 100644 --- a/runner/network/consensus/sequencer_consensus.go +++ b/runner/network/consensus/sequencer_consensus.go @@ -112,7 +112,7 @@ func marshalBinaryWithSignature(info *derive.L1BlockInfo, signature []byte) ([]b return w.Bytes(), nil } -func (f *SequencerConsensusClient) generatePayloadAttributes(sequencerTxs [][]byte, isSetupPayload bool) (*eth.PayloadAttributes, *common.Hash, error) { +func (f *SequencerConsensusClient) generatePayloadAttributes(sequencerTxs [][]byte, isSetupPayload bool, nextBoundary time.Time, blockTime time.Duration) (*eth.PayloadAttributes, *common.Hash, error) { gasLimit := eth.Uint64Quantity(f.options.GasLimit) if isSetupPayload { gasLimit = eth.Uint64Quantity(f.options.GasLimitSetup) @@ -121,15 +121,11 @@ func (f *SequencerConsensusClient) generatePayloadAttributes(sequencerTxs [][]by var b8 eth.Bytes8 copy(b8[:], eip1559.EncodeHolocene1559Params(50, 1)) - // Always keep timestamps at or ahead of wall clock so the builder - // never sees "FCU arrived too late" and produces empty blocks. - now := uint64(time.Now().Unix()) - lastTimestamp := f.lastTimestamp - if now > lastTimestamp { - lastTimestamp = now - } - - timestamp := lastTimestamp + 1 + // Use nextBoundary (the block-time-aligned wall-clock boundary we slept to) plus + // one block time as the block timestamp. This guarantees the FCU always arrives + // at the same point relative to the block deadline, eliminating the jitter that + // causes "FCU arrived too late" and empty blocks when sendTxs takes variable time. + timestamp := uint64(nextBoundary.Add(blockTime).Unix()) number := uint64(0) time := uint64(0) @@ -268,11 +264,17 @@ func (f *SequencerConsensusClient) Propose(ctx context.Context, blockMetrics *me duration := time.Since(startTime) f.log.Info("Sent transactions", "duration", duration, "num_txs", len(sendTxs)) blockMetrics.AddExecutionMetric(networktypes.SendTxsLatencyMetric, duration) - startBlockBuildingTime := time.Now() + now := time.Now() + nextBoundary := now.Truncate(f.options.BlockTime).Add(f.options.BlockTime) + sleepDuration := time.Until(nextBoundary) + f.log.Info("Aligning to next block time boundary before FCU", "sleep", sleepDuration, "block_time", f.options.BlockTime) + time.Sleep(sleepDuration) + + startBlockBuildingTime := time.Now() f.log.Info("Starting block building") - payloadAttrs, beaconRoot, err := f.generatePayloadAttributes(sequencerTxs, isSetupPayload) + payloadAttrs, beaconRoot, err := f.generatePayloadAttributes(sequencerTxs, isSetupPayload, nextBoundary, f.options.BlockTime) if err != nil { return nil, errors.Wrap(err, "failed to generate payload attributes") } @@ -290,9 +292,10 @@ func (f *SequencerConsensusClient) Propose(ctx context.Context, blockMetrics *me blockMetrics.AddExecutionMetric(networktypes.UpdateForkChoiceLatencyMetric, duration) f.currentPayloadID = payloadID - f.log.Info("Waiting for block time", "block_time", f.options.BlockTime) - // wait block time - time.Sleep(f.options.BlockTime) + blockDeadline := nextBoundary.Add(f.options.BlockTime) + waitDuration := time.Until(blockDeadline) + f.log.Info("Waiting for block deadline", "wait", waitDuration) + time.Sleep(waitDuration) startTime = time.Now() From 2e6bd33a52a849f949a636a2049101f9b0265786 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Fri, 15 May 2026 11:07:54 -0700 Subject: [PATCH 2/4] fix(simulator): use numCallsPerBlock as pendingTxs gate, not scaled targetCalls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When scaleFactor > 1 (e.g. base-mainnet-simulation with avg_gas_used=35M and GasLimit=200M gives scaleFactor≈5.71, targetCalls=572), the previous guard if pendingTxs >= targetCalls { callsToSend = 0 } allowed pendingTxs to accumulate past targetCalls after just 2 blocks (572 sent, only ~50 included per block → pendingTxs grows to 1044 ≥ 572). From that point callsToSend=0 forever and every subsequent block is empty (deposit-tx only). The gate should use numCallsPerBlock (unscaled) since scaleFactor scales the ops-per-tx, not the tx count threshold for backpressure. With numCallsPerBlock=100 as the gate, pending tx buildup is bounded to one block's worth and the simulator continues generating txs regardless of scaleFactor. --- runner/payload/simulator/worker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runner/payload/simulator/worker.go b/runner/payload/simulator/worker.go index 65c6071..b7ac71e 100644 --- a/runner/payload/simulator/worker.go +++ b/runner/payload/simulator/worker.go @@ -597,7 +597,7 @@ func (t *simulatorPayloadWorker) sendTxs(ctx context.Context, pendingTxs int) (i targetCalls := uint64(math.Ceil(float64(t.numCallsPerBlock) * t.scaleFactor)) var callsToSend uint64 - if uint64(pendingTxs) >= targetCalls { + if uint64(pendingTxs) >= t.numCallsPerBlock { callsToSend = 0 } else { callsToSend = targetCalls - uint64(pendingTxs) From 0173e7d36ea31959ae2a7c2b983bd18959f231f9 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Fri, 15 May 2026 11:14:17 -0700 Subject: [PATCH 3/4] revert: restore original pendingTxs >= targetCalls guard in sendTxs --- runner/payload/simulator/worker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runner/payload/simulator/worker.go b/runner/payload/simulator/worker.go index b7ac71e..65c6071 100644 --- a/runner/payload/simulator/worker.go +++ b/runner/payload/simulator/worker.go @@ -597,7 +597,7 @@ func (t *simulatorPayloadWorker) sendTxs(ctx context.Context, pendingTxs int) (i targetCalls := uint64(math.Ceil(float64(t.numCallsPerBlock) * t.scaleFactor)) var callsToSend uint64 - if uint64(pendingTxs) >= t.numCallsPerBlock { + if uint64(pendingTxs) >= targetCalls { callsToSend = 0 } else { callsToSend = targetCalls - uint64(pendingTxs) From ac4d7b130c208e1a9c07300d81aefe23670aabda Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Fri, 15 May 2026 11:18:55 -0700 Subject: [PATCH 4/4] fix(simulator): remove double-application of scaleFactor in targetCalls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scaleFactor is applied per-tx inside sendTxs at line 608: expected = payloadParams.Mul(numCalls+1 * scaleFactor) meaning each tx already does scaleFactor times the base operations. targetCalls was then computed as: numCallsPerBlock * scaleFactor which multiplied scaleFactor in again — resulting in 5.71x more txs being targeted than actually needed (e.g. 572 instead of 100 for base-mainnet-simulation with GasLimit=200M, avg_gas_used=35M). Since the builder only absorbs ~100 txs/block worth of those scaled ops, pendingTxs accumulated beyond targetCalls after 2 blocks, causing callsToSend=0 forever and 7,000+ consecutive empty (deposit-only) blocks in a 900-block run. Fix: targetCalls = numCallsPerBlock (the tx count, not the ops count). scaleFactor belongs only in the per-tx operation calculation. --- runner/payload/simulator/worker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runner/payload/simulator/worker.go b/runner/payload/simulator/worker.go index 65c6071..1a228c7 100644 --- a/runner/payload/simulator/worker.go +++ b/runner/payload/simulator/worker.go @@ -595,7 +595,7 @@ func (t *simulatorPayloadWorker) sendTxs(ctx context.Context, pendingTxs int) (i sendTxsStartTime := time.Now() - targetCalls := uint64(math.Ceil(float64(t.numCallsPerBlock) * t.scaleFactor)) + targetCalls := t.numCallsPerBlock var callsToSend uint64 if uint64(pendingTxs) >= targetCalls { callsToSend = 0