From cd4e5426b244346b15b297cee7f176c5a70e19bd Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Mon, 22 Dec 2025 19:56:20 -0300 Subject: [PATCH] regtest: align activations with Bitcoin Core Set regtest buried heights to match Core: BIP34/65/66 at height 1 and make CSV/SegWit/Taproot always active. Added regtest coverage for header version floors, coinbase height enforcement, and deployment state to ensure we now reject the blocks Core rejects and accept the ones Core accepts. Updated package fullblocktests to generate BIP34-compliant blocks. Test helpers now set prev block height to 0 (not the default -1) so generated blocks start at height 1 and satisfy coinbase height rules. --- blockchain/chain_test.go | 8 + blockchain/common_test.go | 2 +- blockchain/fullblocktests/generate.go | 42 ++++- blockchain/fullblocktests/params.go | 6 +- blockchain/regtest_test.go | 218 ++++++++++++++++++++++++++ blockchain/utxocache_test.go | 1 + chaincfg/params.go | 10 +- integration/bip0009_test.go | 10 ++ 8 files changed, 287 insertions(+), 10 deletions(-) create mode 100644 blockchain/regtest_test.go diff --git a/blockchain/chain_test.go b/blockchain/chain_test.go index b3bccf56..a1fcb9af 100644 --- a/blockchain/chain_test.go +++ b/blockchain/chain_test.go @@ -1384,6 +1384,7 @@ func TestInvalidateBlock(t *testing.T) { "invalidate-once") // Grab the tip of the chain. tip := btcutil.NewBlock(params.GenesisBlock) + tip.SetHeight(0) // Create a chain with 11 blocks. _, _, err := addBlocks(11, chain, tip, []*testhelper.SpendableOut{}) @@ -1407,6 +1408,7 @@ func TestInvalidateBlock(t *testing.T) { chain, params, tearDown := utxoCacheTestChain("TestInvalidateBlock-invalidate-twice") // Grab the tip of the chain. tip := btcutil.NewBlock(params.GenesisBlock) + tip.SetHeight(0) // Create a chain with 11 blocks. _, spendableOuts, err := addBlocks(11, chain, tip, []*testhelper.SpendableOut{}) @@ -1453,6 +1455,7 @@ func TestInvalidateBlock(t *testing.T) { chainGen: func() (*BlockChain, []*chainhash.Hash, func()) { chain, params, tearDown := utxoCacheTestChain("TestInvalidateBlock-invalidate-side-branch") tip := btcutil.NewBlock(params.GenesisBlock) + tip.SetHeight(0) // Grab the tip of the chain. tip, err := chain.BlockByHash(&chain.bestChain.Tip().hash) @@ -1635,6 +1638,7 @@ func TestReconsiderBlock(t *testing.T) { // Create a chain with 101 blocks. tip := btcutil.NewBlock(params.GenesisBlock) + tip.SetHeight(0) _, _, err := addBlocks(101, chain, tip, []*testhelper.SpendableOut{}) if err != nil { t.Fatal(err) @@ -1657,6 +1661,7 @@ func TestReconsiderBlock(t *testing.T) { // Create a chain with 101 blocks. tip := btcutil.NewBlock(params.GenesisBlock) + tip.SetHeight(0) _, spendableOuts, err := addBlocks(101, chain, tip, []*testhelper.SpendableOut{}) if err != nil { t.Fatal(err) @@ -1689,6 +1694,7 @@ func TestReconsiderBlock(t *testing.T) { // Create a chain with 101 blocks. tip := btcutil.NewBlock(params.GenesisBlock) + tip.SetHeight(0) _, spendableOuts, err := addBlocks(101, chain, tip, []*testhelper.SpendableOut{}) if err != nil { t.Fatal(err) @@ -1718,6 +1724,7 @@ func TestReconsiderBlock(t *testing.T) { chain, params, tearDown := utxoCacheTestChain("TestReconsiderBlock-reconsider-an-invalid-side-branch-higher") tip := btcutil.NewBlock(params.GenesisBlock) + tip.SetHeight(0) _, spendableOuts, err := addBlocks(6, chain, tip, []*testhelper.SpendableOut{}) if err != nil { t.Fatal(err) @@ -1752,6 +1759,7 @@ func TestReconsiderBlock(t *testing.T) { chain, params, tearDown := utxoCacheTestChain("TestReconsiderBlock-reconsider-an-invalid-side-branch-lower") tip := btcutil.NewBlock(params.GenesisBlock) + tip.SetHeight(0) _, spendableOuts, err := addBlocks(6, chain, tip, []*testhelper.SpendableOut{}) if err != nil { t.Fatal(err) diff --git a/blockchain/common_test.go b/blockchain/common_test.go index 12badd3e..8c647c12 100644 --- a/blockchain/common_test.go +++ b/blockchain/common_test.go @@ -465,7 +465,7 @@ func newBlock(chain *BlockChain, prev *btcutil.Block, // SolveBlock. block := btcutil.NewBlock(&wire.MsgBlock{ Header: wire.BlockHeader{ - Version: 1, + Version: 4, PrevBlock: *prev.Hash(), MerkleRoot: calcMerkleRoot(txns), Bits: chain.chainParams.PowLimitBits, diff --git a/blockchain/fullblocktests/generate.go b/blockchain/fullblocktests/generate.go index 2b499c4e..a17af451 100644 --- a/blockchain/fullblocktests/generate.go +++ b/blockchain/fullblocktests/generate.go @@ -286,6 +286,30 @@ func replaceCoinbaseSigScript(script []byte) func(*wire.MsgBlock) { } } +// padCoinbaseSigScript returns a function that pads the existing coinbase +// signature script with OP_0 until it reaches the provided length. It keeps the +// existing prefix (including the serialized height) intact. +func padCoinbaseSigScript(targetLen int) func(*wire.MsgBlock) { + return func(b *wire.MsgBlock) { + sigScript := b.Transactions[0].TxIn[0].SignatureScript + if len(sigScript) > targetLen { + panic(fmt.Sprintf("padCoinbaseSigScript: script len "+ + "%d > target %d", len(sigScript), targetLen)) + } + + if len(sigScript) == targetLen { + return + } + + padding := bytes.Repeat( + []byte{txscript.OP_0}, targetLen-len(sigScript), + ) + b.Transactions[0].TxIn[0].SignatureScript = append( + sigScript, padding..., + ) + } +} + // additionalTx returns a function that itself takes a block and modifies it by // adding the provided transaction. func additionalTx(tx *wire.MsgTx) func(*wire.MsgBlock) { @@ -355,7 +379,7 @@ func (g *testGenerator) nextBlock(blockName string, spend *testhelper.SpendableO block := wire.MsgBlock{ Header: wire.BlockHeader{ - Version: 1, + Version: 4, PrevBlock: g.tip.BlockHash(), MerkleRoot: calcMerkleRoot(txns), Bits: g.params.PowLimitBits, @@ -1044,8 +1068,7 @@ func Generate(includeLargeReorg bool) (tests [][]TestInstance, err error) { // // ... -> b23(6) -> b30(7) g.setTip("b23") - maxSizeCbScript := repeatOpcode(0x00, maxCoinbaseScriptLen) - g.nextBlock("b30", outs[7], replaceCoinbaseSigScript(maxSizeCbScript)) + g.nextBlock("b30", outs[7], padCoinbaseSigScript(maxCoinbaseScriptLen)) accepted() // --------------------------------------------------------------------- @@ -1576,6 +1599,19 @@ func Generate(includeLargeReorg bool) (tests [][]TestInstance, err error) { parent := g.blocks[b.Header.PrevBlock] b.Transactions[0] = parent.Transactions[0] }) + rejected(blockchain.ErrBadCoinbaseHeight) + + // Create block that duplicates a non-coinbase transaction from an + // earlier block to trigger BIP30 rejection (duplicate txid in UTXO). + // + // ... -> b60(17) + // \-> b61dup(18) + g.setTip("b60") + g.nextBlock("b61dup", outs[18], func(b *wire.MsgBlock) { + parent := g.blocks[b.Header.PrevBlock] + dupTx := parent.Transactions[1].Copy() + b.AddTransaction(dupTx) + }) rejected(blockchain.ErrOverwriteTx) // --------------------------------------------------------------------- diff --git a/blockchain/fullblocktests/params.go b/blockchain/fullblocktests/params.go index 4679036f..2e36dcee 100644 --- a/blockchain/fullblocktests/params.go +++ b/blockchain/fullblocktests/params.go @@ -103,9 +103,9 @@ var regressionNetParams = &chaincfg.Params{ PowLimit: regressionPowLimit, PowLimitBits: 0x207fffff, CoinbaseMaturity: 100, - BIP0034Height: 100000000, // Not active - Permit ver 1 blocks - BIP0065Height: 1351, // Used by regression tests - BIP0066Height: 1251, // Used by regression tests + BIP0034Height: 1, + BIP0065Height: 1, + BIP0066Height: 1, SubsidyReductionInterval: 150, TargetTimespan: time.Hour * 24 * 14, // 14 days TargetTimePerBlock: time.Minute * 10, // 10 minutes diff --git a/blockchain/regtest_test.go b/blockchain/regtest_test.go new file mode 100644 index 00000000..1f554a57 --- /dev/null +++ b/blockchain/regtest_test.go @@ -0,0 +1,218 @@ +package blockchain + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" +) + +// stubChainCtx provides the minimal ChainCtx implementation needed for header +// context checks in tests. +type stubChainCtx struct { + params *chaincfg.Params +} + +// ChainParams returns the active chain parameters. +func (s stubChainCtx) ChainParams() *chaincfg.Params { + return s.params +} + +// BlocksPerRetarget returns the blocks per difficulty retarget. +func (s stubChainCtx) BlocksPerRetarget() int32 { + return int32(s.params.TargetTimespan / s.params.TargetTimePerBlock) +} + +// MinRetargetTimespan returns the lower bound of the retarget timespan. +func (s stubChainCtx) MinRetargetTimespan() int64 { + return int64( + s.params.TargetTimespan / + time.Duration(s.params.RetargetAdjustmentFactor), + ) +} + +// MaxRetargetTimespan returns the upper bound of the retarget timespan. +func (s stubChainCtx) MaxRetargetTimespan() int64 { + return int64( + s.params.TargetTimespan * + time.Duration(s.params.RetargetAdjustmentFactor), + ) +} + +// VerifyCheckpoint reports whether a checkpoint matches. +func (s stubChainCtx) VerifyCheckpoint(_ int32, _ *chainhash.Hash) bool { + return true +} + +// FindPreviousCheckpoint returns the last known checkpoint. +func (s stubChainCtx) FindPreviousCheckpoint() (HeaderCtx, error) { + return nil, nil +} + +// regtestPrevNode returns a blockNode for the regtest genesis block to serve +// as the parent of height-1 test headers. +func regtestPrevNode(t *testing.T) *blockNode { + t.Helper() + + params := chaincfg.RegressionNetParams + + return newBlockNode(¶ms.GenesisBlock.Header, nil) +} + +// TestRegtestBlockVersions ensures regtest enforces BIP34/66/65 version floors +// from height 1. +func TestRegtestBlockVersions(t *testing.T) { + params := chaincfg.RegressionNetParams + prevNode := regtestPrevNode(t) + + testCases := []struct { + name string + version int32 + wantErr ErrorCode + }{ + { + name: "v1_rejected", + version: 1, + wantErr: ErrBlockVersionTooOld, + }, + { + name: "v2_rejected", + version: 2, + wantErr: ErrBlockVersionTooOld, + }, + { + name: "v3_rejected", + version: 3, + wantErr: ErrBlockVersionTooOld, + }, + { + name: "v4_allowed", + version: 4, + wantErr: ErrorCode(0), + }, + { + name: "vb_signal_allowed", + version: 0x20000000, + wantErr: ErrorCode(0), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + block := &wire.BlockHeader{ + Version: tc.version, + PrevBlock: *params.GenesisHash, + Bits: params.PowLimitBits, + Timestamp: time.Unix(prevNode.Timestamp()+1, 0), + } + + err := CheckBlockHeaderContext( + block, prevNode, BFFastAdd, + stubChainCtx{params: ¶ms}, true, + ) + + if tc.wantErr == 0 { + require.NoError(t, err) + + return + } + + require.Error(t, err) + + var rErr RuleError + require.ErrorAs(t, err, &rErr) + require.Equal(t, tc.wantErr, rErr.ErrorCode) + }) + } +} + +// TestRegtestBuriedDeploymentsAlwaysActive asserts CSV, SegWit, and Taproot +// are always active on regtest, mirroring Bitcoin Core. +func TestRegtestBuriedDeploymentsAlwaysActive(t *testing.T) { + chain := newFakeChain(&chaincfg.RegressionNetParams) + prevNode := chain.bestChain.Tip() + + stateCSV, err := chain.deploymentState(prevNode, chaincfg.DeploymentCSV) + require.NoError(t, err) + require.Equal(t, ThresholdActive, stateCSV) + + stateSegwit, err := chain.deploymentState( + prevNode, chaincfg.DeploymentSegwit, + ) + require.NoError(t, err) + require.Equal(t, ThresholdActive, stateSegwit) + + stateTaproot, err := chain.deploymentState( + prevNode, chaincfg.DeploymentTaproot, + ) + require.NoError(t, err) + require.Equal(t, ThresholdActive, stateTaproot) +} + +// TestRegtestRejectsCoinbaseMissingHeight ensures contextual validation +// enforces coinbase height serialization on regtest. +func TestRegtestRejectsCoinbaseMissingHeight(t *testing.T) { + chain := newFakeChain(&chaincfg.RegressionNetParams) + prevNode := chain.bestChain.Tip() + + block := wire.MsgBlock{ + Header: wire.BlockHeader{ + Version: 4, + PrevBlock: prevNode.hash, + Bits: chain.chainParams.PowLimitBits, + Timestamp: time.Unix(prevNode.timestamp+1, 0), + }, + } + + coinbase := wire.NewMsgTx(wire.TxVersion) + coinbase.AddTxIn(&wire.TxIn{SignatureScript: []byte{}}) + coinbase.AddTxOut(&wire.TxOut{Value: 0}) + block.AddTransaction(coinbase) + block.Header.MerkleRoot = block.Transactions[0].TxHash() + + btcBlock := btcutil.NewBlock(&block) + + // The block without a height in coinbase is not considered valid. + err := chain.checkBlockContext(btcBlock, prevNode, BFNone) + require.Error(t, err) + + // Make sure the error is in coinbase height record. + var rErr RuleError + require.ErrorAs(t, err, &rErr) + require.Equal(t, ErrMissingCoinbaseHeight, rErr.ErrorCode) +} + +// TestRegtestAcceptsCoinbaseHeight ensures a properly encoded coinbase height +// passes contextual checks on regtest. +func TestRegtestAcceptsCoinbaseHeight(t *testing.T) { + chain := newFakeChain(&chaincfg.RegressionNetParams) + prevNode := chain.bestChain.Tip() + + coinbaseScript, err := txscript.NewScriptBuilder().AddInt64(1).Script() + require.NoError(t, err) + + block := wire.MsgBlock{ + Header: wire.BlockHeader{ + Version: 4, + PrevBlock: prevNode.hash, + Bits: chain.chainParams.PowLimitBits, + Timestamp: time.Unix(prevNode.timestamp+1, 0), + }, + } + + coinbase := wire.NewMsgTx(wire.TxVersion) + coinbase.AddTxIn(&wire.TxIn{SignatureScript: coinbaseScript}) + coinbase.AddTxOut(&wire.TxOut{Value: 0}) + block.AddTransaction(coinbase) + block.Header.MerkleRoot = block.Transactions[0].TxHash() + + btcBlock := btcutil.NewBlock(&block) + + // Make sure the block with the height in coinbase is considered valid. + require.NoError(t, chain.checkBlockContext(btcBlock, prevNode, BFNone)) +} diff --git a/blockchain/utxocache_test.go b/blockchain/utxocache_test.go index 0f410cc9..9b3bc992 100644 --- a/blockchain/utxocache_test.go +++ b/blockchain/utxocache_test.go @@ -405,6 +405,7 @@ func TestUtxoCacheFlush(t *testing.T) { defer tearDown() cache := chain.utxoCache tip := btcutil.NewBlock(params.GenesisBlock) + tip.SetHeight(0) // The chainSetup init triggers the consistency status write. err := assertConsistencyState(chain, params.GenesisHash) diff --git a/chaincfg/params.go b/chaincfg/params.go index 1abe7fc8..8aa6c110 100644 --- a/chaincfg/params.go +++ b/chaincfg/params.go @@ -481,9 +481,9 @@ var RegressionNetParams = Params{ PowLimitBits: 0x207fffff, PoWNoRetargeting: true, CoinbaseMaturity: 100, - BIP0034Height: 100000000, // Not active - Permit ver 1 blocks - BIP0065Height: 1351, // Used by regression tests - BIP0066Height: 1251, // Used by regression tests + BIP0034Height: 1, + BIP0065Height: 1, + BIP0066Height: 1, SubsidyReductionInterval: 150, TargetTimespan: time.Hour * 24 * 14, // 14 days TargetTimePerBlock: time.Minute * 10, // 10 minutes @@ -540,6 +540,7 @@ var RegressionNetParams = Params{ DeploymentEnder: NewMedianTimeDeploymentEnder( time.Time{}, // Never expires ), + AlwaysActiveHeight: 1, }, DeploymentSegwit: { BitNumber: 1, @@ -549,6 +550,7 @@ var RegressionNetParams = Params{ DeploymentEnder: NewMedianTimeDeploymentEnder( time.Time{}, // Never expires. ), + AlwaysActiveHeight: 1, }, DeploymentTaproot: { BitNumber: 2, @@ -558,6 +560,8 @@ var RegressionNetParams = Params{ DeploymentEnder: NewMedianTimeDeploymentEnder( time.Time{}, // Never expires. ), + MinActivationHeight: 0, + AlwaysActiveHeight: 1, CustomActivationThreshold: 108, // Only needs 75% hash rate. }, }, diff --git a/integration/bip0009_test.go b/integration/bip0009_test.go index 8f8b59a5..5d443d71 100644 --- a/integration/bip0009_test.go +++ b/integration/bip0009_test.go @@ -139,6 +139,16 @@ func testBIP0009(t *testing.T, forkKey string, deploymentID uint32) { } defer r.TearDown() + // Short-circuit deployments that are configured as always active. + if deploymentID < uint32(len(r.ActiveNet.Deployments)) { + dep := &r.ActiveNet.Deployments[deploymentID] + if dep.AlwaysActiveHeight != 0 { + assertChainHeight(r, t, 0) + assertSoftForkStatus(r, t, forkKey, blockchain.ThresholdActive) + return + } + } + // If the deployment is meant to be always active, then it should be // active from the very first block. if deploymentID == chaincfg.DeploymentTestDummyAlwaysActive {