Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,19 @@ Upgrading from v0.16.0 changes nothing for them; upgrading from an earlier relea
shape. The same collapse on the HTTP blob store's own client and server is fixed
too; no shipped configuration uses that store. (#1963)

- **A block whose coinbase is not final is rejected.** Affects every node and every miner.
bitcoin-sv applies its `IsFinalTx` rule to the coinbase as to any other transaction, and
Teranode now does too: a block whose coinbase has a non-final input sequence and an
nLockTime not yet reached is invalid (`bad-txns-nonfinal`). From CSV onwards a time-based
nLockTime is measured against the parent's median time past. `submitminingsolution`
refuses such a coinbase instead of mining a block peers reject, and keeps the job, so a
corrected coinbase can still be submitted against it. The time-based check needs the
parent's median time past, which is looked up only after the solution has passed the
proof-of-work check. A solution's time and nonce are no longer copied into the coinbase
as its nLockTime and input sequence; they go into the header only, as in bitcoin-sv. A
node on an earlier build accepts blocks this one rejects, so upgrade every node
together. (#2017)

- **A block's conflicting transactions are checked in one walk, and
`utxostore_conflictingChildrenMaxNodes` becomes a per-root budget for it.** Subtree
validation used to check a block's conflicting transactions one at a time, re-walking
Expand Down
48 changes: 35 additions & 13 deletions model/Block.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,7 @@ type Block struct {
// firstRootMemo caches subtree 0's coinbase-substituted root from the early
// binding check, so CheckMerkleRoot does not recompute it for the same
// subtree (a full merkle store over up to 1M leaves).
firstRootMemo atomic.Pointer[firstRootMemo]
medianTimestamp uint32
firstRootMemo atomic.Pointer[firstRootMemo]
// nodeAllocator, if non-nil, supplies pooled backing slices for the
// per-subtree Node arrays during GetAndValidateSubtrees. Only the
// blockvalidation service sets this (via SetNodeAllocator); model-internal
Expand Down Expand Up @@ -696,14 +695,25 @@ func (b *Block) MedianTimeWindow(currentChain []*BlockHeader) ([]*BlockHeader, e
// When currentChain is empty the median-time-past rule is skipped (same as Valid()). Returns nil
// when the header passes.
func (b *Block) CheckHeaderContextual(currentChain []*BlockHeader, settings *settings.Settings, logger ulogger.Logger) error {
_, err := b.checkHeaderContextual(currentChain, settings, logger)

return err
}

// checkHeaderContextual is CheckHeaderContextual, and additionally returns the parent's median time
// past it measured the rule against, or 0 when currentChain is empty and the rule was skipped. Valid
// needs that value again for the coinbase finality check.
func (b *Block) checkHeaderContextual(currentChain []*BlockHeader, settings *settings.Settings, logger ulogger.Logger) (uint32, error) {
var parentMedianTimePast uint32

// 2. Check that the block timestamp is not more than two hours in the future.
twoHoursToTheFutureTimestampUint32, err := safeconversion.Int64ToUint32(time.Now().Add(2 * time.Hour).Unix())
if err != nil {
return errors.NewProcessingError("[BLOCK][%s] failed to convert two hours to the future timestamp to uint32", b.String(), err)
return 0, errors.NewProcessingError("[BLOCK][%s] failed to convert two hours to the future timestamp to uint32", b.String(), err)
}

if b.Header.Timestamp > twoHoursToTheFutureTimestampUint32 {
return errors.NewBlockInvalidError("[BLOCK][%s] block timestamp is more than two hours in the future", b.String())
return 0, errors.NewBlockInvalidError("[BLOCK][%s] block timestamp is more than two hours in the future", b.String())
}

// 3. Check that the block's timestamp is strictly after the median timestamp of the 11
Expand All @@ -713,7 +723,7 @@ func (b *Block) CheckHeaderContextual(currentChain []*BlockHeader, settings *set
if len(currentChain) > 0 {
lastTimeStamps, err := b.MedianTimeWindow(currentChain)
if err != nil {
return err
return 0, err
}

pruneLength := len(lastTimeStamps)
Expand All @@ -726,19 +736,19 @@ func (b *Block) CheckHeaderContextual(currentChain []*BlockHeader, settings *set
// calculate the median timestamp
medianTimestamp, err := CalculateMedianTimestamp(prevTimeStamps)
if err != nil {
return err
return 0, err
}

b.medianTimestamp, err = safeconversion.Int64ToUint32(medianTimestamp.Unix())
parentMedianTimePast, err = safeconversion.Int64ToUint32(medianTimestamp.Unix())
if err != nil {
return err
return 0, err
}

// validate that the block's timestamp is after the median timestamp
if b.Header.Timestamp <= b.medianTimestamp {
if b.Header.Timestamp <= parentMedianTimePast {
// if we're not on a chain that allows blocks to be generated quickly then return an error
if !settings.ChainCfgParams.GenerateSupported {
return errors.NewBlockInvalidError("block timestamp %d is not after median time past of last %d blocks %d", b.Header.Timestamp, pruneLength, medianTimestamp.Unix())
return 0, errors.NewBlockInvalidError("block timestamp %d is not after median time past of last %d blocks %d", b.Header.Timestamp, pruneLength, medianTimestamp.Unix())
}
// otherwise just warn
logger.Warnf("block timestamp %d is not after median time past of last %d blocks %d", b.Header.Timestamp, pruneLength, medianTimestamp.Unix())
Expand All @@ -749,10 +759,10 @@ func (b *Block) CheckHeaderContextual(currentChain []*BlockHeader, settings *set
// Parity with bitcoin-sv ContextualCheckBlockHeader; must run before body/coinbase checks to
// match svnode's bad-version error priority.
if err := CheckBlockVersion(b.Header.Version, b.Height, settings.ChainCfgParams); err != nil {
return errors.NewBlockInvalidError("[BLOCK][%s] outdated block version", b.String(), err)
return 0, errors.NewBlockInvalidError("[BLOCK][%s] outdated block version", b.String(), err)
}

return nil
return parentMedianTimePast, nil
}

// Valid runs this function's consensus checks over the block — the header's own proof of work, the
Expand Down Expand Up @@ -807,7 +817,8 @@ func (b *Block) ValidWithBinding(ctx context.Context, logger ulogger.Logger, sub
// must be evaluated at block-receipt time rather than only in the background Valid() goroutine
// where time.Now() has drifted past receipt. Factored into CheckHeaderContextual so the
// optimistic path can run them synchronously before AddBlock (issue #1149).
if err := b.CheckHeaderContextual(currentChain, settings, logger); err != nil {
parentMedianTimePast, err := b.checkHeaderContextual(currentChain, settings, logger)
if err != nil {
return false, false, err
}

Expand Down Expand Up @@ -1023,6 +1034,17 @@ func (b *Block) ValidWithBinding(ctx context.Context, logger ulogger.Logger, sub
return false, merkleRootChecked, bindErr("[BLOCK][%s] bad coinbase length", b.String())
}

// 4c. Check that the coinbase is final (bitcoin-sv/teranode#4981). bitcoin-sv's
// ContextualCheckBlock applies IsFinalTx to every transaction, coinbase included, ahead of its
// bad-cb-height check; the coinbase never reaches the validator, so it is checked here. Same
// binding-aware classification as the coinbase checks either side of it. With no ancestor
// headers (block assembly passes none) the parent's median time past is unknown, so from CSV
// onwards a time-based nLockTime is not evaluated, as the median-time-past rule above is
// skipped; the block is checked again with headers when block validation receives it.
if reason := CoinbaseFinalityViolation(b.CoinbaseTx, b.Header.Timestamp, b.Height, settings.ChainCfgParams, parentMedianTimePast); reason != "" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coinbase finality only needs the coinbase, height, header time and parent MTP. No subtrees. Yet it runs in ValidWithBinding, not beside CheckHeaderContextual. With optimistic mining on, a block whose coinbase has a non-final sequence and nLockTime >= height passes CheckHeaderContextual, gets added as the tip and is built on, and only the background Valid then invalidates it, which forces a reorg. bitcoin-sv rejects it in ContextualCheckBlock before accepting it. Move the check into the synchronous contextual step.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing to keep in mind if this moves: at the optimistic pre-add point (BlockValidation.go, just before AddBlock), the coinbase is only bound to the header for a subtree-less body (CheckCoinbaseOnlyBodyBound). For a body with subtrees, a finality failure there is a verdict on an unbound coinbase. It should not mark the block invalid or penalize the peer as a header verdict would. The safe shape is probably: on a violation, skip the optimistic add and fall through to the full Valid path, which classifies it with bindErr. The existing coinbase rules (BIP34 height, bad-cb-length) have the same exposure under optimistic mining today, so whatever is chosen could cover them too.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have left the check where it is in this PR and flagged the placement to Stu as a design decision. Here is why.

  1. The exposure needs an opt-in that is off by default. The pre-Valid add only happens when both blockvalidation_optimistic_mining (default true) and blockvalidation_optimistic_mining_peer_blocks (default false, settings/settings.go:416) are set. Catch-up and the legacy route are never optimistic. On a default node every peer block runs the full Valid, finality included, before AddBlock.

  2. Under the opt-in, finality has the same exposure as the coinbase rules next to it: CoinbaseCommonRuleViolation, bad-cb-length and the BIP34 height check. They all sit after the merkle binding for the reason github-actions gave. For a body with subtrees, the coinbase is not bound to the header until CheckMerkleRoot runs inside Valid. A finality verdict at the optimistic pre-add point (right after CheckHeaderContextual in BlockValidation.go) would be a verdict on a coinbase the header may not commit to. Marking the block invalid or charging the peer there would let anyone who swaps the coinbase poison a good header. bitcoin-sv can reject in ContextualCheckBlock because its CheckBlock has already checked the merkle root by then.

  3. The safe shape would be: at the pre-add point, run the coinbase rules with the median time past checkHeaderContextual already computes. On a violation, skip the optimistic add and fall through to the full path, which classifies the failure with bindErr. No verdict and no penalty at the pre-add point. That should cover all four coinbase rules, not finality alone. The opt-in's own setting docs say to keep it off until the block.Valid integrity-floor split lands, and that split is where the coinbase rules would naturally move together with the binding. Doing it for one rule here would leave the other three exposed in the same way.

Stu, this one is yours: fall-through for all coinbase rules in a follow-up, or wait for the block.Valid split.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring this to #2077, which tracks the block.Valid split that moves the integrity floor ahead of the optimistic add. Moving finality alone would not close the gap. For a body with subtrees the coinbase is not yet bound to the header at that point, and the BIP34 height, bad-cb-length and shared coinbase rules have the same exposure. The flag stays off by default, so a default node fully validates, finality included, before a block joins the chain.

return false, merkleRootChecked, bindErr("[BLOCK][%s] coinbase is not final: %s", b.String(), reason)
}

// BIP34 (https://en.bitcoin.it/wiki/BIP_0034) forces miners to encode the block height in the
// coinbase tx; it activates per network at ChainCfgParams.BIP0034Height (skipped below that
// height). This check deliberately runs AFTER the merkle binding above (bitcoin-sv/teranode#4692): the coinbase is committed by the merkle root, so only once the body is
Expand Down
64 changes: 64 additions & 0 deletions model/block_coinbase_finality.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package model

import (
"github.com/bsv-blockchain/go-bt/v2"
"github.com/bsv-blockchain/go-chaincfg"
"github.com/bsv-blockchain/teranode/util"
)

// lockTimeThreshold is bitcoin-sv's LOCKTIME_THRESHOLD: an nLockTime below it is a block height,
// at or above it a unix timestamp.
const lockTimeThreshold = 500_000_000

// CoinbaseFinalityViolation applies bitcoin-sv's IsFinalTx to a block's coinbase and returns
// bitcoin-sv's reject reason, bad-txns-nonfinal, when the coinbase is not final at height in a block
// stamped blockTime, or "" when it is. bitcoin-sv's ContextualCheckBlock applies IsFinalTx to every
// transaction in the block; regular transactions get it from the validator, but the coinbase never
// goes through the validator, so a block with a non-final coinbase was accepted
// (bitcoin-sv/teranode#4981).
//
// A time-based nLockTime is compared as ContextualCheckBlock does: against blockTime before CSV
// activates, against the parent's median time past (BIP113) from CSV onwards. parentMedianTimePast
// is the median time past of the 11 blocks ending at the block's parent, or 0 when the caller has no
// ancestor headers. In that case, when the verdict depends on it (CoinbaseFinalityNeedsMedianTimePast),
// the coinbase is passed, the same way Valid skips the median-time-past header rule without ancestor
// headers; the sequence-number short-circuit and a height-based nLockTime are still enforced.
//
// Deliberately a reason rather than an error, like CoinbaseCommonRuleViolation: the verdict depends
// on whether the body is bound to its header, which only the caller knows. The caller must guarantee
// a non-nil coinbaseTx with at least one input.
func CoinbaseFinalityViolation(coinbaseTx *bt.Tx, blockTime uint32, height uint32, params *chaincfg.Params, parentMedianTimePast uint32) string {
lockTimeCutoff := blockTime
if height >= params.CSVHeight {
if parentMedianTimePast == 0 && CoinbaseFinalityNeedsMedianTimePast(coinbaseTx, height, params) {
return ""
}

lockTimeCutoff = parentMedianTimePast
}

if util.IsTransactionFinal(coinbaseTx, height, lockTimeCutoff) != nil {
return "bad-txns-nonfinal"
}

return ""
}

// CoinbaseFinalityNeedsMedianTimePast reports whether CoinbaseFinalityViolation's verdict on
// coinbaseTx at height depends on the parent's median time past. That is only so from CSV onwards,
// for a time-based nLockTime, and when some input's sequence number is not final; in every other
// case the verdict is the same whatever parentMedianTimePast is passed, including 0. A caller that
// has no ancestor headers to hand uses this to fetch the median time past only when it matters.
func CoinbaseFinalityNeedsMedianTimePast(coinbaseTx *bt.Tx, height uint32, params *chaincfg.Params) bool {
if height < params.CSVHeight || coinbaseTx.LockTime < lockTimeThreshold {
return false
}

for _, input := range coinbaseTx.Inputs {
if input.SequenceNumber != bt.DefaultSequenceNumber {
return true
}
}

return false
}
Loading
Loading