diff --git a/consensus/bor/bor.go b/consensus/bor/bor.go index ec2dfcd79f..e304fbac44 100644 --- a/consensus/bor/bor.go +++ b/consensus/bor/bor.go @@ -106,6 +106,10 @@ var ( // the gas target or base fee change denominator in its extra data. errMissingGiuglianoFields = errors.New("missing gas target or base fee change denominator in extra data") + // errMissingTimeNano is returned if a post-Placeholder block is missing + // the nanosecond-precision timestamp in its extra data. + errMissingTimeNano = errors.New("missing time nano in extra data") + // errInvalidMixDigest is returned if a block's mix digest is non-zero. errInvalidMixDigest = errors.New("non-zero mix digest") @@ -504,6 +508,14 @@ func (c *Bor) verifyHeader(chain consensus.ChainHeaderReader, header *types.Head } } + // Post-Placeholder: verify that nanosecond-precision timestamp is present. + if c.config.IsPlaceholder(header.Number) { + timeNano := header.GetTimeNano(c.chainConfig) + if timeNano == nil { + return errMissingTimeNano + } + } + // Ensure that the mix digest is zero as we don't have fork protection currently if header.MixDigest != (common.Hash{}) { return errInvalidMixDigest @@ -1019,6 +1031,16 @@ func (c *Bor) setGiuglianoExtraFields(header *types.Header, parent *types.Header } } +// setTimeNano sets the nanosecond-precision block timestamp in BlockExtraData. +// Only set for Placeholder+ blocks where it is consensus-validated. +// Must be called after header.Time/ActualTime are finalized. +func (c *Bor) setTimeNano(header *types.Header, blockExtraData *types.BlockExtraData) { + if c.config.IsPlaceholder(header.Number) { + timeNano := uint64(header.GetActualTime().UnixNano()) + blockExtraData.TimeNano = &timeNano + } +} + // Prepare implements consensus.Engine, preparing all the consensus fields of the // header for running the transactions on top. func (c *Bor) Prepare(chain consensus.ChainHeaderReader, header *types.Header, waitOnPrepare bool) error { @@ -1051,6 +1073,59 @@ func (c *Bor) Prepare(chain consensus.ChainHeaderReader, header *types.Header, w return consensus.ErrUnknownAncestor } + // Calculate succession — needed for timestamp calculation + var succession int + if currentSigner.signer != (common.Address{}) { + succession, err = snap.GetSignerSuccessionNumber(currentSigner.signer) + if err != nil { + // If the signer is not in the active validator set, use succession 0 + // so that the pending block header is still valid for RPC queries. + // Seal() will independently reject the block if unauthorized. + succession = 0 + } + } + + // Validate custom block time configuration + if c.blockTime > 0 && uint64(c.blockTime.Seconds()) < c.config.CalculatePeriod(number) { + return fmt.Errorf("the floor of custom mining block time (%v) is less than the consensus block time: %v < %v", c.blockTime, c.blockTime.Seconds(), c.config.CalculatePeriod(number)) + } + + // Calculate header.Time and header.ActualTime early so they're available for BlockExtraData.TimeNano + var delay time.Duration + if c.blockTime > 0 && c.config.IsRio(header.Number) { + // Only enable custom block time for Rio and later + parentBlockTime := time.Unix(int64(parent.Time), 0) + parentActualBlockTime := parentBlockTime + if c.parentActualTimeCache != nil { + if v, ok := c.parentActualTimeCache.Get(header.ParentHash); ok { + if at, ok := v.(time.Time); ok && at.After(parentBlockTime) { + parentActualBlockTime = at + } + } + } + actualNewBlockTime := parentActualBlockTime.Add(c.blockTime) + header.Time = uint64(actualNewBlockTime.Unix()) + header.ActualTime = actualNewBlockTime + delay = time.Until(parentActualBlockTime) + } else { + header.Time = parent.Time + CalcProducerDelay(number, succession, c.config) + delay = time.Until(time.Unix(int64(parent.Time), 0)) + } + + now := time.Now() + blockTime := time.Duration(c.config.CalculatePeriod(number)) * time.Second + if c.blockTime > 0 && c.config.IsRio(header.Number) { + blockTime = c.blockTime + } + // Ensure minimum build time so the block has enough time to include transactions. + if time.Until(header.GetActualTime()) < minBlockBuildTime { + header.Time = uint64(now.Add(blockTime).Unix()) + belowMinBuildTimeCounter.Inc(1) + if c.blockTime > 0 && c.config.IsRio(header.Number) { + header.ActualTime = now.Add(blockTime) + } + } + // get validator set if number if IsSprintStart(number+1, c.config.CalculateSprint(number)) && !c.config.IsRio(header.Number) { newValidators, err := c.spanner.GetCurrentValidatorsByHash(context.Background(), header.ParentHash, number+1) @@ -1074,6 +1149,7 @@ func (c *Bor) Prepare(chain consensus.ChainHeaderReader, header *types.Header, w } c.setGiuglianoExtraFields(header, parent, blockExtraData) + c.setTimeNano(header, blockExtraData) blockExtraDataBytes, err := rlp.EncodeToBytes(blockExtraData) if err != nil { @@ -1094,6 +1170,7 @@ func (c *Bor) Prepare(chain consensus.ChainHeaderReader, header *types.Header, w } c.setGiuglianoExtraFields(header, parent, blockExtraData) + c.setTimeNano(header, blockExtraData) blockExtraDataBytes, err := rlp.EncodeToBytes(blockExtraData) if err != nil { @@ -1110,64 +1187,6 @@ func (c *Bor) Prepare(chain consensus.ChainHeaderReader, header *types.Header, w // Mix digest is reserved for now, set to empty header.MixDigest = common.Hash{} - // Ensure the timestamp has the correct delay - var succession int - // if signer is not empty - if currentSigner.signer != (common.Address{}) { - succession, err = snap.GetSignerSuccessionNumber(currentSigner.signer) - if err != nil { - // If the signer is not in the active validator set, use succession 0 - // so that the pending block header is still valid for RPC queries. - // Seal() will independently reject the block if unauthorized. - succession = 0 - } - } - - if c.blockTime > 0 && uint64(c.blockTime.Seconds()) < c.config.CalculatePeriod(number) { - return fmt.Errorf("the floor of custom mining block time (%v) is less than the consensus block time: %v < %v", c.blockTime, c.blockTime.Seconds(), c.config.CalculatePeriod(number)) - } - - var delay time.Duration - - if c.blockTime > 0 && c.config.IsRio(header.Number) { - // Only enable custom block time for Rio and later - - parentBlockTime := time.Unix(int64(parent.Time), 0) - // Default to parent block timestamp - parentActualBlockTime := parentBlockTime - // If we have the parent's ActualTime locally (by parent hash), prefer it - if c.parentActualTimeCache != nil { - if v, ok := c.parentActualTimeCache.Get(header.ParentHash); ok { - if at, ok := v.(time.Time); ok && at.After(parentBlockTime) { - parentActualBlockTime = at - } - } - } - actualNewBlockTime := parentActualBlockTime.Add(c.blockTime) - header.Time = uint64(actualNewBlockTime.Unix()) - header.ActualTime = actualNewBlockTime - delay = time.Until(parentActualBlockTime) - } else { - header.Time = parent.Time + CalcProducerDelay(number, succession, c.config) - delay = time.Until(time.Unix(int64(parent.Time), 0)) - } - - now := time.Now() - blockTime := time.Duration(c.config.CalculatePeriod(number)) * time.Second - if c.blockTime > 0 && c.config.IsRio(header.Number) { - blockTime = c.blockTime - } - // Ensure minimum build time so the block has enough time to include transactions. - // The interrupt timer reserves 500ms for state root computation, so without - // sufficient remaining time the block would end up empty. - if time.Until(header.GetActualTime()) < minBlockBuildTime { - header.Time = uint64(now.Add(blockTime).Unix()) - belowMinBuildTimeCounter.Inc(1) - if c.blockTime > 0 && c.config.IsRio(header.Number) { - header.ActualTime = now.Add(blockTime) - } - } - // Wait before start the block production if needed (previously this wait was on Seal) if c.config.IsGiugliano(header.Number) && waitOnPrepare { // if signer is not empty (RPC nodes have empty signer) diff --git a/consensus/bor/bor_test.go b/consensus/bor/bor_test.go index 92fed881e6..da553519cf 100644 --- a/consensus/bor/bor_test.go +++ b/consensus/bor/bor_test.go @@ -5705,6 +5705,234 @@ func TestVerifyHeader_PreGiugliano_NoCheck(t *testing.T) { } } +// placeholderBorConfig returns a BorConfig with Giugliano and Placeholder enabled at genesis. +func placeholderBorConfig() *params.BorConfig { + return ¶ms.BorConfig{ + Sprint: map[string]uint64{"0": 16}, + Period: map[string]uint64{"0": 2}, + ProducerDelay: map[string]uint64{"0": 4}, + BackupMultiplier: map[string]uint64{"0": 2}, + GiuglianoBlock: big.NewInt(0), + PlaceholderBlock: big.NewInt(0), + } +} + +// placeholderChainConfig returns a ChainConfig with all forks + Cancun + Giugliano + Placeholder enabled. +func placeholderChainConfig(borCfg *params.BorConfig) *params.ChainConfig { + return giuglianoChainConfig(borCfg) +} + +// newPlaceholderBorForTest creates a chain and Bor engine with Placeholder enabled/disabled. +func newPlaceholderBorForTest(t *testing.T, placeholder bool) (*core.BlockChain, *Bor, *params.ChainConfig) { + t.Helper() + addr1 := common.HexToAddress("0x1") + sp := &fakeSpanner{vals: []*valset.Validator{{Address: addr1, VotingPower: 1}}} + + var cfg *params.ChainConfig + if placeholder { + cfg = placeholderChainConfig(placeholderBorConfig()) + } else { + cfg = giuglianoChainConfig(giuglianoBorConfig()) + } + + chain, b := newChainAndBorForTestWithConfig(t, sp, cfg, true, addr1, uint64(time.Now().Unix())-200) + return chain, b, cfg +} + +func TestPrepare_PlaceholderTimeNano(t *testing.T) { + t.Parallel() + chain, b, cfg := newPlaceholderBorForTest(t, true) + genesis := chain.HeaderChain().GetHeaderByNumber(0) + + h := &types.Header{ + ParentHash: genesis.Hash(), + Number: big.NewInt(1), + GasLimit: genesis.GasLimit, + UncleHash: uncleHash, + } + + err := b.Prepare(chain.HeaderChain(), h, false) + require.NoError(t, err) + + timeNano := h.GetTimeNano(cfg) + require.NotNil(t, timeNano, "TimeNano should be present for Placeholder blocks") + + // TimeNano should match header.GetActualTime().UnixNano() + // In the non-Rio path, ActualTime is not set so GetActualTime falls back to Time + expectedTimeNano := uint64(h.GetActualTime().UnixNano()) + require.Equal(t, expectedTimeNano, *timeNano, + "TimeNano should equal header.GetActualTime().UnixNano()") +} + +// TestTimeNano_PreservesNanoseconds verifies that TimeNano correctly preserves +// nanosecond precision through encode/decode roundtrip. This tests the core +// mechanism independently of the Prepare code path. +func TestTimeNano_PreservesNanoseconds(t *testing.T) { + t.Parallel() + s := newPlaceholderVerifySetup(t, true) + + // Create a TimeNano with non-zero nanoseconds (123456789 ns after the second) + baseSeconds := uint64(1700000000) + nanoseconds := uint64(123456789) + timeNanoValue := baseSeconds*1_000_000_000 + nanoseconds + + gasTarget := uint64(15_000_000) + bfcd := uint64(64) + extra := buildBlockExtraBytes(&types.BlockExtraData{ + GasTarget: &gasTarget, + BaseFeeChangeDenominator: &bfcd, + TimeNano: &timeNanoValue, + }) + h := s.makeSignedChild(t, extra, big.NewInt(params.InitialBaseFee)) + + // Verify TimeNano roundtrips correctly with full nanosecond precision + decoded := h.GetTimeNano(s.cfg) + require.NotNil(t, decoded, "TimeNano should decode successfully") + require.Equal(t, timeNanoValue, *decoded, "TimeNano should preserve exact nanosecond value") + + // Verify the nanosecond component is preserved (not truncated to seconds) + decodedNanos := *decoded % 1_000_000_000 + require.Equal(t, nanoseconds, decodedNanos, + "TimeNano should preserve the nanosecond component, got %d, want %d", decodedNanos, nanoseconds) +} + +func TestPrepare_PrePlaceholder_NoTimeNano(t *testing.T) { + t.Parallel() + // Giugliano enabled but Placeholder not enabled + chain, b, cfg := newPlaceholderBorForTest(t, false) + genesis := chain.HeaderChain().GetHeaderByNumber(0) + + h := &types.Header{ + ParentHash: genesis.Hash(), + Number: big.NewInt(1), + GasLimit: genesis.GasLimit, + UncleHash: uncleHash, + } + + err := b.Prepare(chain.HeaderChain(), h, false) + require.NoError(t, err) + + timeNano := h.GetTimeNano(cfg) + require.Nil(t, timeNano, "TimeNano should be nil for pre-Placeholder blocks") + + // But Giugliano fields should still be present + gasTarget, bfcd := h.GetBaseFeeParams(cfg) + require.NotNil(t, gasTarget, "GasTarget should be present for Giugliano blocks") + require.NotNil(t, bfcd, "BaseFeeChangeDenominator should be present for Giugliano blocks") +} + +func TestVerifyHeader_PlaceholderMissingTimeNano(t *testing.T) { + t.Parallel() + s := newPlaceholderVerifySetup(t, true) + + // Build a header with Giugliano fields but no TimeNano + gasTarget := uint64(15_000_000) + bfcd := uint64(64) + extra := buildBlockExtraBytes(&types.BlockExtraData{ + GasTarget: &gasTarget, + BaseFeeChangeDenominator: &bfcd, + // TimeNano intentionally omitted + }) + h := s.makeSignedChild(t, extra, big.NewInt(params.InitialBaseFee)) + + chain := newRawDBChain(s.db, s.cfg, h, nil, nil) + err := s.b.verifyHeader(chain, h, nil) + require.ErrorIs(t, err, errMissingTimeNano) +} + +func TestVerifyHeader_PlaceholderTimeNanoPresent(t *testing.T) { + t.Parallel() + s := newPlaceholderVerifySetup(t, true) + + gasTarget := uint64(15_000_000) + bfcd := uint64(64) + timeNano := uint64(1700000000_000_000_000) // example nanosecond timestamp + extra := buildBlockExtraBytes(&types.BlockExtraData{ + GasTarget: &gasTarget, + BaseFeeChangeDenominator: &bfcd, + TimeNano: &timeNano, + }) + h := s.makeSignedChild(t, extra, big.NewInt(params.InitialBaseFee)) + + chain := newRawDBChain(s.db, s.cfg, h, nil, nil) + err := s.b.verifyHeader(chain, h, nil) + // Should not fail with errMissingTimeNano + if err != nil { + require.NotErrorIs(t, err, errMissingTimeNano) + } +} + +// placeholderVerifySetup holds shared state for verifyHeader Placeholder tests. +type placeholderVerifySetup struct { + b *Bor + borCfg *params.BorConfig + cfg *params.ChainConfig + privKey *ecdsa.PrivateKey + db ethdb.Database + genesis *types.Header +} + +// newPlaceholderVerifySetup creates a Bor engine with Placeholder enabled for verifyHeader tests. +func newPlaceholderVerifySetup(t *testing.T, placeholder bool) *placeholderVerifySetup { + t.Helper() + privKey, _ := crypto.GenerateKey() + signerAddr := crypto.PubkeyToAddress(privKey.PublicKey) + + var borCfg *params.BorConfig + var cfg *params.ChainConfig + if placeholder { + borCfg = placeholderBorConfig() + cfg = placeholderChainConfig(borCfg) + } else { + borCfg = giuglianoBorConfig() + cfg = giuglianoChainConfig(borCfg) + } + + sp := &fakeSpanner{vals: []*valset.Validator{{Address: signerAddr, VotingPower: 1000}}} + _, b := newChainAndBorForTestWithConfig(t, sp, cfg, false, signerAddr, uint64(time.Now().Unix())-200) + + db := rawdb.NewMemoryDatabase() + genesisTime := uint64(time.Now().Unix()) - 200 + + genesis := &types.Header{ + Number: big.NewInt(0), + Time: genesisTime, + GasLimit: 8_000_000, + BaseFee: big.NewInt(params.InitialBaseFee), + Difficulty: big.NewInt(1), + Extra: make([]byte, types.ExtraVanityLength+types.ExtraSealLength), + } + sigHash := SealHash(genesis, borCfg) + sig, _ := crypto.Sign(sigHash.Bytes(), privKey) + copy(genesis.Extra[len(genesis.Extra)-types.ExtraSealLength:], sig) + + rawdb.WriteHeader(db, genesis) + rawdb.WriteCanonicalHash(db, genesis.Hash(), 0) + + return &placeholderVerifySetup{b: b, borCfg: borCfg, cfg: cfg, privKey: privKey, db: db, genesis: genesis} +} + +func (s *placeholderVerifySetup) makeSignedChild(t *testing.T, extra []byte, baseFee *big.Int) *types.Header { + t.Helper() + h := &types.Header{ + ParentHash: s.genesis.Hash(), + Number: big.NewInt(1), + Time: s.genesis.Time + s.borCfg.Period["0"], + GasLimit: 8_000_000, + BaseFee: baseFee, + Difficulty: big.NewInt(1), + Extra: extra, + UncleHash: uncleHash, + } + sigHash := SealHash(h, s.borCfg) + sig, _ := crypto.Sign(sigHash.Bytes(), s.privKey) + copy(h.Extra[len(h.Extra)-types.ExtraSealLength:], sig) + + rawdb.WriteHeader(s.db, h) + rawdb.WriteCanonicalHash(s.db, h.Hash(), 1) + return h +} + // TestApplyMessage_StateSyncTxContext validates if TxContext is correctly // set for state-sync transactions. func TestApplyMessage_StateSyncTxContext(t *testing.T) { diff --git a/core/types/block.go b/core/types/block.go index c47c564dd0..b95a37bb7c 100644 --- a/core/types/block.go +++ b/core/types/block.go @@ -134,6 +134,8 @@ type BlockExtraData struct { GasTarget *uint64 `rlp:"optional"` // BaseFeeChangeDenominator is the EIP-1559 base fee change denominator used by the block producer (post-Giugliano) BaseFeeChangeDenominator *uint64 `rlp:"optional"` + // TimeNano is the nanosecond-precision Unix timestamp when the block was prepared (post-Chicago) + TimeNano *uint64 `rlp:"optional"` } // field type overrides for gencodec @@ -563,6 +565,28 @@ func (h *Header) DecodeBlockExtraData(chainConfig *params.ChainConfig) *BlockExt return &blockExtraData } +// GetTimeNano extracts the nanosecond-precision block timestamp from the +// header's Extra field. Returns nil for pre-Cancun blocks or if TimeNano +// is not set. TimeNano is consensus-validated starting from the Chicago fork. +// If you need multiple fields from BlockExtraData, prefer DecodeBlockExtraData +// to avoid redundant RLP decodes. +func (h *Header) GetTimeNano(chainConfig *params.ChainConfig) *uint64 { + if !chainConfig.IsCancun(h.Number) { + return nil + } + + if len(h.Extra) < ExtraVanityLength+ExtraSealLength { + return nil + } + + var blockExtraData BlockExtraData + if err := rlp.DecodeBytes(h.Extra[ExtraVanityLength:len(h.Extra)-ExtraSealLength], &blockExtraData); err != nil { + return nil + } + + return blockExtraData.TimeNano +} + func (b *Block) BaseFee() *big.Int { if b.header.BaseFee == nil { return nil diff --git a/core/vm/contracts_test.go b/core/vm/contracts_test.go index 612a677f1c..da1f8fb7bd 100644 --- a/core/vm/contracts_test.go +++ b/core/vm/contracts_test.go @@ -540,6 +540,7 @@ func TestReinforceMultiClientPreCompilesTest(t *testing.T) { "IsLisovo", "IsLisovoPro", "IsChicago", + "IsPlaceholder", } if len(actual) != len(expected) { diff --git a/params/config.go b/params/config.go index 2b35b885f1..82ad4c4111 100644 --- a/params/config.go +++ b/params/config.go @@ -740,7 +740,9 @@ var ( DandeliBlock: big.NewInt(0), LisovoBlock: big.NewInt(0), LisovoProBlock: big.NewInt(0), + GiuglianoBlock: big.NewInt(0), ChicagoBlock: big.NewInt(0), + PlaceholderBlock: big.NewInt(0), }, } @@ -958,6 +960,7 @@ type BorConfig struct { LisovoProBlock *big.Int `json:"lisovoProBlock"` // LisovoPro switch block (nil = no fork, 0 = already on lisovoPro) GiuglianoBlock *big.Int `json:"giuglianoBlock"` // Giugliano switch block (nil = no fork, 0 = already on giugliano) ChicagoBlock *big.Int `json:"chicagoBlock"` // Chicago switch block (nil = no fork, 0 = already on chicago) + PlaceholderBlock *big.Int `json:"placeholderBlock"` // Placeholder switch block (nil = no fork, 0 = already on placeholder) - TEMP NAME } // String implements the stringer interface, returning the consensus engine details. @@ -1037,6 +1040,10 @@ func (c *BorConfig) IsChicago(number *big.Int) bool { return isBlockForked(c.ChicagoBlock, number) } +func (c *BorConfig) IsPlaceholder(number *big.Int) bool { + return isBlockForked(c.PlaceholderBlock, number) +} + // GetTargetGasPercentage returns the target gas percentage for gas limit calculation. // After Lisovo hard fork, this value can be configured via CLI flags (stored in BorConfig at runtime). // It validates the configured value and falls back to defaults if invalid or nil. @@ -1246,7 +1253,10 @@ func (c *ChainConfig) Description() string { banner += fmt.Sprintf(" - Giugliano: #%-8v\n", c.Bor.GiuglianoBlock) } if c.Bor.ChicagoBlock != nil { - banner += fmt.Sprintf(" - Chicago: #%-8v\n", c.Bor.ChicagoBlock) + banner += fmt.Sprintf(" - Chicago: #%-8v\n", c.Bor.ChicagoBlock) + } + if c.Bor.PlaceholderBlock != nil { + banner += fmt.Sprintf(" - Placeholder: #%-8v\n", c.Bor.PlaceholderBlock) } return banner } @@ -1887,6 +1897,7 @@ type Rules struct { IsLisovo bool IsLisovoPro bool IsChicago bool + IsPlaceholder bool } // Rules ensures c's ChainID is not nil. @@ -1923,5 +1934,6 @@ func (c *ChainConfig) Rules(num *big.Int, isMerge bool, _ uint64) Rules { IsLisovo: c.Bor != nil && c.Bor.IsLisovo(num), IsLisovoPro: c.Bor != nil && c.Bor.IsLisovoPro(num), IsChicago: c.Bor != nil && c.Bor.IsChicago(num), + IsPlaceholder: c.Bor != nil && c.Bor.IsPlaceholder(num), } }