diff --git a/consensus/bor/bor.go b/consensus/bor/bor.go index e304fbac44..cb0d6fa152 100644 --- a/consensus/bor/bor.go +++ b/consensus/bor/bor.go @@ -110,6 +110,10 @@ var ( // the nanosecond-precision timestamp in its extra data. errMissingTimeNano = errors.New("missing time nano in extra data") + // errMissingSealTimings is returned if a post-Placeholder block is missing + // the producer's commit-sealing timings (elapsed/finalize) in its extra data. + errMissingSealTimings = errors.New("missing seal timings in extra data") + // errInvalidMixDigest is returned if a block's mix digest is non-zero. errInvalidMixDigest = errors.New("non-zero mix digest") @@ -514,6 +518,14 @@ func (c *Bor) verifyHeader(chain consensus.ChainHeaderReader, header *types.Head if timeNano == nil { return errMissingTimeNano } + + // Verify that the producer's commit-sealing timings are present. As with + // TimeNano and the Giugliano fields, we only check presence, not the values, + // since they are per-producer and not consensus-deterministic. + elapsedNano, finalizeNano := header.GetSealTimings(c.chainConfig) + if elapsedNano == nil || finalizeNano == nil { + return errMissingSealTimings + } } // Ensure that the mix digest is zero as we don't have fork protection currently diff --git a/consensus/bor/bor_test.go b/consensus/bor/bor_test.go index da553519cf..4d441ab802 100644 --- a/consensus/bor/bor_test.go +++ b/consensus/bor/bor_test.go @@ -5862,6 +5862,53 @@ func TestVerifyHeader_PlaceholderTimeNanoPresent(t *testing.T) { } } +func TestVerifyHeader_PlaceholderMissingSealTimings(t *testing.T) { + t.Parallel() + s := newPlaceholderVerifySetup(t, true) + + // TimeNano present but seal timings omitted. + gasTarget := uint64(15_000_000) + bfcd := uint64(64) + timeNano := uint64(1700000000_000_000_000) + extra := buildBlockExtraBytes(&types.BlockExtraData{ + GasTarget: &gasTarget, + BaseFeeChangeDenominator: &bfcd, + TimeNano: &timeNano, + // SealElapsedNano / SealFinalizeNano 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, errMissingSealTimings) +} + +func TestVerifyHeader_PlaceholderSealTimingsPresent(t *testing.T) { + t.Parallel() + s := newPlaceholderVerifySetup(t, true) + + gasTarget := uint64(15_000_000) + bfcd := uint64(64) + timeNano := uint64(1700000000_000_000_000) + elapsedNano := uint64(2_648_000) + finalizeNano := uint64(1_500_000) + extra := buildBlockExtraBytes(&types.BlockExtraData{ + GasTarget: &gasTarget, + BaseFeeChangeDenominator: &bfcd, + TimeNano: &timeNano, + SealElapsedNano: &elapsedNano, + SealFinalizeNano: &finalizeNano, + }) + 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 for missing seal timings (other unrelated checks may still error). + if err != nil { + require.NotErrorIs(t, err, errMissingSealTimings) + } +} + // placeholderVerifySetup holds shared state for verifyHeader Placeholder tests. type placeholderVerifySetup struct { b *Bor diff --git a/core/types/block.go b/core/types/block.go index b95a37bb7c..4b31fd421b 100644 --- a/core/types/block.go +++ b/core/types/block.go @@ -136,6 +136,15 @@ type BlockExtraData struct { BaseFeeChangeDenominator *uint64 `rlp:"optional"` // TimeNano is the nanosecond-precision Unix timestamp when the block was prepared (post-Chicago) TimeNano *uint64 `rlp:"optional"` + // SealElapsedNano is the producer's total commit-sealing time in nanoseconds + // (transaction processing + FinalizeAndAssemble). It mirrors the "elapsed" field + // of the "Commit new sealing work" log. The value is per-producer and not + // consensus-deterministic, so only its presence is validated (post-Placeholder). + SealElapsedNano *uint64 `rlp:"optional"` + // SealFinalizeNano is the FinalizeAndAssemble duration in nanoseconds. It mirrors + // the "finalize" field of the "Commit new sealing work" log. Like SealElapsedNano, + // only its presence is validated (post-Placeholder). + SealFinalizeNano *uint64 `rlp:"optional"` } // field type overrides for gencodec @@ -587,6 +596,56 @@ func (h *Header) GetTimeNano(chainConfig *params.ChainConfig) *uint64 { return blockExtraData.TimeNano } +// GetSealTimings extracts the producer's commit-sealing timings (elapsed and +// finalize durations, in nanoseconds) from the header's Extra field. Either +// value is nil for pre-Cancun blocks, on decode error, or if the producer did +// not set it. Seal timings are presence-validated starting from the Placeholder +// fork. If you need multiple fields from BlockExtraData, prefer DecodeBlockExtraData +// to avoid redundant RLP decodes. +func (h *Header) GetSealTimings(chainConfig *params.ChainConfig) (elapsedNano *uint64, finalizeNano *uint64) { + blockExtraData := h.DecodeBlockExtraData(chainConfig) + if blockExtraData == nil { + return nil, nil + } + + return blockExtraData.SealElapsedNano, blockExtraData.SealFinalizeNano +} + +// SetSealTimings writes the producer's commit-sealing timings into the header's +// already-encoded BlockExtraData. The header's Extra must be in the post-Cancun +// [vanity | RLP(BlockExtraData) | seal] layout; it is decoded, the timing fields +// are set, and Extra is re-encoded in place. This is a producer-only path — the +// values are not consensus-validated, only their presence. +func (h *Header) SetSealTimings(elapsedNano, finalizeNano uint64) error { + if len(h.Extra) < ExtraVanityLength+ExtraSealLength { + return fmt.Errorf("invalid extra data length %d, cannot set seal timings", len(h.Extra)) + } + + vanity := h.Extra[:ExtraVanityLength] + seal := h.Extra[len(h.Extra)-ExtraSealLength:] + + var blockExtraData BlockExtraData + if err := rlp.DecodeBytes(h.Extra[ExtraVanityLength:len(h.Extra)-ExtraSealLength], &blockExtraData); err != nil { + return fmt.Errorf("failed to decode block extra data: %w", err) + } + + blockExtraData.SealElapsedNano = &elapsedNano + blockExtraData.SealFinalizeNano = &finalizeNano + + blockExtraDataBytes, err := rlp.EncodeToBytes(&blockExtraData) + if err != nil { + return fmt.Errorf("failed to encode block extra data: %w", err) + } + + extra := make([]byte, 0, ExtraVanityLength+len(blockExtraDataBytes)+ExtraSealLength) + extra = append(extra, vanity...) + extra = append(extra, blockExtraDataBytes...) + extra = append(extra, seal...) + h.Extra = extra + + return nil +} + func (b *Block) BaseFee() *big.Int { if b.header.BaseFee == nil { return nil diff --git a/core/types/block_test.go b/core/types/block_test.go index a9100a3358..2fb183c9a9 100644 --- a/core/types/block_test.go +++ b/core/types/block_test.go @@ -863,6 +863,76 @@ func TestGetBaseFeeParams(t *testing.T) { } } +func TestSetSealTimings(t *testing.T) { + t.Parallel() + + cancunBlock := big.NewInt(100) + chainConfig := ¶ms.ChainConfig{ + ChainID: big.NewInt(137), + CancunBlock: cancunBlock, + } + + // Distinctive vanity and seal bytes to confirm they survive the rewrite. + vanity := bytes.Repeat([]byte{0xab}, ExtraVanityLength) + seal := bytes.Repeat([]byte{0xcd}, ExtraSealLength) + + gasTarget := uint64(15000000) + bfcd := uint64(64) + timeNano := uint64(1700000000_000_000_000) + 123456789 + encoded, err := rlp.EncodeToBytes(&BlockExtraData{ + GasTarget: &gasTarget, + BaseFeeChangeDenominator: &bfcd, + TimeNano: &timeNano, + }) + if err != nil { + t.Fatalf("failed to encode BlockExtraData: %v", err) + } + + extra := append(append(append([]byte{}, vanity...), encoded...), seal...) + header := &Header{Number: big.NewInt(200), Extra: extra} + + // Use values with non-zero nanosecond components to prove full precision. + elapsedNano := uint64(2_648_123) // 2.648123ms + finalizeNano := uint64(1_500_456) + if err := header.SetSealTimings(elapsedNano, finalizeNano); err != nil { + t.Fatalf("SetSealTimings failed: %v", err) + } + + gotElapsed, gotFinalize := header.GetSealTimings(chainConfig) + if gotElapsed == nil || *gotElapsed != elapsedNano { + t.Errorf("SealElapsedNano mismatch: got %v, want %d", gotElapsed, elapsedNano) + } + if gotFinalize == nil || *gotFinalize != finalizeNano { + t.Errorf("SealFinalizeNano mismatch: got %v, want %d", gotFinalize, finalizeNano) + } + + // Vanity and seal must be untouched by the rewrite. + if !bytes.Equal(header.Extra[:ExtraVanityLength], vanity) { + t.Errorf("vanity bytes modified by SetSealTimings") + } + if !bytes.Equal(header.Extra[len(header.Extra)-ExtraSealLength:], seal) { + t.Errorf("seal bytes modified by SetSealTimings") + } + + // Pre-existing optional fields must be preserved. + bed := header.DecodeBlockExtraData(chainConfig) + if bed == nil { + t.Fatalf("DecodeBlockExtraData returned nil after SetSealTimings") + } + if bed.TimeNano == nil || *bed.TimeNano != timeNano { + t.Errorf("TimeNano not preserved: got %v, want %d", bed.TimeNano, timeNano) + } + if bed.GasTarget == nil || *bed.GasTarget != gasTarget { + t.Errorf("GasTarget not preserved: got %v, want %d", bed.GasTarget, gasTarget) + } + + // Short extra data must error rather than panic. + short := &Header{Number: big.NewInt(200), Extra: []byte{0x01, 0x02}} + if err := short.SetSealTimings(1, 2); err == nil { + t.Errorf("expected error for short extra data, got nil") + } +} + func TestDecodeBlockExtraData(t *testing.T) { t.Parallel() diff --git a/miner/worker.go b/miner/worker.go index 9bcfaa2172..2073e24680 100644 --- a/miner/worker.go +++ b/miner/worker.go @@ -2922,6 +2922,20 @@ func (w *worker) commit(env *environment, interval func(), update bool, start ti return err } + // Post-Placeholder: embed the commit-sealing timings into the block's extra + // data so they can be read directly from the chain (verifyHeader enforces + // their presence). The timings are only known after FinalizeAndAssemble, so + // the block built above carries the pre-timing Extra and must be rebuilt + // before it is handed off for sealing. The embedded elapsed is captured here + // rather than reusing the logged value below so the existing log line is left + // untouched; the two differ only by negligible sub-microsecond timing. + if w.chainConfig.Bor != nil && w.chainConfig.Bor.IsPlaceholder(env.header.Number) { + if err := env.header.SetSealTimings(uint64(time.Since(start).Nanoseconds()), uint64(finalizeDuration.Nanoseconds())); err != nil { + return err + } + block = types.NewBlock(env.header, &types.Body{Transactions: env.txs}, env.receipts, trie.NewStackTrie(nil)) + } + select { case w.taskCh <- &task{receipts: env.receipts, state: env.state, block: block, createdAt: time.Now(), productionElapsed: time.Since(firstNonZeroTime(productionStartFrom(genParams), start)), intermediateRootTime: commitTime}: fees := totalFees(block, env.receipts)