evm: extract nextBuildTime pure helper + regression test for build pacing

Decomplect the build-pacing decision out of waitForEvent into a pure
nextBuildTime(earliestBuild, lastBuildTime) function (no clock read), so the
logic the network-halt fix depends on is deterministically testable. The
wall-clock comparison stays in waitForEvent.

TestNextBuildTime covers all 5 cases (first-build paces to target rate; past
earliestBuild builds immediately; target rate dominates retry floor; retry
floor prevents hot-loop; boundary tie-break). TestNextBuildTime_NeverEarlier
ThanTargetRate locks the invariant the fix relies on: the next build is NEVER
scheduled before parent.Time+TargetBlockRate, so a too-early high-fee block can
never re-introduce the small-tip stall.
This commit is contained in:
2026-06-28 00:52:09 -07:00
parent 5c129faa36
commit d096677067
2 changed files with 125 additions and 7 deletions
+22 -7
View File
@@ -122,13 +122,7 @@ func (b *blockBuilder) waitForEvent(ctx context.Context, earliestBuild time.Time
if err != nil {
return block.Message{}, err
}
nextBuild := earliestBuild
if !lastBuildTime.IsZero() {
if retry := lastBuildTime.Add(minBlockBuildingRetryDelay); retry.After(nextBuild) {
nextBuild = retry
}
}
timeUntilNextBuild := time.Until(nextBuild)
timeUntilNextBuild := time.Until(nextBuildTime(earliestBuild, lastBuildTime))
if timeUntilNextBuild <= 0 {
return block.Message{Type: block.PendingTxs}, nil
}
@@ -141,6 +135,27 @@ func (b *blockBuilder) waitForEvent(ctx context.Context, earliestBuild time.Time
}
}
// nextBuildTime is the earliest wall-clock instant the next block may be built.
// It is the later of two floors:
// - earliestBuild: parent.Time + TargetBlockRate — building before this raises
// the block's required fee, so a small-tip tx cannot cover it and the block
// is rejected by verifyBlockFee (the stall this pacing exists to prevent).
// - lastBuildTime + minBlockBuildingRetryDelay: a retry floor that keeps a
// failed BuildBlock from hot-looping once earliestBuild has already passed.
//
// A zero lastBuildTime (no block built this session yet) means only earliestBuild
// applies. Pure function of its inputs (no clock read) so the pacing decision is
// deterministically testable; the wall-clock comparison stays in waitForEvent.
func nextBuildTime(earliestBuild, lastBuildTime time.Time) time.Time {
if lastBuildTime.IsZero() {
return earliestBuild
}
if retry := lastBuildTime.Add(minBlockBuildingRetryDelay); retry.After(earliestBuild) {
return retry
}
return earliestBuild
}
// waitForNeedToBuild waits until needToBuild returns true.
// It returns the last time a block was built.
func (b *blockBuilder) waitForNeedToBuild(ctx context.Context) (time.Time, error) {
+103
View File
@@ -0,0 +1,103 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package evm
import (
"testing"
"time"
)
// TestNextBuildTime locks in the block-build pacing decision (the fix for the
// network-wide block-production stall: building before parent.Time+TargetBlockRate
// raises the block fee above what a small-tip tx can cover, so verifyBlockFee
// rejects the block and the chain never advances).
//
// nextBuildTime returns the later of:
// - earliestBuild (parent.Time + TargetBlockRate), and
// - lastBuildTime + minBlockBuildingRetryDelay (retry floor, only when a block
// was already built this session).
func TestNextBuildTime(t *testing.T) {
base := time.Unix(1_700_000_000, 0)
tests := []struct {
name string
earliestBuild time.Time
lastBuildTime time.Time
want time.Time
}{
{
// First build of the session (lastBuildTime zero): pace purely to the
// target rate. This is the path every frozen chain takes on its first
// post-bootstrap block — it MUST honor earliestBuild, never fire early.
name: "first build paces to earliestBuild",
earliestBuild: base.Add(2 * time.Second),
lastBuildTime: time.Time{},
want: base.Add(2 * time.Second),
},
{
// earliestBuild already in the past + no prior build: build now
// (returned time is in the past, so time.Until <= 0). This is the
// healthy catch-up case (a chain idle longer than the block rate).
name: "first build with past earliestBuild is immediate",
earliestBuild: base.Add(-time.Hour),
lastBuildTime: time.Time{},
want: base.Add(-time.Hour),
},
{
// Target rate dominates the retry floor: a freshly built block must
// still wait the full target rate, not just the retry floor.
name: "target rate dominates retry floor",
earliestBuild: base.Add(2 * time.Second),
lastBuildTime: base.Add(-50 * time.Millisecond),
want: base.Add(2 * time.Second),
},
{
// earliestBuild already passed but we JUST built: the retry floor
// prevents a hot-loop. Returns lastBuildTime + retry delay.
name: "retry floor prevents hot-loop after earliestBuild passed",
earliestBuild: base.Add(-time.Second),
lastBuildTime: base,
want: base.Add(minBlockBuildingRetryDelay),
},
{
// Exact boundary: retry floor equal to earliestBuild is NOT "after",
// so earliestBuild is returned (deterministic tie-break, no drift).
name: "retry floor equal to earliestBuild yields earliestBuild",
earliestBuild: base.Add(minBlockBuildingRetryDelay),
lastBuildTime: base,
want: base.Add(minBlockBuildingRetryDelay),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := nextBuildTime(tt.earliestBuild, tt.lastBuildTime)
if !got.Equal(tt.want) {
t.Fatalf("nextBuildTime(%v, %v) = %v, want %v",
tt.earliestBuild, tt.lastBuildTime, got, tt.want)
}
})
}
}
// TestNextBuildTime_NeverEarlierThanTargetRate is the property the stall fix
// depends on: regardless of the prior build time, the next build is NEVER
// scheduled before earliestBuild (the target-rate floor). If this ever returns
// a time before earliestBuild, a block could be built too soon, its fee would
// exceed a small-tip tx's budget, and the chain would stall again.
func TestNextBuildTime_NeverEarlierThanTargetRate(t *testing.T) {
earliest := time.Unix(1_700_000_000, 0)
for _, deltaMs := range []int{-10_000, -100, -1, 0, 1, 100, 10_000} {
last := earliest.Add(time.Duration(deltaMs) * time.Millisecond)
got := nextBuildTime(earliest, last)
if got.Before(earliest) {
t.Fatalf("nextBuildTime scheduled %v BEFORE earliestBuild %v (lastBuildTime=%v) — would re-introduce the fee stall",
got, earliest, last)
}
}
// And the zero-value (first build) case must equal earliestBuild exactly.
if got := nextBuildTime(earliest, time.Time{}); !got.Equal(earliest) {
t.Fatalf("first-build nextBuildTime = %v, want earliestBuild %v", got, earliest)
}
}