Skip to content

fix(blockvalidation): reject blocks whose coinbase is not final, and stop block assembly mining them - #2017

Merged
freemans13 merged 14 commits into
bsv-blockchain:mainfrom
freemans13:stu/coinbase-finality-check
Oct 28, 2026
Merged

freemans13 merged 14 commits into
bsv-blockchain:mainfrom
freemans13:stu/coinbase-finality-check

Conversation

@freemans13

Copy link
Copy Markdown
Collaborator

What happened

Teranode accepted blocks whose coinbase is not final, and bitcoin-sv rejects them. That is a chain-split risk.

A transaction is final when every input's sequence number is 0xffffffff, or when its nLockTime is already in the past. bitcoin-sv applies this rule (IsFinalTx) to every transaction in a block, coinbase included, and rejects a failure as bad-txns-nonfinal. Teranode's only finality check was in the validator, which never sees a coinbase. So nothing checked the coinbase at all.

Block Assembly could also build such a coinbase itself. When a miner submits a solution without a coinbase, Block Assembly rebuilds one and copied the solution's time into its nLockTime and its nonce into its input sequence. For any non-zero nonce, that coinbase is non-final.

Closes bitcoin-sv/teranode#4981.

Changes

Block validation rejects a non-final coinbase. model.CoinbaseFinalityViolation applies bitcoin-sv's rule. Block.Valid runs it after the coinbase length check and before the BIP34 height check, which is bitcoin-sv's order. The quick-validation route used below checkpoints runs it too. A time-based lock is compared to the block's own timestamp before CSV activation, and from CSV onwards to the parent's median time past (the median timestamp of the 11 blocks ending at the parent). CheckHeaderContextual already computes that median, so it now returns it, and the write-only Block.medianTimestamp field is gone.

Routes without ancestor headers skip only the one case they cannot judge. Quick validation and Block Assembly's own Block.Valid call have no headers to compute median time past from. From CSV onwards they don't evaluate a time-based lock, just as they already skip the median-time-past header rule. They still enforce the sequence rule, height-based locks, and pre-CSV time locks.

The rebuilt coinbase is final. SubmitMiningSolution no longer copies the solution's time and nonce into the coinbase. bitcoin-sv's submitminingsolution puts them in the header only, and Teranode already builds the header from them.

Block Assembly checks a miner's own coinbase for finality, median time past included. A mined block goes from SubmitMiningSolution straight to AddBlock, never through block validation. Without this check, the skipped case above would let a miner's time-locked coinbase through, and peers would reject the block. The submit path now runs the finality check itself. model.CoinbaseFinalityNeedsMedianTimePast tells it when the verdict depends on median time past: from CSV onwards, a time-based lock, and a non-final sequence. Only then does it call BlockAssembler.ParentMedianTimePast, which reads the value the candidate-time code (#1466) already caches for the parent, and asks the blockchain service on a cache miss. If the value can't be established, the solution is refused rather than mined. A non-final coinbase is refused as block-invalid, the same class Block.Valid gives it. A failed lookup is a processing error, because the fault is this node's.

Testing

  • model/block_coinbase_finality_test.go: Block.Valid on bound and unbound blocks, every lock-time boundary before and after CSV, and the promise that CoinbaseFinalityNeedsMedianTimePast makes to callers without headers.
  • services/blockvalidation/quick_validate_coinbase_finality_test.go: quick validation rejects a non-final coinbase as invalid and never commits the block.
  • services/blockassembly/submit_mining_solution_coinbase_finality_test.go: the submit path refuses a time lock at or after median time past and accepts one before it. It fills a cold cache from the blockchain service, refuses when the lookup fails, and makes no lookup when the verdict doesn't depend on it. A rebuilt coinbase comes out final. With the Server.go change reversed, four of these tests fail.
  • The issue's proof test, TestGapBV045_NonFinalCoinbaseRejected from docs(spec): services/blockvalidation #1975, passes on this branch with its bvgaps tag removed.
  • The model, block assembly and block validation packages pass in full with -race, 5,077 tests. make lint reports three errcheck failures, all in test files whose lines are the same on main.

Limits

  • Unit tests only. Nothing has run on a node.
  • A miner coinbase with a time lock and a non-final sequence now costs one blockchain lookup when the cache is cold. If the blockchain service is down at that moment, the solution is refused. Ordinary coinbases never trigger the lookup.
  • On a failed lookup, computeMtpFloor logs in the candidate-time code's wording ("serving an unfloored candidate time"), which reads oddly on the submit path.
  • The rebuild route only works at trivial difficulty such as regtest. The rebuilt coinbase gets a fresh random extranonce, so the merkle root no longer matches what the miner hashed. Its copy of the header version into the coinbase's transaction version has no bitcoin-sv counterpart either. It doesn't affect finality, so it is left as it was.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HwBYs9tELJrTFomcTUPmp6

freemans13 and others added 3 commits October 20, 2026 18:03
bitcoin-sv applies IsFinalTx to every transaction in a block, coinbase
included, and rejects a non-final one as bad-txns-nonfinal. Teranode's
only finality check is in the validator, which never sees the coinbase,
so a block with a non-final coinbase was accepted here and rejected by
bitcoin-sv.

Add model.CoinbaseFinalityViolation and apply it in Block.Valid, after
bad-cb-length and before the BIP34 height check (bitcoin-sv's order),
and on the quick-validation route. A time-based nLockTime is compared to
the header time before CSV and to the parent's median time past from CSV
on. Without ancestor headers (block assembly, quick validation) the
median time past is unknown, so a post-CSV time lock is skipped there,
as the median-time-past header rule already is.

checkHeaderContextual now returns the median time past it computed, and
the write-only Block.medianTimestamp field is removed.

Fixes bitcoin-sv/teranode#4981

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATTxGCcnoi1LqjvX93AD8W
When a solution omits its coinbase, SubmitMiningSolution rebuilds one and
copied the solution's time into its nLockTime and its nonce into the
input sequence. For any non-zero nonce that coinbase is non-final, which
bitcoin-sv rejects as bad-txns-nonfinal. bitcoin-sv puts those values in
the header only, and the header here is already built from them, so the
copies are removed.

A miner-supplied coinbase could still carry a time lock that block.Valid
on the submit path cannot evaluate: without ancestor headers it skips a
time-based nLockTime from CSV onwards, and a mined block goes from there
straight to AddBlock, never through block validation. The submit path now
applies CoinbaseFinalityViolation itself. When the verdict depends on the
parent's median time past (new CoinbaseFinalityNeedsMedianTimePast), it
reads it from the candidate-time memo, or from the blockchain service on
a miss, and refuses the solution if it cannot be established rather than
add a block peers may reject.

CoinbaseFinalityViolation now takes the block timestamp rather than the
header, so the submit path can call it before building one.

Refs bitcoin-sv/teranode#4981

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HwBYs9tELJrTFomcTUPmp6
@freemans13 freemans13 self-assigned this Oct 21, 2026
… a real store, and log the consensus change (review round 1)

The submit-path finality tests answered the median-time-past lookup from
blockchain.Mock, so the cold-memo case checked the code against the mock's
idea of header order rather than the store's. They now read headers from a
sqlitememory blockchain store through a thin counting wrapper: the cold-memo
case stores eleven real blocks, the failed-lookup case uses a parent the
store does not hold, and the no-lookup cases assert a zero lookup count.
Skipping the lookup in submitMiningSolution fails three of the six subtests.

CHANGELOG gains an entry under behaviour changes on upgrade: a block with a
non-final coinbase is now rejected, and the coinbase rebuilt for a solution
without one no longer carries the solution's time and nonce.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0191Q6pyDA2snkaJ2JRGJXLz
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-2017 (4c6ac35)

Summary

  • Regressions: 0
  • Improvements: 0
  • Unchanged: 169
  • Significance level: p < 0.05
All benchmark results (sec/op)
Benchmark Baseline Current Change p-value
_NewBlockFromBytes-4 969.1n 920.4n ~ 0.400
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=1024/memory-4 11.71m 11.49m ~ 0.100
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=1024/disk_1-4 12.75m 11.81m ~ 0.100
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=1024/disk_2-4 13.76m 12.91m ~ 0.100
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=16384/memo... 23.37m 21.65m ~ 0.100
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=16384/disk... 32.91m 27.38m ~ 0.100
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=16384/disk... 33.44m 26.40m ~ 0.100
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=131072/mem... 90.44m 87.52m ~ 0.400
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=131072/dis... 213.3m 207.9m ~ 0.100
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=131072/dis... 214.8m 190.7m ~ 0.100
BlockTxMap/txs=16384/nodeindex-4 1.527m 1.515m ~ 0.700
BlockTxMap/txs=16384/swiss-4 12.31m 10.52m ~ 0.400
BlockTxMap/txs=262144/nodeindex-4 11.88m 11.69m ~ 0.700
BlockTxMap/txs=262144/swiss-4 41.80m 34.82m ~ 0.100
BlockTxMap/txs=4194304/nodeindex-4 384.7m 319.1m ~ 0.100
BlockTxMap/txs=4194304/swiss-4 752.9m 665.2m ~ 0.100
TxMapPut_MemoryVsDisk/txs=100000/memory-4 4.736m 4.682m ~ 0.700
TxMapPut_MemoryVsDisk/txs=100000/disk_1-4 7.730m 8.187m ~ 0.400
TxMapPut_MemoryVsDisk/txs=100000/disk_2-4 7.298m 8.009m ~ 0.100
TxMapGetFrozen_MemoryVsDisk/txs=100000/memory/hit-4 2.653m 2.666m ~ 0.700
TxMapGetFrozen_MemoryVsDisk/txs=100000/memory/miss-4 2.120m 2.098m ~ 1.000
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_1/hit-4 1.140m 1.161m ~ 0.400
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_1/miss-4 1.207m 1.208m ~ 1.000
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_2/hit-4 1.101m 1.130m ~ 0.100
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_2/miss-4 1.218m 1.210m ~ 1.000
ParentSpendsSetIfNotExists_MemoryVsDisk/inputs=200000/dis... 20.41m 20.44m ~ 1.000
ParentSpendsSetIfNotExists_MemoryVsDisk/inputs=200000/dis... 18.93m 19.20m ~ 1.000
InpointDedup/txs=16K-4 3.607m 3.641m ~ 0.700
InpointDedup/txs=128K-4 12.77m 13.07m ~ 0.400
InpointDedup/txs=1M-4 95.77m 95.06m ~ 1.000
InpointDedup_TinyRuns-4 34.81m 34.19m ~ 1.000
MiningCandidate_Stringify_Short-4 112.7n 114.3n ~ 0.700
MiningCandidate_Stringify_Long-4 821.1n 823.0n ~ 0.400
MiningSolution_Stringify-4 463.6n 465.9n ~ 0.700
BlockInfo_MarshalJSON-4 1.199µ 1.177µ ~ 1.000
DecodeSubtreeMeta/go-subtree-4 114.9m 114.2m ~ 0.700
DecodeSubtreeMeta/packed-4 31.16m 30.78m ~ 1.000
DecodeSubtreeMeta/go-subtree/parallel-4 64.02m 63.90m ~ 1.000
DecodeSubtreeMeta/packed/parallel-4 17.72m 17.44m ~ 0.700
ValidateSubtreeNodesMatchKey/leaves=1024-4 179.5µ 181.7µ ~ 0.700
ValidateSubtreeNodesMatchKey/leaves=1048576-4 54.81m 57.35m ~ 0.100
NewFromBytes-4 155.5n 125.4n ~ 0.100
AddTxBatchColumnar_Validation-4 2.109µ 2.197µ ~ 0.200
OffsetValidationLoop-4 644.6n 655.3n ~ 1.000
BuildSubtreeMeta/serialized-4 19.28m 15.79m ~ 0.100
BuildSubtreeMeta/decoded-4 31.86m 29.08m ~ 0.100
Mine_EasyDifficulty-4 67.86µ 68.02µ ~ 0.700
Mine_WithAddress-4 8.182µ 7.979µ ~ 0.700
BlockAssembler_AddTx-4 0.02069n 0.02289n ~ 1.000
AddNode-4 11.08 11.26 ~ 1.000
AddNodeWithMap-4 11.72 11.65 ~ 1.000
DirectSubtreeAdd/4_per_subtree-4 81.62n 76.78n ~ 1.000
DirectSubtreeAdd/64_per_subtree-4 34.81n 33.46n ~ 0.100
DirectSubtreeAdd/256_per_subtree-4 33.55n 31.59n ~ 0.100
DirectSubtreeAdd/1024_per_subtree-4 31.64n 30.41n ~ 0.100
DirectSubtreeAdd/2048_per_subtree-4 31.01n 29.62n ~ 0.100
SubtreeProcessorAdd/4_per_subtree-4 251.5n 253.0n ~ 0.700
SubtreeProcessorAdd/64_per_subtree-4 284.7n 284.9n ~ 1.000
SubtreeProcessorAdd/256_per_subtree-4 302.1n 302.0n ~ 1.000
SubtreeProcessorAdd/1024_per_subtree-4 302.1n 303.3n ~ 1.000
SubtreeProcessorAdd/2048_per_subtree-4 295.9n 298.5n ~ 0.400
SubtreeProcessorRotate/4_per_subtree-4 302.8n 302.4n ~ 1.000
SubtreeProcessorRotate/64_per_subtree-4 302.3n 312.7n ~ 0.100
SubtreeProcessorRotate/256_per_subtree-4 302.0n 321.4n ~ 0.100
SubtreeProcessorRotate/1024_per_subtree-4 299.1n 309.1n ~ 0.100
SubtreeNodeAddOnly/4_per_subtree-4 66.58n 65.09n ~ 1.000
SubtreeNodeAddOnly/64_per_subtree-4 40.38n 40.01n ~ 1.000
SubtreeNodeAddOnly/256_per_subtree-4 39.24n 39.47n ~ 1.000
SubtreeNodeAddOnly/1024_per_subtree-4 38.86n 39.68n ~ 0.700
SubtreeCreationOnly/4_per_subtree-4 163.9n 165.2n ~ 1.000
SubtreeCreationOnly/64_per_subtree-4 419.3n 411.3n ~ 1.000
SubtreeCreationOnly/256_per_subtree-4 1.408µ 1.411µ ~ 0.500
SubtreeCreationOnly/1024_per_subtree-4 4.939µ 5.129µ ~ 1.000
SubtreeCreationOnly/2048_per_subtree-4 10.03µ 10.13µ ~ 0.400
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 238.9n 241.5n ~ 0.700
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 294.6n 296.2n ~ 1.000
ParallelGetAndSetIfNotExists/1k_nodes-4 763.3µ 962.5µ ~ 0.700
ParallelGetAndSetIfNotExists/10k_nodes-4 4.883m 4.953m ~ 0.700
ParallelGetAndSetIfNotExists/50k_nodes-4 7.440m 7.470m ~ 0.700
ParallelGetAndSetIfNotExists/100k_nodes-4 9.978m 9.836m ~ 1.000
SequentialGetAndSetIfNotExists/1k_nodes-4 688.2µ 724.4µ ~ 0.700
SequentialGetAndSetIfNotExists/10k_nodes-4 5.539m 6.030m ~ 0.700
SequentialGetAndSetIfNotExists/50k_nodes-4 22.24m 20.58m ~ 0.200
SequentialGetAndSetIfNotExists/100k_nodes-4 39.15m 35.11m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 802.2µ 820.4µ ~ 0.700
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 7.469m 7.252m ~ 0.200
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 12.95m 12.86m ~ 0.400
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 746.0µ 750.2µ ~ 0.700
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 8.395m 8.902m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 55.04m 53.20m ~ 0.100
DiskTxMap_SetIfNotExists-4 618.4n 549.3n ~ 0.100
DiskTxMap_SetIfNotExists_Parallel-4 211.6n 202.2n ~ 0.200
DiskTxMap_ExistenceOnly-4 277.3n 259.9n ~ 0.400
Queue-4 151.9n 144.6n ~ 0.100
AtomicPointer-4 3.297n 2.959n ~ 0.100
TxMapSetIfNotExists-4 46.92n 41.45n ~ 0.100
TxMapSetIfNotExistsDuplicate-4 39.27n 37.28n ~ 0.100
ChannelSendReceive-4 529.5n 490.1n ~ 0.100
CalcBlockWork-4 514.6n 519.4n ~ 0.200
CalculateWork-4 696.7n 698.6n ~ 1.000
CheckOldBlockIDs/on-chain-prefetch/1000-4 34.74µ 35.71µ ~ 0.100
CheckOldBlockIDs/in-memory-chain-check/1000-4 911.5µ 960.7µ ~ 0.400
CheckOldBlockIDs/on-chain-prefetch/10000-4 299.7µ 269.3µ ~ 0.700
CheckOldBlockIDs/in-memory-chain-check/10000-4 1.310m 1.332m ~ 0.100
BuildBlockLocatorString_Helpers/Size_10-4 872.3n 972.9n ~ 0.100
BuildBlockLocatorString_Helpers/Size_100-4 8.267µ 9.127µ ~ 0.100
BuildBlockLocatorString_Helpers/Size_1000-4 83.13µ 87.70µ ~ 0.100
CatchupWithHeaderCache-4 105.1m 105.1m ~ 0.400
_BufferPoolAllocation/16KB-4 4.664µ 4.560µ ~ 0.400
_BufferPoolAllocation/32KB-4 12.672µ 9.356µ ~ 0.700
_BufferPoolAllocation/64KB-4 18.48µ 24.90µ ~ 0.100
_BufferPoolAllocation/128KB-4 35.26µ 35.72µ ~ 1.000
_BufferPoolAllocation/512KB-4 150.6µ 149.0µ ~ 1.000
_BufferPoolConcurrent/32KB-4 22.53µ 22.97µ ~ 0.200
_BufferPoolConcurrent/64KB-4 35.80µ 35.78µ ~ 1.000
_BufferPoolConcurrent/512KB-4 184.9µ 183.2µ ~ 0.400
_SubtreeDeserializationWithBufferSizes/16KB-4 707.2µ 691.2µ ~ 0.200
_SubtreeDeserializationWithBufferSizes/32KB-4 700.1µ 683.5µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/64KB-4 700.3µ 691.8µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/128KB-4 702.7µ 684.4µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/512KB-4 693.1µ 698.1µ ~ 0.700
_SubtreeDataDeserializationWithBufferSizes/16KB-4 37.39m 36.74m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/32KB-4 37.08m 36.33m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/64KB-4 36.95m 36.45m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/128KB-4 36.96m 36.66m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/512KB-4 37.86m 36.08m ~ 0.100
_PooledVsNonPooled/Pooled-4 731.4n 735.2n ~ 0.700
_PooledVsNonPooled/NonPooled-4 9.662µ 9.367µ ~ 0.200
_MemoryFootprint/Current_512KB_32concurrent-4 7.609µ 7.591µ ~ 1.000
_MemoryFootprint/Proposed_32KB_32concurrent-4 10.78µ 10.70µ ~ 0.800
_MemoryFootprint/Alternative_64KB_32concurrent-4 10.014µ 9.846µ ~ 0.200
_prepareTxsPerLevel-4 453.8m 529.4m ~ 0.200
_prepareTxsPerLevelOrdered-4 4.536m 4.480m ~ 0.700
_prepareTxsPerLevel_Comparison/Original-4 459.8m 456.8m ~ 0.700
_prepareTxsPerLevel_Comparison/Optimized-4 4.669m 4.110m ~ 0.400
SubtreeSizes/10k_tx_4_per_subtree-4 1.289m 1.273m ~ 0.700
SubtreeSizes/10k_tx_16_per_subtree-4 308.0µ 308.1µ ~ 1.000
SubtreeSizes/10k_tx_64_per_subtree-4 74.64µ 74.78µ ~ 0.700
SubtreeSizes/10k_tx_256_per_subtree-4 18.64µ 18.09µ ~ 0.200
SubtreeSizes/10k_tx_512_per_subtree-4 9.275µ 9.136µ ~ 0.100
SubtreeSizes/10k_tx_1024_per_subtree-4 4.535µ 4.693µ ~ 0.700
SubtreeSizes/10k_tx_2k_per_subtree-4 2.301µ 2.286µ ~ 0.400
BlockSizeScaling/10k_tx_64_per_subtree-4 74.21µ 76.12µ ~ 0.700
BlockSizeScaling/10k_tx_256_per_subtree-4 18.25µ 19.89µ ~ 0.100
BlockSizeScaling/10k_tx_1024_per_subtree-4 4.696µ 4.663µ ~ 0.200
BlockSizeScaling/50k_tx_64_per_subtree-4 369.7µ 385.4µ ~ 0.100
BlockSizeScaling/50k_tx_256_per_subtree-4 91.07µ 96.07µ ~ 0.200
BlockSizeScaling/50k_tx_1024_per_subtree-4 24.74µ 24.86µ ~ 1.000
SubtreeAllocations/small_subtrees_exists_check-4 161.0µ 171.6µ ~ 0.100
SubtreeAllocations/small_subtrees_data_fetch-4 171.2µ 177.3µ ~ 0.100
SubtreeAllocations/small_subtrees_full_validation-4 341.5µ 346.5µ ~ 0.400
SubtreeAllocations/medium_subtrees_exists_check-4 10.21µ 10.71µ ~ 0.100
SubtreeAllocations/medium_subtrees_data_fetch-4 10.92µ 11.35µ ~ 0.100
SubtreeAllocations/medium_subtrees_full_validation-4 21.82µ 22.75µ ~ 0.100
SubtreeAllocations/large_subtrees_exists_check-4 2.564µ 2.646µ ~ 0.100
SubtreeAllocations/large_subtrees_data_fetch-4 2.784µ 2.820µ ~ 0.700
SubtreeAllocations/large_subtrees_full_validation-4 5.431µ 5.603µ ~ 0.400
StoreBlockForkPath/inMemoryChainCheck=false-4 425.6µ 422.5µ ~ 0.700
StoreBlockForkPath/inMemoryChainCheck=true-4 1.065m 1.051m ~ 1.000
StoreBlock_Sequential/BelowCSVHeight-4 136.6µ 136.4µ ~ 0.700
StoreBlock_Sequential/AboveCSVHeight-4 136.7µ 135.7µ ~ 0.100
CheckSpendAndCreateMultiList_FanIn/parents=80000-4 42.65m 43.81m ~ 0.400
CheckSpendAndCreateMultiList_FanIn/parents=160000-4 107.9m 120.8m ~ 0.100
GetUtxoHashes-4 240.1n 241.6n ~ 0.400
GetUtxoHashes_ManyOutputs-4 34.92µ 35.57µ ~ 0.100
MetaBytes-4 50.70n 51.33n ~ 0.700
_NewMetaDataFromBytes-4 169.2n 170.6n ~ 0.100
_Bytes-4 257.6n 257.2n ~ 0.400
_MetaBytes-4 79.15n 79.83n ~ 0.100

Threshold: >10% with p < 0.05 | Generated: 2026-10-28 13:57 UTC

@freemans13
freemans13 marked this pull request as ready for review October 21, 2026 11:25
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete

Current Review:
No new issues. There is one new commit since the last review. 2f1bf5d changes checkCoinbaseTimeLockFinality to take the *model.Block and adds block.Hash() to its warning and both of its errors. Their prefix is now [jobID][blockHash], the same as the sibling refusals in submitMiningSolution. The fields it reads now are the same values the old arguments carried. CoinbaseTx, Header.Timestamp (nTime), Header.HashPrevBlock and Height (job.MiningCandidate.Height) are all set when the block is built just before the call. So the check behaves exactly as before.

One design call is still open for the maintainers: whether the finality check belongs in ValidWithBinding or at the optimistic pre-add point. The author's replies in that thread give the reasoning for keeping it where it is.

History:

  • ✅ Fixed: the coinbase finality refusal now names the block hash, matching the sibling refusals (2f1bf5d).
  • ✅ Fixed: a coinbase finality refusal now gets a warning in the log like the other consensus refusals (1999dc2).
  • ✅ Fixed: the stale note that a coinbase-less solution can't be mined, and the test's retry loop, are gone now that fix(blockassembly): keep a mining candidate's coinbase on its job, so a solution without one can be mined #2027 is merged.
  • ✅ Fixed: the median-time-past lookup runs after the proof-of-work check (8164da4).
  • ✅ Fixed earlier: ParentMedianTimePast no longer fetches the parent header on every call.
  • ✅ Fixed earlier: the submit-path lookup no longer writes to the floor memo or uses up its log latch.
  • ✅ Fixed earlier: the finality harness uses a real sqlitememory blockchain client.

@freemans13

Copy link
Copy Markdown
Collaborator Author

Claude Code Review on db54c95: no issues, one suggestion, declined.

The suggestion was to warn pool operators in the release notes about the coinbase no longer carrying the time and nonce. The CHANGELOG entry added in db54c95 already does that job. It opens with "Affects every miner", and the release notes are cut from CHANGELOG. A pool could not have depended on the old copy in practice, either. The solution time and nonce went into the coinbase after the miner had already hashed the header, so the rebuilt coinbase changed the merkle root under a header the miner had solved for a different one.

…over real stores, on both entry points

The test ran on the catch-up suite's mocked blockchain client and only checked
the returned error. It now uses newPreBindHarness, which puts sqlitememory UTXO
and blockchain stores behind the ordinary LocalClient, and asserts the end
state: a non-final coinbase leaves no block in the blockchain store.

It also covers quickValidateBlockAsync, the default catch-up entry point, which
the old test missed. Each entry point has a final-coinbase control that must
commit, so a rejection cannot come from a broken fixture. With the finality
check disabled in checkQuickValidationCoinbase, both rejection cases fail and
both controls still pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0191Q6pyDA2snkaJ2JRGJXLz
@freemans13
freemans13 requested review from icellan and ordishs October 21, 2026 12:11

@icellan icellan left a comment

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.

The finality rule matches bitcoin-sv IsFinalTx: block time before CSV, parent MTP from CSV onwards, checked before bad-cb-height. The coinbase-less submit path is still broken, see inline.

Comment thread services/blockassembly/Server.go Outdated
// bitcoin-sv's submitminingsolution puts them. They used to be copied into the coinbase as
// well, as its nLockTime and input sequence, and that made the coinbase non-final for any
// non-zero nonce: bitcoin-sv rejects such a block as bad-txns-nonfinal
// (bitcoin-sv/teranode#4981). CreateCoinbaseTxCandidate's coinbase is final as built. The

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.

The coinbase-less path is still unminable. CreateCoinbaseTxCandidate (line 1938) builds a fresh coinbase with a random extranonce (rand.Read), so the merkle root can never match the one the miner hashed. Every coinbase-less submit fails the target check, deletes the job and resets BA. The req.Version copy changes the txid as well. The CHANGELOG entry reads as if this path now works. Either refuse coinbase-less solutions, or store the candidate's coinbase on the job and rebuild from it.

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.

Agreed, and the cause predates this PR: CreateCoinbase appends 12 random extranonce bytes (model/mining_candidate.go:126), so the rebuilt coinbase never matches the one the miner hashed. Dropping the time and nonce copy removed one source of divergence, not the main one. The paths do not agree with each other either: GetCandidateBlock, getminingcandidate with coinbase=true (a p2pk coinbase block assembly never sees) and submit each build their own random coinbase.

We are taking your second option, the bitcoin-sv shape (mining-fbb.cpp keeps the candidate block and falls back to result->GetBlockCoinbase(), with version going to the header only). It needs the job to own the coinbase and the GetMiningCandidate response to carry it, so it goes in its own PR rather than widening this consensus fix. I will link it here when it is open.

For this PR, fa510dd corrects the CHANGELOG entry: it now says a coinbase-less solution is still unminable and that miners must submit the coinbase, which every in-repo miner (mining.Mine, the RPC submit) already does.

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.

The bitcoin-sv shape is up as #2027 (draft): the job keeps the candidate coinbase, getminingcandidate coinbase=true and GetCandidateBlock return it, and a coinbase-less solution is mined with it in a single submit over a real store.

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.

Closed by PR 2027, which is now on main and merged into this branch in 70ea819. A coinbase-less solution is mined with the coinbase the job was issued with, and the solution's version goes into the header only. The merge took main's side of that branch of submitMiningSolution, and this PR's CHANGELOG entry no longer says a coinbase-less solution is unminable.

err error
)

for attempt := 0; attempt < 64; attempt++ {

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.

The 64-attempt retry loop with a reset wait of up to 1s is there to work around the random-extranonce rebuild above. It hides the bug instead of pinning it, and the runtime is unpredictable. Make it single-shot once the rebuild is deterministic.

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.

Agreed that the loop only exists to get past the random rebuild. It stays in this PR because the rebuild is still random here, and fa510dd says so in the test: it becomes a single submit in the follow-up PR that makes the job keep and reuse the candidate coinbase. Its worst case is bounded: regtest takes about half of all hashes, so 64 misses in a row is roughly 2^-64, and each miss waits only for the reset the harness answers at once, never the full 1s timeout in practice.

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.

Done in 70ea819, now that PR 2027 is on main and merged in. TestSubmitMiningSolution_RecreatedCoinbaseIsFinal is a single submit with no retry loop. It stores the job through storeJobWithCoinbase, so the job holds its issued coinbase. It finds a nonce for that coinbase in advance, starting at 1 so that a nonce copied into the input sequence would show, and submits once. The block must be stored with that nonce in the header and a final coinbase with nLockTime 0 and a final sequence.

Comment thread services/blockassembly/candidate_time.go Outdated
Comment thread services/blockassembly/submit_mining_solution_coinbase_finality_test.go Outdated
freemans13 and others added 2 commits October 21, 2026 17:03
…t of the candidate floor, and test it on a real blockchain store

ParentMedianTimePast filled a cold lookup through mtpFloor. That placed the
submit's parent in the two-slot floor memo, which could evict the entry a
candidate path was using, and a failed lookup logged "serving an unfloored
candidate time" for what was a refused submit, spending the per-parent log
latch so the real candidate failure on that parent then logged nothing.

The fetch, verify, walk and median steps now live in
parentChainMedianTimePast, which neither logs nor memoizes. computeMtpFloor
wraps it with its logging and memo placement as before. ParentMedianTimePast
still reads the memo on a hit, and on a miss calls the helper directly and
returns the cause to the submit path.

The submit-path finality test no longer asserts AddBlock on a mocked
blockchain client. The server adds blocks to a sqlitememory store holding a
real 149-block chain to the candidate's parent, and each case asserts whether
a block was stored on that parent. A new test pins that a cold lookup leaves
both memo slots and the log latch untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0191Q6pyDA2snkaJ2JRGJXLz
…minable

The entry read as if dropping the time and nonce copy made the coinbase-less
submit path work. It does not: CreateCoinbase gives the rebuilt coinbase a
fresh random extranonce, so its merkle root is never the one the miner
hashed. The entry now says so and tells miners to submit the coinbase, which
every in-repo miner already does. The recreate test's retry loop says the
same; it becomes a single submit once the job keeps the candidate's coinbase,
as bitcoin-sv does, which is a separate PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0191Q6pyDA2snkaJ2JRGJXLz

@icellan icellan left a comment

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.

Re-review at fa510dd. I resolved the mtpFloor and mock threads. The coinbase-less rebuild and the retry loop stay open until #2027. No blockers. Four smaller points inline.

Comment thread model/Block.go
// 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.

Comment thread services/blockassembly/Server.go Outdated
Comment thread services/blockassembly/Server.go
Comment thread services/blockassembly/candidate_time.go Outdated
@icellan

icellan commented Oct 26, 2026

Copy link
Copy Markdown
Contributor

Merged main in 20825dd. The only conflict was in CHANGELOG.md: this PR's entry and the one from #2037 landed in the same spot, and I kept both.

The markdownlint pre-commit hook fails on main as it stands: MD007 ul-indent on the four nested bullets in the #2037 entry ("Before: / Now: / A batch sample's nodes / Blessing a single missing transaction"). They're indented 2 spaces and .markdownlint.yml expects 4. I committed the merge with SKIP=markdownlint and didn't touch that text. Anyone committing a CHANGELOG.md change will hit the hook until main is fixed.

freemans13 and others added 2 commits October 28, 2026 12:30
…lookup

Two review findings on the submit path, both about blockchain reads a
submission could cause before it had shown any work.

The median-time-past lookup ran before block.Valid. A miner could send a
time-locked coinbase with a non-final sequence and a junk nonce, and every
such submit cost a GetBlockHeader, a GetBlockHeaders and, on the fallback
walk, up to ten more reads. submitMiningSolution now runs only the
lookup-free part of the finality check up front (parent median time past
passed as 0, which CoinbaseFinalityViolation skips for a time lock it cannot
judge). The lookup and the time-based check run after block.Valid has
accepted the proof of work, so an invalid nonce triggers no reads. The
height is block.Height, which is set from job.MiningCandidate.Height, so the
verdict is unchanged. A finality refusal still keeps the job: the job's
template is sound, only the miner's coinbase is wrong, and a corrected
coinbase against the same job is valid.

ParentMedianTimePast fetched the parent header on every call, though only
the hash-keyed fallback walk needs it. In the common case the batched run
verifies and the header was thrown away, and a transient GetBlockHeader
error could refuse a submit the batched fetch would have handled.
parentChainMedianTimePast now fetches the header only when the batched run
fails verification and the caller did not supply one.

Tests: a new subtest submits a below-target nonce with a time-locked coinbase
and asserts zero blockchain reads; moving the lookup back before block.Valid
makes it fail. The cold-lookup test now asserts exactly one read; restoring
the unconditional header fetch makes it fail with two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HzSYaAXVb6mbD9mt4iDds
@freemans13
freemans13 requested a review from icellan October 28, 2026 12:37
…wn method

The previous commit put the median-time-past lookup and the time-based
finality check inline in submitMiningSolution, after block.Valid. That pushed
the function's cognitive complexity from under 50 to 52, and SonarCloud
flagged it (go:S3776). The lookup and the check now live in
checkCoinbaseTimeLockFinality, which submitMiningSolution calls at the same
point. The behaviour is unchanged: the same condition gates the lookup, the
same errors come back, and the finality tests pass unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HzSYaAXVb6mbD9mt4iDds

@icellan icellan left a comment

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.

Approving at 7d59091. The finality rule matches bitcoin-sv IsFinalTx, and the submit-side median-time-past lookup now runs after the proof-of-work check.

Follow-up, in #2027:

  • A submit without a coinbase still can't be mined, because the rebuilt coinbase gets a random extranonce. Until #2027 lands, it would be cleaner to refuse a coinbase-less solution up front than to let it fail proof of work and delete the job.
  • TestSubmitMiningSolution_RecreatedCoinbaseIsFinal should become single-shot once the rebuild is deterministic.

@freemans13, a design decision for you: coinbase finality only needs the coinbase, height, header time and parent median time past, but it runs in ValidWithBinding, not beside CheckHeaderContextual. With optimistic mining on for peer blocks, a block with a non-final coinbase is added and built on before the background Valid invalidates it. bitcoin-sv rejects it in ContextualCheckBlock. Moving it into the synchronous contextual step closes that, here or in a follow-up. The open thread on model/Block.go tracks it.

Minor: the finality refusal after the PoW check returns without a log line, unlike the ErrBlockInvalid branch next to it.

freemans13 and others added 2 commits October 28, 2026 13:16
PR 2027 landed on main. It makes the job keep the coinbase it was issued
with and mine that one for a coinbase-less solution, checks proof of work
before block.Valid, and keeps the job when block.Valid finds a consensus
fault.

One conflict, in services/blockassembly/Server.go, on the coinbase-less
branch of submitMiningSolution. This branch still rebuilt the coinbase there,
with the solution's version copied in; main parses the job's issued coinbase.
Took main's side: it already leaves the solution's time and nonce out of the
coinbase, which is what this PR needs.

Adjustments the merge needs:
- TestSubmitMiningSolution_RecreatedCoinbaseIsFinal is now a single submit.
  Its 64-attempt retry loop only existed to get past the random extranonce
  rebuild that PR 2027 removed. The test stores the job through
  storeJobWithCoinbase, finds a nonce for the issued coinbase in advance,
  starting at 1 so a nonce copied into the sequence would show, and submits
  once.
- The invalid-nonce subtest now expects main's "does not meet the target"
  message, from the proof-of-work check main added before block.Valid. The
  median-time-past lookup still runs after that check.
- The CHANGELOG entry for this PR no longer says a coinbase-less solution is
  unminable, and says the job is kept after a finality refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HzSYaAXVb6mbD9mt4iDds
…f-work check

checkCoinbaseTimeLockFinality refused a non-final coinbase without a log
line, while the block.Valid ErrBlockInvalid branch just before it logs a
warning for every consensus refusal. An operator could see the second kind
in the log and not the first. The refusal now logs a warning with the
reason, the parent's median time past and the coinbase nLockTime. icellan
raised this as a minor point in the approving review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HzSYaAXVb6mbD9mt4iDds
@freemans13

Copy link
Copy Markdown
Collaborator Author

Thanks @icellan. Where each point in the approving review stands now:

  • Coinbase-less solutions: PR 2027 landed on main and is merged into this branch in 70ea819. A solution without a coinbase is now mined with the coinbase the job was issued with, so the up-front refusal is no longer needed. This PR's CHANGELOG entry no longer says such a solution is unminable.
  • TestSubmitMiningSolution_RecreatedCoinbaseIsFinal: it is a single submit as of 70ea819. It finds a nonce for the job's issued coinbase in advance and submits once, with no retry loop.
  • No log line on the finality refusal after the PoW check: fixed in 1999dc2. checkCoinbaseTimeLockFinality now logs a warning with the reason, the parent's median time past and the coinbase nLockTime, like the ErrBlockInvalid branch beside it.
  • Placement of the finality check (ValidWithBinding vs the synchronous contextual step): the model/Block.go thread has the analysis. The exposure only exists under the off-by-default blockvalidation_optimistic_mining_peer_blocks. For a body with subtrees, the coinbase is not yet bound to the header at the pre-add point, and BIP34, bad-cb-length and the common coinbase rules share the same exposure. That thread stays open for Stu's call.

The warning and errors from checkCoinbaseTimeLockFinality carried only the
job id. The sibling refusals in submitMiningSolution log the job id and
the block hash, which is what an operator uses to match a refusal to a
solution. The helper now takes the block it is checking and reads the
coinbase, height, timestamp and parent hash from it, so the hash comes for
free, and the call site passes one argument instead of four. The values are
the same ones as before: the block is built from coinbaseTx, nTime,
hashPrevBlock and the job's height just above. The Claude Code Review bot
suggested this as an optional nit on 1999dc2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HzSYaAXVb6mbD9mt4iDds
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants