From 6befd399ec96378d93520d2cd50aa36c2ebc0f8e Mon Sep 17 00:00:00 2001 From: Lucas Ontivero Date: Wed, 12 Aug 2026 21:07:51 -0300 Subject: [PATCH 1/3] Enforce EnforceBIP94 difficult adjustment rule --- NBitcoin/Bitcoin.Testnet4.cs | 1 + NBitcoin/ChainedBlock.cs | 7 +++++-- NBitcoin/Network.cs | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/NBitcoin/Bitcoin.Testnet4.cs b/NBitcoin/Bitcoin.Testnet4.cs index 1be3aba916..ca9c49b544 100644 --- a/NBitcoin/Bitcoin.Testnet4.cs +++ b/NBitcoin/Bitcoin.Testnet4.cs @@ -65,6 +65,7 @@ private Network CreateTestnet4() SupportTaproot = true, SupportSegwit = true, CoinType = 1, + EnforceBIP94 = true, // Testnet4 a different difficulty adjustment rule }; // Modify the testnet genesis block so the timestamp is valid for a later start. diff --git a/NBitcoin/ChainedBlock.cs b/NBitcoin/ChainedBlock.cs index 771637234c..f2b6622971 100644 --- a/NBitcoin/ChainedBlock.cs +++ b/NBitcoin/ChainedBlock.cs @@ -1,4 +1,4 @@ -#if NO_NATIVE_BIGNUM +#if NO_NATIVE_BIGNUM using NBitcoin.BouncyCastle.Math; #else using System.Numerics; @@ -431,7 +431,10 @@ public Target GetWorkRequired(Consensus consensus) nActualTimespan = TimeSpan.FromTicks(consensus.PowTargetTimespan.Ticks * 4); // Retarget - var bnNew = pindexLast.Header.Bits.ToBigInteger(); + // TestNet4 (BIP 94) uses the first block of the previous period instead of the last block. + var baseBlock = consensus.EnforceBIP94 ? pindexFirst : pindexLast; + var bnNew = baseBlock.Header.Bits.ToBigInteger(); + #if NO_NATIVE_BIGNUM bnNew = bnNew.Multiply(BigInteger.ValueOf((long)nActualTimespan.TotalSeconds)); bnNew = bnNew.Divide(BigInteger.ValueOf((long)consensus.PowTargetTimespan.TotalSeconds)); diff --git a/NBitcoin/Network.cs b/NBitcoin/Network.cs index 215554dc26..3c487fca53 100644 --- a/NBitcoin/Network.cs +++ b/NBitcoin/Network.cs @@ -674,6 +674,20 @@ public bool NeverNeedPreviousTxForSigning } } + bool _EnforceBIP94; + public bool EnforceBIP94 + { + get + { + return _EnforceBIP94; + } + set + { + EnsureNotFrozen(); + _EnforceBIP94 = value; + } + } + public virtual Consensus Clone() { var consensus = new Consensus(); @@ -715,6 +729,7 @@ protected void Fill(Consensus consensus) consensus._SupportSegwit = _SupportSegwit; consensus._SupportTaproot = _SupportTaproot; consensus._NeverNeedPreviousTxForSigning = _NeverNeedPreviousTxForSigning; + consensus._EnforceBIP94 = _EnforceBIP94; } } public partial class Network From e12ddb8f2b452b51435279bd480298168aea34f2 Mon Sep 17 00:00:00 2001 From: Lucas Ontivero Date: Wed, 12 Aug 2026 21:45:32 -0300 Subject: [PATCH 2/3] Add unit tests for BIP 94 (Testnet4) difficulty adjustment Tests verify that: 1. Testnet4 uses the first block of the previous period as the difficulty anchor 2. Min-difficulty blocks (from 20-minute rule) don't distort retargeting 3. Real testnet4 block headers at difficulty boundary (28224, 30239, 30240) --- NBitcoin.Tests/Testnet4PowTests.cs | 80 ++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 NBitcoin.Tests/Testnet4PowTests.cs diff --git a/NBitcoin.Tests/Testnet4PowTests.cs b/NBitcoin.Tests/Testnet4PowTests.cs new file mode 100644 index 0000000000..7eda41c6c8 --- /dev/null +++ b/NBitcoin.Tests/Testnet4PowTests.cs @@ -0,0 +1,80 @@ +using Xunit; + +namespace NBitcoin.Tests +{ + /// + /// Tests for BIP 94 (Testnet4) difficulty adjustment rules. + /// BIP 94 uses the FIRST block of the previous period as the difficulty anchor, not the LAST. + /// + public class Testnet4PowTests + { + // Real testnet4 headers at difficulty boundary where BIP 94 matters: + // Block 28224 (first of period): nBits=0x1a0082a5, Block 30239 (last): nBits=0x1d00ffff (min diff!) + // Block 30240's difficulty 0x1973b070 was calculated from 28224, NOT from 30239's min-difficulty. + const string Header28224 = "0060992d545f75503d65d04310b1904921b09b6f98c0674c20a84f2d6e00000000000000fe7f1811f7694b3cff4ed7a8ef0447fb59139d763e1a58c6ffbd6198eae38e83c1cf5c66a582001a2c76f01d"; + const string Header30239 = "0000002092e737868048420bb08385b9937cec6b2f77784509a0995507000000000000002e4de945b5edd021ad3420e46a7ebe5dbd5d9bc5d15b319dec8f9afe3fde9334de276d66ffff001db5b35b90"; + const string Header30240 = "00805222bbde84820b42d69d70d6b4c45d5ec570ed2a891436833d25eae0f8ea0000000026a7ae7a6e174ca8bb25e9d3d3063b2c54dba4fb16012108d7c5ffff27957e43752a6d6670b073194c48554f"; + + [Fact] + [Trait("UnitTest", "UnitTest")] + public void CanCalculateTestnet4DifficultyWithBIP94() + { + var network = Network.TestNet4; + Assert.True(network.Consensus.EnforceBIP94); + + var header28224 = BlockHeader.Parse(Header28224, network); + var header30239 = BlockHeader.Parse(Header30239, network); + var header30240 = BlockHeader.Parse(Header30240, network); + + Assert.Equal(0x1a0082a5u, header28224.Bits.ToCompact()); + Assert.Equal(0x1d00ffffu, header30239.Bits.ToCompact()); + Assert.Equal(0x1973b070u, header30240.Bits.ToCompact()); + + var block30240 = BuildTestChain(header28224, header30239, header30240, network.Consensus); + Assert.Equal(header30240.Bits, block30240.GetWorkRequired(network.Consensus)); + } + + [Fact] + [Trait("UnitTest", "UnitTest")] + public void BIP94PreventsMinDifficultyFromDistortingRetarget() + { + var network = Network.TestNet4; + var header28224 = BlockHeader.Parse(Header28224, network); + var header30239 = BlockHeader.Parse(Header30239, network); + var header30240 = BlockHeader.Parse(Header30240, network); + + var block30240 = BuildTestChain(header28224, header30239, header30240, network.Consensus); + + var targetWithBIP94 = block30240.GetWorkRequired(network.Consensus); + + var consensusWithoutBIP94 = network.Consensus.Clone(); + consensusWithoutBIP94.EnforceBIP94 = false; + var targetWithoutBIP94 = block30240.GetWorkRequired(consensusWithoutBIP94); + + Assert.Equal(new Target(0x1973b070), targetWithBIP94); + Assert.True(targetWithoutBIP94 > targetWithBIP94); + } + + private static ChainedBlock BuildTestChain(BlockHeader first, BlockHeader last, BlockHeader next, Consensus consensus) + { + const int firstHeight = 28224; + const int lastHeight = 30239; + + var current = new ChainedBlock(first, firstHeight); + for (int height = firstHeight + 1; height <= lastHeight; height++) + { + var header = consensus.ConsensusFactory.CreateBlockHeader(); + header.HashPrevBlock = current.HashBlock; + header.Bits = (height == lastHeight) ? last.Bits : first.Bits; + header.BlockTime = (height == lastHeight) ? last.BlockTime : first.BlockTime; + current = new ChainedBlock(header, header.GetHash(), current); + } + + var nextHeader = consensus.ConsensusFactory.CreateBlockHeader(); + nextHeader.HashPrevBlock = current.HashBlock; + nextHeader.BlockTime = next.BlockTime; + nextHeader.Bits = next.Bits; + return new ChainedBlock(nextHeader, nextHeader.GetHash(), current); + } + } +} \ No newline at end of file From b802fe89a6d56aed87446173dba148e8f54f972e Mon Sep 17 00:00:00 2001 From: Lucas Ontivero Date: Thu, 13 Aug 2026 10:26:01 -0300 Subject: [PATCH 3/3] fixup! Enforce EnforceBIP94 difficult adjustment rule --- NBitcoin/ChainedBlock.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/NBitcoin/ChainedBlock.cs b/NBitcoin/ChainedBlock.cs index f2b6622971..2f83ba2b72 100644 --- a/NBitcoin/ChainedBlock.cs +++ b/NBitcoin/ChainedBlock.cs @@ -1,4 +1,4 @@ -#if NO_NATIVE_BIGNUM +#if NO_NATIVE_BIGNUM using NBitcoin.BouncyCastle.Math; #else using System.Numerics; @@ -431,9 +431,9 @@ public Target GetWorkRequired(Consensus consensus) nActualTimespan = TimeSpan.FromTicks(consensus.PowTargetTimespan.Ticks * 4); // Retarget - // TestNet4 (BIP 94) uses the first block of the previous period instead of the last block. - var baseBlock = consensus.EnforceBIP94 ? pindexFirst : pindexLast; - var bnNew = baseBlock.Header.Bits.ToBigInteger(); + // TestNet4 (BIP 94) uses the first block of the previous period instead of the last block. + var baseBlock = consensus.EnforceBIP94 ? pindexFirst : pindexLast; + var bnNew = baseBlock.Header.Bits.ToBigInteger(); #if NO_NATIVE_BIGNUM bnNew = bnNew.Multiply(BigInteger.ValueOf((long)nActualTimespan.TotalSeconds));