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.
This commit is contained in:
Boris Nagaev 2025-12-22 19:56:20 -03:00
parent 6803c62599
commit cd4e5426b2
No known key found for this signature in database
8 changed files with 287 additions and 10 deletions

View file

@ -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)

View file

@ -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,

View file

@ -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)
// ---------------------------------------------------------------------

View file

@ -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

218
blockchain/regtest_test.go Normal file
View file

@ -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(&params.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: &params}, 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))
}

View file

@ -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)

View file

@ -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.
},
},

View file

@ -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 {