Skip to content

Commit ecfd0e3

Browse files
committed
consensus/bor: ensure late blocks have sufficient build time
When a parent block is sealed late (actual seal time exceeds its timestamp), the next block had insufficient time to include transactions, resulting in empty blocks. Problem: - Parent block timestamp: T=2s, sealed at T=4s (2s late) - Next block gets timestamp: parent + period = 4s - Current time: 4s - Build time available: 0s → empty block Solution: Set header.Time = now + blockPeriod when scheduled time has passed, giving blocks full build time regardless of parent seal latency.
1 parent 0131c7b commit ecfd0e3

File tree

4 files changed

+277
-17
lines changed

4 files changed

+277
-17
lines changed

consensus/bor/bor.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,9 +1041,13 @@ func (c *Bor) Prepare(chain consensus.ChainHeaderReader, header *types.Header) e
10411041

10421042
now := time.Now()
10431043
if header.Time < uint64(now.Unix()) {
1044-
header.Time = uint64(now.Unix())
1044+
additionalBlockTime := time.Duration(c.config.CalculatePeriod(number)) * time.Second
1045+
if c.blockTime > 0 {
1046+
additionalBlockTime = c.blockTime
1047+
}
1048+
header.Time = uint64(now.Add(additionalBlockTime).Unix())
10451049
if c.blockTime > 0 && c.config.IsRio(header.Number) {
1046-
header.ActualTime = now
1050+
header.ActualTime = now.Add(additionalBlockTime)
10471051
}
10481052
}
10491053

consensus/bor/bor_test.go

Lines changed: 83 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -743,7 +743,7 @@ func TestCustomBlockTimeClampsToNowAlsoUpdatesActualTime(t *testing.T) {
743743

744744
addr1 := common.HexToAddress("0x1")
745745
// Force parent time far in the past so that after adding blockTime, header.Time is still < now
746-
// and the "clamp to now" block triggers.
746+
// and the "clamp to now + blockTime" block triggers.
747747
pastParentTime := time.Now().Add(-10 * time.Minute).Unix()
748748

749749
sp := &fakeSpanner{vals: []*valset.Validator{{Address: addr1, VotingPower: 1}}}
@@ -771,18 +771,27 @@ func TestCustomBlockTimeClampsToNowAlsoUpdatesActualTime(t *testing.T) {
771771

772772
require.NoError(t, err)
773773

774-
// Validate the clamp happened: header.Time should be "now-ish", not the past-derived time.
775-
require.GreaterOrEqual(t, int64(header.Time), before.Unix(), "header.Time should be clamped up to now")
776-
require.LessOrEqual(t, int64(header.Time), after.Unix()+1, "header.Time should be close to now")
774+
// With the late block fix, header.Time should be "now + blockTime", not just "now"
775+
// This gives the block builder sufficient time to include transactions
776+
expectedMinTime := before.Add(b.blockTime).Unix()
777+
expectedMaxTime := after.Add(b.blockTime).Unix() + 1 // +1 for timing tolerance
778+
779+
require.GreaterOrEqual(t, int64(header.Time), expectedMinTime,
780+
"header.Time should be at least now + blockTime to provide build time")
781+
require.LessOrEqual(t, int64(header.Time), expectedMaxTime,
782+
"header.Time should be approximately now + blockTime")
777783

778784
// Critical regression assertion:
779-
// When custom blockTime is enabled for Rio, clamping header.Time to now must also set ActualTime = now.
785+
// When custom blockTime is enabled for Rio, clamping header.Time must also set ActualTime = now + blockTime.
780786
require.False(t, header.ActualTime.IsZero(), "ActualTime should be set when blockTime > 0 and Rio is enabled")
781-
require.GreaterOrEqual(t, header.ActualTime.Unix(), before.Unix(), "ActualTime should be updated to now when clamping occurs")
782-
require.LessOrEqual(t, header.ActualTime.Unix(), after.Unix()+1, "ActualTime should be close to now when clamping occurs")
783-
784-
// Optional: since clamping sets both from the same `now`, they should match on Unix seconds.
785-
require.Equal(t, int64(header.Time), header.ActualTime.Unix(), "header.Time and ActualTime should align after clamping")
787+
require.GreaterOrEqual(t, header.ActualTime.Unix(), expectedMinTime,
788+
"ActualTime should be at least now + blockTime when clamping occurs")
789+
require.LessOrEqual(t, header.ActualTime.Unix(), expectedMaxTime,
790+
"ActualTime should be approximately now + blockTime when clamping occurs")
791+
792+
// Since clamping sets both from the same calculation, they should match on Unix seconds.
793+
require.Equal(t, int64(header.Time), header.ActualTime.Unix(),
794+
"header.Time and ActualTime should align after clamping")
786795
}
787796

788797
func TestVerifySealRejectsOversizedDifficulty(t *testing.T) {
@@ -869,3 +878,67 @@ func TestVerifySealRejectsOversizedDifficulty(t *testing.T) {
869878
diffErr.Actual, uint64(math.MaxUint64))
870879
}
871880
}
881+
882+
// TestLateBlockTimestampFix verifies that late blocks get sufficient build time
883+
// by setting header.Time = now + blockPeriod instead of just clamping to now.
884+
func TestLateBlockTimestampFix(t *testing.T) {
885+
t.Parallel()
886+
887+
addr1 := common.HexToAddress("0x1")
888+
borCfg := &params.BorConfig{
889+
Sprint: map[string]uint64{"0": 64},
890+
Period: map[string]uint64{"0": 2},
891+
}
892+
893+
t.Run("late parent gets future timestamp", func(t *testing.T) {
894+
sp := &fakeSpanner{vals: []*valset.Validator{{Address: addr1, VotingPower: 1}}}
895+
oldParentTime := time.Now().Add(-4 * time.Second).Unix()
896+
chain, b := newChainAndBorForTest(t, sp, borCfg, true, addr1, uint64(oldParentTime))
897+
898+
header := &types.Header{Number: big.NewInt(1), ParentHash: chain.HeaderChain().GetHeaderByNumber(0).Hash()}
899+
900+
before := time.Now()
901+
require.NoError(t, b.Prepare(chain.HeaderChain(), header))
902+
903+
// Should give full 2s build time from now, not from parent
904+
expectedMin := before.Add(2 * time.Second).Unix()
905+
require.GreaterOrEqual(t, int64(header.Time), expectedMin)
906+
})
907+
908+
t.Run("on-time parent uses normal calculation", func(t *testing.T) {
909+
sp := &fakeSpanner{vals: []*valset.Validator{{Address: addr1, VotingPower: 1}}}
910+
recentParentTime := time.Now().Unix()
911+
chain, b := newChainAndBorForTest(t, sp, borCfg, true, addr1, uint64(recentParentTime))
912+
913+
header := &types.Header{Number: big.NewInt(1), ParentHash: chain.HeaderChain().GetHeaderByNumber(0).Hash()}
914+
915+
require.NoError(t, b.Prepare(chain.HeaderChain(), header))
916+
917+
// Should use parent.Time + period
918+
genesis := chain.HeaderChain().GetHeaderByNumber(0)
919+
require.GreaterOrEqual(t, header.Time, genesis.Time+borCfg.Period["0"])
920+
})
921+
922+
t.Run("custom blockTime with Rio", func(t *testing.T) {
923+
sp := &fakeSpanner{vals: []*valset.Validator{{Address: addr1, VotingPower: 1}}}
924+
rioCfg := &params.BorConfig{
925+
Sprint: map[string]uint64{"0": 64},
926+
Period: map[string]uint64{"0": 2},
927+
RioBlock: big.NewInt(0),
928+
}
929+
930+
oldParentTime := time.Now().Add(-4 * time.Second).Unix()
931+
chain, b := newChainAndBorForTest(t, sp, rioCfg, true, addr1, uint64(oldParentTime))
932+
b.blockTime = 3 * time.Second
933+
934+
header := &types.Header{Number: big.NewInt(1), ParentHash: chain.HeaderChain().GetHeaderByNumber(0).Hash()}
935+
936+
before := time.Now()
937+
require.NoError(t, b.Prepare(chain.HeaderChain(), header))
938+
939+
expectedMin := before.Add(3 * time.Second).Unix()
940+
require.GreaterOrEqual(t, int64(header.Time), expectedMin)
941+
require.False(t, header.ActualTime.IsZero())
942+
require.GreaterOrEqual(t, header.ActualTime.Unix(), expectedMin)
943+
})
944+
}

tests/bor/bor_test.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2065,3 +2065,185 @@ func TestCustomBlockTimeMining(t *testing.T) {
20652065
require.LessOrEqual(t, blocksMinedCount, maxExpectedBlocks,
20662066
fmt.Sprintf("Too many blocks mined. Expected at most %d, got %d", maxExpectedBlocks, blocksMinedCount))
20672067
}
2068+
2069+
// TestLateBlockNotEmpty tests that when a parent block is sealed late,
2070+
// blocks still have sufficient time to include transactions.
2071+
// This verifies the fix for empty blocks caused by late parent blocks.
2072+
func TestLateBlockNotEmpty(t *testing.T) {
2073+
t.Parallel()
2074+
log.SetDefault(log.NewLogger(log.NewTerminalHandlerWithLevel(os.Stderr, log.LevelInfo, true)))
2075+
fdlimit.Raise(2048)
2076+
2077+
faucets := make([]*ecdsa.PrivateKey, 128)
2078+
for i := 0; i < len(faucets); i++ {
2079+
faucets[i], _ = crypto.GenerateKey()
2080+
}
2081+
2082+
genesis := InitGenesis(t, faucets, "./testdata/genesis_2val.json", 16)
2083+
genesis.Config.Bor.Period = map[string]uint64{"0": 2}
2084+
genesis.Config.Bor.Sprint = map[string]uint64{"0": 16}
2085+
genesis.Config.Bor.RioBlock = big.NewInt(0)
2086+
2087+
// Start a single miner node
2088+
stack, ethBackend, err := InitMiner(genesis, keys[0], true)
2089+
require.NoError(t, err)
2090+
defer stack.Close()
2091+
2092+
for stack.Server().NodeInfo().Ports.Listener == 0 {
2093+
time.Sleep(250 * time.Millisecond)
2094+
}
2095+
2096+
// Start mining
2097+
err = ethBackend.StartMining()
2098+
require.NoError(t, err)
2099+
2100+
// Wait for initial blocks
2101+
log.Info("Waiting for initial blocks...")
2102+
for {
2103+
time.Sleep(500 * time.Millisecond)
2104+
if ethBackend.BlockChain().CurrentBlock().Number.Uint64() >= 3 {
2105+
break
2106+
}
2107+
}
2108+
2109+
// Stop mining and wait for it to fully stop
2110+
ethBackend.StopMining()
2111+
time.Sleep(500 * time.Millisecond)
2112+
2113+
// Capture parent block
2114+
parentBlock := ethBackend.BlockChain().CurrentBlock()
2115+
parentNumber := parentBlock.Number.Uint64()
2116+
parentTime := parentBlock.Time
2117+
2118+
log.Info("Parent block", "number", parentNumber, "time", parentTime)
2119+
2120+
// Add transactions BEFORE waiting (they should be in pool when we resume)
2121+
txpool := ethBackend.TxPool()
2122+
senderKey := pkey1
2123+
senderAddr := crypto.PubkeyToAddress(senderKey.PublicKey)
2124+
recipientAddr := crypto.PubkeyToAddress(pkey2.PublicKey)
2125+
nonce := txpool.Nonce(senderAddr)
2126+
signer := types.LatestSignerForChainID(genesis.Config.ChainID)
2127+
2128+
// Start goroutine to continuously add transactions
2129+
stopTxs := make(chan struct{})
2130+
txNonce := nonce
2131+
go func() {
2132+
ticker := time.NewTicker(100 * time.Millisecond)
2133+
defer ticker.Stop()
2134+
for {
2135+
select {
2136+
case <-stopTxs:
2137+
return
2138+
case <-ticker.C:
2139+
// Add 5 transactions every 100ms
2140+
for i := 0; i < 5; i++ {
2141+
tx := types.NewTransaction(
2142+
txNonce,
2143+
recipientAddr,
2144+
big.NewInt(1000),
2145+
21000,
2146+
big.NewInt(30000000000),
2147+
nil,
2148+
)
2149+
signedTx, _ := types.SignTx(tx, signer, senderKey)
2150+
if txpool.Add([]*types.Transaction{signedTx}, true)[0] == nil {
2151+
txNonce++
2152+
}
2153+
}
2154+
}
2155+
}
2156+
}()
2157+
defer close(stopTxs)
2158+
2159+
// Wait a bit for initial transactions to be added
2160+
time.Sleep(300 * time.Millisecond)
2161+
2162+
pending, queued := txpool.Stats()
2163+
log.Info("Txpool after initial txs", "pending", pending, "queued", queued)
2164+
require.Greater(t, pending, 0, "Expected transactions in pending")
2165+
2166+
// Wait for parent to become older than block period (simulates late parent)
2167+
blockPeriod := time.Duration(genesis.Config.Bor.Period["0"]) * time.Second
2168+
2169+
// Wait until parent age > blockPeriod (not just equal)
2170+
var parentAge int64
2171+
for {
2172+
parentBlock = ethBackend.BlockChain().CurrentBlock()
2173+
parentNumber = parentBlock.Number.Uint64()
2174+
parentTime = parentBlock.Time
2175+
parentAge = time.Now().Unix() - int64(parentTime)
2176+
2177+
if parentAge > int64(blockPeriod.Seconds()) {
2178+
log.Info("Parent is now late", "number", parentNumber, "age", parentAge, "blockPeriod", blockPeriod.Seconds())
2179+
break
2180+
}
2181+
2182+
time.Sleep(100 * time.Millisecond)
2183+
}
2184+
2185+
// Resume mining
2186+
err = ethBackend.StartMining()
2187+
require.NoError(t, err)
2188+
2189+
// Wait for blocks to be mined and check that they ALL contain transactions
2190+
log.Info("Waiting for blocks after resume...")
2191+
blocksToCheck := uint64(3)
2192+
maxWait := 10 * time.Second
2193+
deadline := time.Now().Add(maxWait)
2194+
allBlocksChecked := false
2195+
2196+
var currentNumber uint64
2197+
for time.Now().Before(deadline) {
2198+
time.Sleep(500 * time.Millisecond)
2199+
currentNumber = ethBackend.BlockChain().CurrentBlock().Number.Uint64()
2200+
2201+
if currentNumber >= parentNumber+blocksToCheck {
2202+
allBlocksChecked = true
2203+
break
2204+
}
2205+
}
2206+
2207+
require.True(t, allBlocksChecked, "Expected %d blocks to be mined", blocksToCheck)
2208+
2209+
// Verify ALL blocks after parent contain transactions
2210+
totalTxsInBlocks := 0
2211+
actualNow := time.Now().Unix()
2212+
parentAge = actualNow - int64(parentTime) // Update parent age for error messages
2213+
2214+
for i := uint64(1); i <= blocksToCheck; i++ {
2215+
block := ethBackend.BlockChain().GetBlockByNumber(parentNumber + i)
2216+
require.NotNil(t, block)
2217+
txCount := len(block.Transactions())
2218+
totalTxsInBlocks += txCount
2219+
2220+
// Calculate expected build time
2221+
expectedMinTime := int64(parentTime) + int64(blockPeriod.Seconds())
2222+
actualBuildTime := int64(block.Time()) - actualNow
2223+
timeFromParent := int64(block.Time()) - int64(parentTime)
2224+
2225+
log.Info("Block check",
2226+
"number", block.Number().Uint64(),
2227+
"txCount", txCount,
2228+
"blockTime", block.Time(),
2229+
"actualNow", actualNow,
2230+
"buildTime", actualBuildTime,
2231+
"timeFromParent", timeFromParent,
2232+
"expectedMin", expectedMinTime)
2233+
2234+
// KEY ASSERTION: With the fix, ALL blocks should contain transactions
2235+
// when there are pending transactions in the pool
2236+
require.Greater(t, txCount, 0,
2237+
"Block %d is empty! With late block fix, all blocks should include "+
2238+
"transactions when txpool has pending txs. Parent age was %d seconds. "+
2239+
"Block time: %d, Now: %d, Build time available: %d seconds.",
2240+
block.Number().Uint64(), parentAge, block.Time(), actualNow, actualBuildTime)
2241+
}
2242+
2243+
log.Info("SUCCESS: All blocks after late parent contain transactions",
2244+
"blocksChecked", blocksToCheck,
2245+
"totalTxs", totalTxsInBlocks,
2246+
"parentAge", parentAge)
2247+
2248+
ethBackend.StopMining()
2249+
}

tests/bor/helper.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -607,11 +607,12 @@ func InitMinerWithBlockTime(genesis *core.Genesis, privKey *ecdsa.PrivateKey, wi
607607
TxPool: legacypool.DefaultConfig,
608608
GPO: ethconfig.Defaults.GPO,
609609
Miner: miner.Config{
610-
Etherbase: crypto.PubkeyToAddress(privKey.PublicKey),
611-
GasCeil: genesis.GasLimit * 11 / 10,
612-
GasPrice: big.NewInt(1),
613-
Recommit: time.Second,
614-
BlockTime: blockTime,
610+
Etherbase: crypto.PubkeyToAddress(privKey.PublicKey),
611+
GasCeil: genesis.GasLimit * 11 / 10,
612+
GasPrice: big.NewInt(1),
613+
Recommit: time.Second,
614+
BlockTime: blockTime,
615+
CommitInterruptFlag: true,
615616
},
616617
WithoutHeimdall: withoutHeimdall,
617618
})

0 commit comments

Comments
 (0)