What changed, and why it matters
This commit changes btcd's regression test network (regtest) rules so they match Bitcoin Core's behavior. It makes several Bitcoin protocol upgrades active from block 1 instead of being delayed or inactive. The change is primarily about test-network compatibility and does not appear to be a security fix for main Bitcoin production networks. It adds tests to confirm regtest now rejects old-style blocks and accepts modern ones, just like Core does.
Treat this as a compatibility and test-coverage change rather than an urgent security patch. Reviewers should verify that the regtest parameter changes do not accidentally affect mainnet or testnet3 parameters, and that the new tests correctly exercise the intended Core-aligned behavior. No immediate deployment action is required for production nodes.
Security signals we found
Consensus parameter change for regtest: BIP34/65/66 activation heights moved to 1
Deployment definitions updated with AlwaysActiveHeight=1 for CSV, SegWit, Taproot on regtest
Test block version changed from 1 to 4 in test helpers
Coinbase signature scripts padded to max length instead of replaced, preserving serialized height
New contextual validation tests for block versions, buried deployments, and coinbase height
Fullblocktests updated to trigger BIP30 duplicate-txid rejection via a duplicated non-coinbase transaction
Evidence from the diff
The patch aligns btcd’s RegressionNetParams with Bitcoin Core’s regtest activation heights: BIP34, BIP65, and BIP66 are set to height 1, and CSV, SegWit, and Taproot deployments are marked AlwaysActiveHeight=1. Test helpers are updated so generated blocks start at height 1 and use block version 4, satisfying BIP34 coinbase-height and version-floor rules. New tests verify that regtest rejects block versions 1-3, enforces coinbase height serialization, and reports CSV/SegWit/Taproot as active. Existing fullblocktests are adjusted to generate BIP34-compliant coinbase scripts and to duplicate a non-coinbase transaction for the BIP30 duplicate-txid test case.
Changed components
chaincfg/params.go (RegressionNetParams)blockchain/regtest_test.go (new test file)blockchain/common_test.go (test block version)blockchain/fullblocktests/generate.goblockchain/fullblocktests/params.goblockchain/chain_test.goblockchain/utxocache_test.gointegration/bip0009_test.goInspect captured patch +287 / −10
diff --git a/blockchain/chain_test.go b/blockchain/chain_test.go
index b3bccf5..a1fcb9a 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 12badd3..8c647c1 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 2b499c4..a17af45 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 4679036..2e36dce 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 0000000..1f554a5
--- /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 0f410cc..9b3bc99 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 1abe7fc..8aa6c11 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 8f8b59a..5d443d7 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 {
Why this scored 28/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.