From 7de99187ce97baaa3420ae4b152c539c7bd36086 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Tue, 18 Nov 2025 15:47:18 -0600 Subject: [PATCH 01/34] versionbits: add expiry support to versionbit deployments --- src/consensus/params.h | 3 +++ src/deploymentstatus.h | 10 +++++++++- src/rpc/blockchain.cpp | 11 +++++++++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/consensus/params.h b/src/consensus/params.h index dd29b9408e23..79c3e68e7b23 100644 --- a/src/consensus/params.h +++ b/src/consensus/params.h @@ -52,6 +52,9 @@ struct BIP9Deployment { * boundary. */ int min_activation_height{0}; + /** For temporary softforks: number of blocks the deployment remains active after activation. + * std::numeric_limits::max() means permanent (never expires). */ + int active_duration{std::numeric_limits::max()}; /** Constant for nTimeout very far in the future. */ static constexpr int64_t NO_TIMEOUT = std::numeric_limits::max(); diff --git a/src/deploymentstatus.h b/src/deploymentstatus.h index 03d3c531ccec..550f38154bd6 100644 --- a/src/deploymentstatus.h +++ b/src/deploymentstatus.h @@ -20,7 +20,15 @@ inline bool DeploymentActiveAfter(const CBlockIndex* pindexPrev, const Consensus inline bool DeploymentActiveAfter(const CBlockIndex* pindexPrev, const Consensus::Params& params, Consensus::DeploymentPos dep, VersionBitsCache& versionbitscache) { assert(Consensus::ValidDeployment(dep)); - return ThresholdState::ACTIVE == versionbitscache.State(pindexPrev, params, dep); + if (ThresholdState::ACTIVE != versionbitscache.State(pindexPrev, params, dep)) return false; + + const auto& deployment = params.vDeployments[dep]; + // Permanent deployment (never expires) + if (deployment.active_duration == std::numeric_limits::max()) return true; + + const int activation_height = versionbitscache.StateSinceHeight(pindexPrev, params, dep); + const int height = pindexPrev == nullptr ? 0 : pindexPrev->nHeight + 1; + return height < activation_height + deployment.active_duration; } /** Determine if a deployment is active for this block */ diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp index 89ab885caa20..680a9868704a 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -1729,7 +1729,13 @@ static void SoftForkDescPushBack(const CBlockIndex* blockindex, UniValue& softfo UniValue rv(UniValue::VOBJ); rv.pushKV("type", "bip9"); if (ThresholdState::ACTIVE == next_state) { - rv.pushKV("height", chainman.m_versionbitscache.StateSinceHeight(blockindex, chainman.GetConsensus(), id)); + const int activation_height = chainman.m_versionbitscache.StateSinceHeight(blockindex, chainman.GetConsensus(), id); + rv.pushKV("height", activation_height); + // Add height_end for temporary softforks + const auto& deployment = chainman.GetConsensus().vDeployments[id]; + if (deployment.active_duration < std::numeric_limits::max()) { + rv.pushKV("height_end", activation_height + deployment.active_duration - 1); + } } rv.pushKV("active", ThresholdState::ACTIVE == next_state); rv.pushKV("bip9", std::move(bip9)); @@ -1826,7 +1832,8 @@ RPCHelpMan getblockchaininfo() namespace { const std::vector RPCHelpForDeployment{ {RPCResult::Type::STR, "type", "one of \"buried\", \"bip9\""}, - {RPCResult::Type::NUM, "height", /*optional=*/true, "height of the first block which the rules are or will be enforced (only for \"buried\" type, or \"bip9\" type with \"active\" status)"}, + {RPCResult::Type::NUM, "height", /*optional=*/true, "height of the first block which enforces the rules (only for \"buried\" type, or \"bip9\" type with \"active\" status)"}, + {RPCResult::Type::NUM, "height_end", /*optional=*/true, "height of the last block which enforces the rules (only for \"bip9\" type with \"active\" status and temporary deployments)"}, {RPCResult::Type::BOOL, "active", "true if the rules are enforced for the mempool and the next block"}, {RPCResult::Type::OBJ, "bip9", /*optional=*/true, "status of bip9 softforks (only for \"bip9\" type)", { From a6fc78ad00a48423bedd3a3970292f8a11cc841c Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Tue, 18 Nov 2025 16:05:30 -0600 Subject: [PATCH 02/34] versionbits: add max_activation_height for mandatory BIP9 activation --- src/consensus/params.h | 4 ++++ src/rpc/blockchain.cpp | 4 ++++ src/versionbits.cpp | 9 +++++++++ src/versionbits.h | 1 + 4 files changed, 18 insertions(+) diff --git a/src/consensus/params.h b/src/consensus/params.h index 79c3e68e7b23..76fa1381ae82 100644 --- a/src/consensus/params.h +++ b/src/consensus/params.h @@ -52,6 +52,10 @@ struct BIP9Deployment { * boundary. */ int min_activation_height{0}; + /** Maximum height for activation. If less than INT_MAX, the deployment will activate + * at this height regardless of signaling (similar to BIP8 flag day). + * std::numeric_limits::max() means no maximum (activation only via signaling). */ + int max_activation_height{std::numeric_limits::max()}; /** For temporary softforks: number of blocks the deployment remains active after activation. * std::numeric_limits::max() means permanent (never expires). */ int active_duration{std::numeric_limits::max()}; diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp index 680a9868704a..a7662f46a39d 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -1697,6 +1697,9 @@ static void SoftForkDescPushBack(const CBlockIndex* blockindex, UniValue& softfo bip9.pushKV("start_time", chainman.GetConsensus().vDeployments[id].nStartTime); bip9.pushKV("timeout", chainman.GetConsensus().vDeployments[id].nTimeout); bip9.pushKV("min_activation_height", chainman.GetConsensus().vDeployments[id].min_activation_height); + if (chainman.GetConsensus().vDeployments[id].max_activation_height < std::numeric_limits::max()) { + bip9.pushKV("max_activation_height", chainman.GetConsensus().vDeployments[id].max_activation_height); + } // BIP9 status bip9.pushKV("status", get_state_name(current_state)); @@ -1841,6 +1844,7 @@ const std::vector RPCHelpForDeployment{ {RPCResult::Type::NUM_TIME, "start_time", "the minimum median time past of a block at which the bit gains its meaning"}, {RPCResult::Type::NUM_TIME, "timeout", "the median time past of a block at which the deployment is considered failed if not yet locked in"}, {RPCResult::Type::NUM, "min_activation_height", "minimum height of blocks for which the rules may be enforced"}, + {RPCResult::Type::NUM, "max_activation_height", /*optional=*/true, "height at which the deployment will unconditionally activate (absent for miner-vetoable deployments)"}, {RPCResult::Type::STR, "status", "status of deployment at specified block (one of \"defined\", \"started\", \"locked_in\", \"active\", \"failed\")"}, {RPCResult::Type::NUM, "since", "height of the first block to which the status applies"}, {RPCResult::Type::STR, "status_next", "status of deployment at the next block"}, diff --git a/src/versionbits.cpp b/src/versionbits.cpp index fa9d1fe9c99e..d37c0139e716 100644 --- a/src/versionbits.cpp +++ b/src/versionbits.cpp @@ -11,6 +11,7 @@ ThresholdState AbstractThresholdConditionChecker::GetStateFor(const CBlockIndex* int nPeriod = Period(params); int nThreshold = Threshold(params); int min_activation_height = MinActivationHeight(params); + int max_activation_height = MaxActivationHeight(params); int64_t nTimeStart = BeginTime(params); int64_t nTimeTimeout = EndTime(params); @@ -74,8 +75,15 @@ ThresholdState AbstractThresholdConditionChecker::GetStateFor(const CBlockIndex* pindexCount = pindexCount->pprev; } if (count >= nThreshold) { + // Normal BIP9 activation via signaling + stateNext = ThresholdState::LOCKED_IN; + } else if (max_activation_height < std::numeric_limits::max() && pindexPrev->nHeight + 1 >= max_activation_height - nPeriod) { + // Force LOCKED_IN one period before max_activation_height + // This ensures activation happens AT max_activation_height (not one period later) + // Overrides timeout to guarantee activation stateNext = ThresholdState::LOCKED_IN; } else if (pindexPrev->GetMedianTimePast() >= nTimeTimeout) { + // Timeout without activation stateNext = ThresholdState::FAILED; } break; @@ -185,6 +193,7 @@ class VersionBitsConditionChecker : public AbstractThresholdConditionChecker { int64_t BeginTime(const Consensus::Params& params) const override { return params.vDeployments[id].nStartTime; } int64_t EndTime(const Consensus::Params& params) const override { return params.vDeployments[id].nTimeout; } int MinActivationHeight(const Consensus::Params& params) const override { return params.vDeployments[id].min_activation_height; } + int MaxActivationHeight(const Consensus::Params& params) const override { return params.vDeployments[id].max_activation_height; } int Period(const Consensus::Params& params) const override { return params.nMinerConfirmationWindow; } int Threshold(const Consensus::Params& params) const override { return params.nRuleChangeActivationThreshold; } diff --git a/src/versionbits.h b/src/versionbits.h index 09313d2054a5..82c11ce12563 100644 --- a/src/versionbits.h +++ b/src/versionbits.h @@ -60,6 +60,7 @@ class AbstractThresholdConditionChecker { virtual int64_t BeginTime(const Consensus::Params& params) const =0; virtual int64_t EndTime(const Consensus::Params& params) const =0; virtual int MinActivationHeight(const Consensus::Params& params) const { return 0; } + virtual int MaxActivationHeight(const Consensus::Params& params) const { return std::numeric_limits::max(); } virtual int Period(const Consensus::Params& params) const =0; virtual int Threshold(const Consensus::Params& params) const =0; From 8cd9085adac153d6ca7f583645afc2f803d3a953 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Tue, 18 Nov 2025 22:46:30 -0600 Subject: [PATCH 03/34] chainparams: support regtest vbparams for max_activation_height and active_duration --- src/chainparams.cpp | 20 +++++++++++++++++--- src/kernel/chainparams.cpp | 2 ++ src/kernel/chainparams.h | 2 ++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/chainparams.cpp b/src/chainparams.cpp index 7290b31479a1..10b0947bedeb 100644 --- a/src/chainparams.cpp +++ b/src/chainparams.cpp @@ -77,8 +77,8 @@ void ReadRegTestArgs(const ArgsManager& args, CChainParams::RegTestOptions& opti for (const std::string& strDeployment : args.GetArgs("-vbparams")) { std::vector vDeploymentParams = SplitString(strDeployment, ':'); - if (vDeploymentParams.size() < 3 || 4 < vDeploymentParams.size()) { - throw std::runtime_error("Version bits parameters malformed, expecting deployment:start:end[:min_activation_height]"); + if (vDeploymentParams.size() < 3 || 6 < vDeploymentParams.size()) { + throw std::runtime_error("Version bits parameters malformed, expecting deployment:start:end[:min_activation_height[:max_activation_height[:active_duration]]]"); } CChainParams::VersionBitsParameters vbparams{}; if (!ParseInt64(vDeploymentParams[1], &vbparams.start_time)) { @@ -94,12 +94,26 @@ void ReadRegTestArgs(const ArgsManager& args, CChainParams::RegTestOptions& opti } else { vbparams.min_activation_height = 0; } + if (vDeploymentParams.size() >= 5) { + if (!ParseInt32(vDeploymentParams[4], &vbparams.max_activation_height)) { + throw std::runtime_error(strprintf("Invalid max_activation_height (%s)", vDeploymentParams[4])); + } + } + if (vDeploymentParams.size() >= 6) { + if (!ParseInt32(vDeploymentParams[5], &vbparams.active_duration)) { + throw std::runtime_error(strprintf("Invalid active_duration (%s)", vDeploymentParams[5])); + } + } + // Validate that timeout and max_activation_height are mutually exclusive + if (vbparams.timeout != Consensus::BIP9Deployment::NO_TIMEOUT && vbparams.max_activation_height < std::numeric_limits::max()) { + throw std::runtime_error(strprintf("Cannot specify both timeout (%ld) and max_activation_height (%d) for deployment %s. Use timeout for BIP9 or max_activation_height for mandatory activation deadline, not both.", vbparams.timeout, vbparams.max_activation_height, vDeploymentParams[0])); + } bool found = false; for (int j=0; j < (int)Consensus::MAX_VERSION_BITS_DEPLOYMENTS; ++j) { if (vDeploymentParams[0] == VersionBitsDeploymentInfo[j].name) { options.version_bits_parameters[Consensus::DeploymentPos(j)] = vbparams; found = true; - LogPrintf("Setting version bits activation parameters for %s to start=%ld, timeout=%ld, min_activation_height=%d\n", vDeploymentParams[0], vbparams.start_time, vbparams.timeout, vbparams.min_activation_height); + LogPrintf("Setting version bits activation parameters for %s to start=%ld, timeout=%ld, min_activation_height=%d, max_activation_height=%d, active_duration=%d\n", vDeploymentParams[0], vbparams.start_time, vbparams.timeout, vbparams.min_activation_height, vbparams.max_activation_height, vbparams.active_duration); break; } } diff --git a/src/kernel/chainparams.cpp b/src/kernel/chainparams.cpp index 861850b8e5c7..454a7ed2b18b 100644 --- a/src/kernel/chainparams.cpp +++ b/src/kernel/chainparams.cpp @@ -628,6 +628,8 @@ class CRegTestParams : public CChainParams consensus.vDeployments[deployment_pos].nStartTime = version_bits_params.start_time; consensus.vDeployments[deployment_pos].nTimeout = version_bits_params.timeout; consensus.vDeployments[deployment_pos].min_activation_height = version_bits_params.min_activation_height; + consensus.vDeployments[deployment_pos].max_activation_height = version_bits_params.max_activation_height; + consensus.vDeployments[deployment_pos].active_duration = version_bits_params.active_duration; } genesis = CreateGenesisBlock(1296688602, 2, 0x207fffff, 1, 50 * COIN); diff --git a/src/kernel/chainparams.h b/src/kernel/chainparams.h index a30097ca964c..4e70a3392e40 100644 --- a/src/kernel/chainparams.h +++ b/src/kernel/chainparams.h @@ -153,6 +153,8 @@ class CChainParams int64_t start_time; int64_t timeout; int min_activation_height; + int max_activation_height{std::numeric_limits::max()}; + int active_duration{std::numeric_limits::max()}; }; /** From b492df602179b818d1bfc6eea3d2ad04922de766 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Mon, 26 Jan 2026 22:37:45 -0600 Subject: [PATCH 04/34] test: add versionbits unit tests for max_activation_height and active_duration --- src/test/versionbits_tests.cpp | 260 +++++++++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) diff --git a/src/test/versionbits_tests.cpp b/src/test/versionbits_tests.cpp index 29240a45f098..7028a3d2c6db 100644 --- a/src/test/versionbits_tests.cpp +++ b/src/test/versionbits_tests.cpp @@ -461,4 +461,264 @@ BOOST_FIXTURE_TEST_CASE(versionbits_computeblockversion, BlockVersionTest) } } +/** + * Test condition checker with max_activation_height for mandatory activation deadline. + * When max_activation_height is set, the deployment forces LOCKED_IN one period before + * max_activation_height, even if threshold signaling was not met. + */ +class TestMaxActivationHeightConditionChecker : public AbstractThresholdConditionChecker +{ +private: + mutable ThresholdConditionCache cache; + int m_max_activation_height; + +public: + explicit TestMaxActivationHeightConditionChecker(int max_height) : m_max_activation_height(max_height) {} + + int64_t BeginTime(const Consensus::Params& params) const override { return 0; } // Start immediately + int64_t EndTime(const Consensus::Params& params) const override { return Consensus::BIP9Deployment::NO_TIMEOUT; } + int Period(const Consensus::Params& params) const override { return 144; } + int Threshold(const Consensus::Params& params) const override { return 108; } // 75% + int MaxActivationHeight(const Consensus::Params& params) const override { return m_max_activation_height; } + bool Condition(const CBlockIndex* pindex, const Consensus::Params& params) const override { return (pindex->nVersion & 0x100); } + + ThresholdState GetStateFor(const CBlockIndex* pindexPrev) const { return AbstractThresholdConditionChecker::GetStateFor(pindexPrev, paramsDummy, cache); } + int GetStateSinceHeightFor(const CBlockIndex* pindexPrev) const { return AbstractThresholdConditionChecker::GetStateSinceHeightFor(pindexPrev, paramsDummy, cache); } + void ClearCache() { cache.clear(); } +}; + +BOOST_AUTO_TEST_CASE(versionbits_max_activation_height) +{ + // Test that max_activation_height forces LOCKED_IN one period before max_activation_height + // even without sufficient signaling. + // + // Timeline with period=144, max_activation_height=432: + // - Period 0 (0-143): DEFINED + // - Period 1 (144-287): STARTED (no signaling -> normally would stay STARTED) + // - Period 2 (288-431): LOCKED_IN (forced because 288 >= 432 - 144) + // - Period 3 (432+): ACTIVE + + std::vector blocks; + auto cleanup = [&blocks]() { + for (auto* b : blocks) delete b; + blocks.clear(); + }; + + // max_activation_height = 432 (period 3 start) + TestMaxActivationHeightConditionChecker checker(432); + + // Helper to create blocks + auto mine_block = [&blocks](int32_t nVersion) -> CBlockIndex* { + CBlockIndex* pindex = new CBlockIndex(); + pindex->nHeight = blocks.size(); + pindex->pprev = blocks.empty() ? nullptr : blocks.back(); + pindex->nTime = 1415926536 + 600 * pindex->nHeight; + pindex->nVersion = nVersion; + pindex->BuildSkip(); + blocks.push_back(pindex); + return pindex; + }; + + // Mine through period 0 (DEFINED) - 144 blocks (0-143) + for (int i = 0; i < 144; i++) { + mine_block(0); // No signaling + } + BOOST_CHECK_EQUAL(blocks.back()->nHeight, 143); + // At tip 143, next block (144) would be STARTED + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::STARTED); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 144); + + // Mine through period 1 (STARTED) without signaling - blocks 144-287 + for (int i = 0; i < 144; i++) { + mine_block(0); // No signaling + } + BOOST_CHECK_EQUAL(blocks.back()->nHeight, 287); + // At tip 287, next block (288) would be LOCKED_IN due to max_activation_height + // 288 >= 432 - 144, so forced LOCKED_IN + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::LOCKED_IN); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 288); + + // Mine through period 2 (LOCKED_IN) - blocks 288-431 + for (int i = 0; i < 144; i++) { + mine_block(0); + } + BOOST_CHECK_EQUAL(blocks.back()->nHeight, 431); + // At tip 431, next block (432) would be ACTIVE + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::ACTIVE); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 432); + + // Mine into period 3 (ACTIVE) - blocks 432+ + for (int i = 0; i < 10; i++) { + mine_block(0); + } + BOOST_CHECK_EQUAL(blocks.back()->nHeight, 441); + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::ACTIVE); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 432); + + cleanup(); + + // Test 2: Verify that signaling still works to activate earlier than max_activation_height + TestMaxActivationHeightConditionChecker checker2(1000); // max_activation_height far in future + + // Period 0: DEFINED + for (int i = 0; i < 144; i++) { + mine_block(0); + } + BOOST_CHECK(checker2.GetStateFor(blocks.back()) == ThresholdState::STARTED); + + // Period 1: Signal 108+ blocks (threshold) + for (int i = 0; i < 108; i++) { + mine_block(0x100); // Signal + } + for (int i = 0; i < 36; i++) { + mine_block(0); // No signal + } + BOOST_CHECK_EQUAL(blocks.back()->nHeight, 287); + // Should be LOCKED_IN via signaling, not via max_activation_height + BOOST_CHECK(checker2.GetStateFor(blocks.back()) == ThresholdState::LOCKED_IN); + BOOST_CHECK_EQUAL(checker2.GetStateSinceHeightFor(blocks.back()), 288); + + // Period 2: LOCKED_IN -> ACTIVE + for (int i = 0; i < 144; i++) { + mine_block(0); + } + BOOST_CHECK(checker2.GetStateFor(blocks.back()) == ThresholdState::ACTIVE); + BOOST_CHECK_EQUAL(checker2.GetStateSinceHeightFor(blocks.back()), 432); + + cleanup(); +} + +BOOST_AUTO_TEST_CASE(versionbits_max_activation_height_boundary) +{ + // Test edge case: verify exact boundary where LOCKED_IN is forced + // With period=144 and max_activation_height=432: + // - At height 287, next block is 288, which is >= 432-144=288, so LOCKED_IN + // - At height 286, next block is 287, which is < 288, so would stay STARTED + + std::vector blocks; + auto cleanup = [&blocks]() { + for (auto* b : blocks) delete b; + blocks.clear(); + }; + + TestMaxActivationHeightConditionChecker checker(432); + + auto mine_block = [&blocks](int32_t nVersion) -> CBlockIndex* { + CBlockIndex* pindex = new CBlockIndex(); + pindex->nHeight = blocks.size(); + pindex->pprev = blocks.empty() ? nullptr : blocks.back(); + pindex->nTime = 1415926536 + 600 * pindex->nHeight; + pindex->nVersion = nVersion; + pindex->BuildSkip(); + blocks.push_back(pindex); + return pindex; + }; + + // Mine to height 143 (end of period 0) + for (int i = 0; i < 144; i++) { + mine_block(0); + } + BOOST_CHECK_EQUAL(blocks.back()->nHeight, 143); + // State for block 144 is STARTED + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::STARTED); + + // Mine period 1 without signaling (blocks 144-287) + // But stop at block 286 first to check boundary + for (int i = 0; i < 143; i++) { + mine_block(0); + } + BOOST_CHECK_EQUAL(blocks.back()->nHeight, 286); + // At tip 286, next block 287 is still in STARTED period + // State is still STARTED + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::STARTED); + + // Mine block 287 (last block of period 1) + mine_block(0); + BOOST_CHECK_EQUAL(blocks.back()->nHeight, 287); + // At tip 287, state for next block (288) is computed + // 288 >= 432 - 144 = 288, so LOCKED_IN + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::LOCKED_IN); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 288); + + cleanup(); +} + +BOOST_FIXTURE_TEST_CASE(versionbits_active_duration, BasicTestingSetup) +{ + // Test active_duration parameter via -vbparams + // Format: deployment:start:timeout:min_activation_height:max_activation_height:active_duration + // + // This tests that the parameter is parsed correctly. The actual expiry logic + // is tested in DeploymentActiveAt/DeploymentActiveAfter which use active_duration. + + { + ArgsManager args; + // start=0, timeout=never, min_height=0, max_height=INT_MAX (disabled), active_duration=144 + args.ForceSetArg("-vbparams", "testdummy:0:999999999999:0:2147483647:144"); + const auto chainParams = CreateChainParams(args, ChainType::REGTEST); + const auto& deployment = chainParams->GetConsensus().vDeployments[Consensus::DEPLOYMENT_TESTDUMMY]; + + BOOST_CHECK_EQUAL(deployment.nStartTime, 0); + BOOST_CHECK_EQUAL(deployment.nTimeout, 999999999999); + BOOST_CHECK_EQUAL(deployment.min_activation_height, 0); + BOOST_CHECK_EQUAL(deployment.max_activation_height, std::numeric_limits::max()); + BOOST_CHECK_EQUAL(deployment.active_duration, 144); + } + + { + ArgsManager args; + // Test with max_activation_height set + // start=0, timeout=NO_TIMEOUT, min_height=288, max_height=432, active_duration=1000 + // NO_TIMEOUT = INT64_MAX = 9223372036854775807 + args.ForceSetArg("-vbparams", "testdummy:0:9223372036854775807:288:432:1000"); + const auto chainParams = CreateChainParams(args, ChainType::REGTEST); + const auto& deployment = chainParams->GetConsensus().vDeployments[Consensus::DEPLOYMENT_TESTDUMMY]; + + BOOST_CHECK_EQUAL(deployment.min_activation_height, 288); + BOOST_CHECK_EQUAL(deployment.max_activation_height, 432); + BOOST_CHECK_EQUAL(deployment.active_duration, 1000); + } + + { + ArgsManager args; + // Test permanent deployment (active_duration = INT_MAX) + args.ForceSetArg("-vbparams", "testdummy:0:999999999999:0:2147483647:2147483647"); + const auto chainParams = CreateChainParams(args, ChainType::REGTEST); + const auto& deployment = chainParams->GetConsensus().vDeployments[Consensus::DEPLOYMENT_TESTDUMMY]; + + BOOST_CHECK_EQUAL(deployment.active_duration, std::numeric_limits::max()); + } +} + +BOOST_FIXTURE_TEST_CASE(versionbits_max_activation_height_parsing, BasicTestingSetup) +{ + // Test max_activation_height parameter via -vbparams + + { + ArgsManager args; + // Test with max_activation_height=432 (mandatory activation deadline) + // NO_TIMEOUT = INT64_MAX = 9223372036854775807 + args.ForceSetArg("-vbparams", "testdummy:0:9223372036854775807:0:432:2147483647"); + const auto chainParams = CreateChainParams(args, ChainType::REGTEST); + const auto& deployment = chainParams->GetConsensus().vDeployments[Consensus::DEPLOYMENT_TESTDUMMY]; + + BOOST_CHECK_EQUAL(deployment.max_activation_height, 432); + // active_duration should be permanent when not specified differently + BOOST_CHECK_EQUAL(deployment.active_duration, std::numeric_limits::max()); + } + + { + ArgsManager args; + // Test combined: max_activation_height + active_duration (RDTS) + // NO_TIMEOUT = INT64_MAX = 9223372036854775807 + args.ForceSetArg("-vbparams", "testdummy:0:9223372036854775807:288:576:144"); + const auto chainParams = CreateChainParams(args, ChainType::REGTEST); + const auto& deployment = chainParams->GetConsensus().vDeployments[Consensus::DEPLOYMENT_TESTDUMMY]; + + BOOST_CHECK_EQUAL(deployment.min_activation_height, 288); + BOOST_CHECK_EQUAL(deployment.max_activation_height, 576); + BOOST_CHECK_EQUAL(deployment.active_duration, 144); + } +} + BOOST_AUTO_TEST_SUITE_END() From 88c569ddcdbc4c3ba4b8bcfe58b4793f3b3fe96e Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Mon, 29 Dec 2025 15:14:08 -0600 Subject: [PATCH 05/34] versionbits: add per-deployment activation threshold --- src/consensus/params.h | 3 +++ src/versionbits.cpp | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/consensus/params.h b/src/consensus/params.h index 76fa1381ae82..08f5e599ac0e 100644 --- a/src/consensus/params.h +++ b/src/consensus/params.h @@ -59,6 +59,9 @@ struct BIP9Deployment { /** For temporary softforks: number of blocks the deployment remains active after activation. * std::numeric_limits::max() means permanent (never expires). */ int active_duration{std::numeric_limits::max()}; + /** Per-deployment activation threshold. If 0, uses the global nRuleChangeActivationThreshold. + * Otherwise, specifies the number of blocks required for this specific deployment. */ + int threshold{0}; /** Constant for nTimeout very far in the future. */ static constexpr int64_t NO_TIMEOUT = std::numeric_limits::max(); diff --git a/src/versionbits.cpp b/src/versionbits.cpp index d37c0139e716..bfd9152d3d8d 100644 --- a/src/versionbits.cpp +++ b/src/versionbits.cpp @@ -195,7 +195,11 @@ class VersionBitsConditionChecker : public AbstractThresholdConditionChecker { int MinActivationHeight(const Consensus::Params& params) const override { return params.vDeployments[id].min_activation_height; } int MaxActivationHeight(const Consensus::Params& params) const override { return params.vDeployments[id].max_activation_height; } int Period(const Consensus::Params& params) const override { return params.nMinerConfirmationWindow; } - int Threshold(const Consensus::Params& params) const override { return params.nRuleChangeActivationThreshold; } + int Threshold(const Consensus::Params& params) const override { + // Use per-deployment threshold if set, otherwise fall back to global + int deploymentThreshold = params.vDeployments[id].threshold; + return deploymentThreshold > 0 ? deploymentThreshold : params.nRuleChangeActivationThreshold; + } bool Condition(const CBlockIndex* pindex, const Consensus::Params& params) const override { From becf3de0860168c4561639907fa3642c6e2aa459 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Mon, 29 Dec 2025 15:16:13 -0600 Subject: [PATCH 06/34] chainparams: support regtest vbparams for per-deployment threshold --- src/chainparams.cpp | 11 ++++++++--- src/kernel/chainparams.cpp | 1 + src/kernel/chainparams.h | 1 + 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/chainparams.cpp b/src/chainparams.cpp index 10b0947bedeb..c123fa5be552 100644 --- a/src/chainparams.cpp +++ b/src/chainparams.cpp @@ -77,8 +77,8 @@ void ReadRegTestArgs(const ArgsManager& args, CChainParams::RegTestOptions& opti for (const std::string& strDeployment : args.GetArgs("-vbparams")) { std::vector vDeploymentParams = SplitString(strDeployment, ':'); - if (vDeploymentParams.size() < 3 || 6 < vDeploymentParams.size()) { - throw std::runtime_error("Version bits parameters malformed, expecting deployment:start:end[:min_activation_height[:max_activation_height[:active_duration]]]"); + if (vDeploymentParams.size() < 3 || 7 < vDeploymentParams.size()) { + throw std::runtime_error("Version bits parameters malformed, expecting deployment:start:end[:min_activation_height[:max_activation_height[:active_duration[:threshold]]]]"); } CChainParams::VersionBitsParameters vbparams{}; if (!ParseInt64(vDeploymentParams[1], &vbparams.start_time)) { @@ -104,6 +104,11 @@ void ReadRegTestArgs(const ArgsManager& args, CChainParams::RegTestOptions& opti throw std::runtime_error(strprintf("Invalid active_duration (%s)", vDeploymentParams[5])); } } + if (vDeploymentParams.size() >= 7) { + if (!ParseInt32(vDeploymentParams[6], &vbparams.threshold)) { + throw std::runtime_error(strprintf("Invalid threshold (%s)", vDeploymentParams[6])); + } + } // Validate that timeout and max_activation_height are mutually exclusive if (vbparams.timeout != Consensus::BIP9Deployment::NO_TIMEOUT && vbparams.max_activation_height < std::numeric_limits::max()) { throw std::runtime_error(strprintf("Cannot specify both timeout (%ld) and max_activation_height (%d) for deployment %s. Use timeout for BIP9 or max_activation_height for mandatory activation deadline, not both.", vbparams.timeout, vbparams.max_activation_height, vDeploymentParams[0])); @@ -113,7 +118,7 @@ void ReadRegTestArgs(const ArgsManager& args, CChainParams::RegTestOptions& opti if (vDeploymentParams[0] == VersionBitsDeploymentInfo[j].name) { options.version_bits_parameters[Consensus::DeploymentPos(j)] = vbparams; found = true; - LogPrintf("Setting version bits activation parameters for %s to start=%ld, timeout=%ld, min_activation_height=%d, max_activation_height=%d, active_duration=%d\n", vDeploymentParams[0], vbparams.start_time, vbparams.timeout, vbparams.min_activation_height, vbparams.max_activation_height, vbparams.active_duration); + LogPrintf("Setting version bits activation parameters for %s to start=%ld, timeout=%ld, min_activation_height=%d, max_activation_height=%d, active_duration=%d, threshold=%d\n", vDeploymentParams[0], vbparams.start_time, vbparams.timeout, vbparams.min_activation_height, vbparams.max_activation_height, vbparams.active_duration, vbparams.threshold); break; } } diff --git a/src/kernel/chainparams.cpp b/src/kernel/chainparams.cpp index 454a7ed2b18b..4f117a7b2c7a 100644 --- a/src/kernel/chainparams.cpp +++ b/src/kernel/chainparams.cpp @@ -630,6 +630,7 @@ class CRegTestParams : public CChainParams consensus.vDeployments[deployment_pos].min_activation_height = version_bits_params.min_activation_height; consensus.vDeployments[deployment_pos].max_activation_height = version_bits_params.max_activation_height; consensus.vDeployments[deployment_pos].active_duration = version_bits_params.active_duration; + consensus.vDeployments[deployment_pos].threshold = version_bits_params.threshold; } genesis = CreateGenesisBlock(1296688602, 2, 0x207fffff, 1, 50 * COIN); diff --git a/src/kernel/chainparams.h b/src/kernel/chainparams.h index 4e70a3392e40..09d2ddfee51f 100644 --- a/src/kernel/chainparams.h +++ b/src/kernel/chainparams.h @@ -155,6 +155,7 @@ class CChainParams int min_activation_height; int max_activation_height{std::numeric_limits::max()}; int active_duration{std::numeric_limits::max()}; + int threshold{0}; // 0 means use global nRuleChangeActivationThreshold }; /** From 6a57462d41f846b6b68a5bb4ad2184121a56bf01 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Tue, 10 Feb 2026 20:38:08 -0600 Subject: [PATCH 07/34] versionbits: add EXPIRED state to BIP9 state machine for temporary deployments --- src/kernel/chainparams.cpp | 4 + src/rpc/blockchain.cpp | 3 +- src/rpc/mining.cpp | 1 + src/test/fuzz/versionbits.cpp | 21 +- src/test/versionbits_tests.cpp | 418 ++++++++++++++++++++++++++++++++- src/versionbits.cpp | 41 +++- src/versionbits.h | 4 +- 7 files changed, 482 insertions(+), 10 deletions(-) diff --git a/src/kernel/chainparams.cpp b/src/kernel/chainparams.cpp index 4f117a7b2c7a..2daddfc5af99 100644 --- a/src/kernel/chainparams.cpp +++ b/src/kernel/chainparams.cpp @@ -630,6 +630,10 @@ class CRegTestParams : public CChainParams consensus.vDeployments[deployment_pos].min_activation_height = version_bits_params.min_activation_height; consensus.vDeployments[deployment_pos].max_activation_height = version_bits_params.max_activation_height; consensus.vDeployments[deployment_pos].active_duration = version_bits_params.active_duration; + // Validated here rather than in src/chainparams.cpp because nMinerConfirmationWindow is not yet available at parse time + if (version_bits_params.active_duration != std::numeric_limits::max() && version_bits_params.active_duration % consensus.nMinerConfirmationWindow != 0) { + throw std::runtime_error(strprintf("active_duration (%d) must be a multiple of nMinerConfirmationWindow (%d)", version_bits_params.active_duration, consensus.nMinerConfirmationWindow)); + } consensus.vDeployments[deployment_pos].threshold = version_bits_params.threshold; } diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp index a7662f46a39d..c36ec039d72e 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -1679,6 +1679,7 @@ static void SoftForkDescPushBack(const CBlockIndex* blockindex, UniValue& softfo case ThresholdState::LOCKED_IN: return "locked_in"; case ThresholdState::ACTIVE: return "active"; case ThresholdState::FAILED: return "failed"; + case ThresholdState::EXPIRED: return "expired"; } return "invalid"; }; @@ -1845,7 +1846,7 @@ const std::vector RPCHelpForDeployment{ {RPCResult::Type::NUM_TIME, "timeout", "the median time past of a block at which the deployment is considered failed if not yet locked in"}, {RPCResult::Type::NUM, "min_activation_height", "minimum height of blocks for which the rules may be enforced"}, {RPCResult::Type::NUM, "max_activation_height", /*optional=*/true, "height at which the deployment will unconditionally activate (absent for miner-vetoable deployments)"}, - {RPCResult::Type::STR, "status", "status of deployment at specified block (one of \"defined\", \"started\", \"locked_in\", \"active\", \"failed\")"}, + {RPCResult::Type::STR, "status", "status of deployment at specified block (one of \"defined\", \"started\", \"locked_in\", \"active\", \"failed\", \"expired\")"}, {RPCResult::Type::NUM, "since", "height of the first block to which the status applies"}, {RPCResult::Type::STR, "status_next", "status of deployment at the next block"}, {RPCResult::Type::OBJ, "statistics", /*optional=*/true, "numeric statistics about signalling for a softfork (only for \"started\" and \"locked_in\" status)", diff --git a/src/rpc/mining.cpp b/src/rpc/mining.cpp index eb885657cbe2..f5d82f67f5b7 100644 --- a/src/rpc/mining.cpp +++ b/src/rpc/mining.cpp @@ -1026,6 +1026,7 @@ static UniValue TemplateToJSON(const Consensus::Params& consensusParams, const C switch (state) { case ThresholdState::DEFINED: case ThresholdState::FAILED: + case ThresholdState::EXPIRED: // Not exposed to GBT at all break; case ThresholdState::LOCKED_IN: diff --git a/src/test/fuzz/versionbits.cpp b/src/test/fuzz/versionbits.cpp index c1b0f552ea04..4ec954a862db 100644 --- a/src/test/fuzz/versionbits.cpp +++ b/src/test/fuzz/versionbits.cpp @@ -32,15 +32,17 @@ class TestConditionChecker : public AbstractThresholdConditionChecker const int m_period; const int m_threshold; const int m_min_activation_height; + const int m_active_duration; const int m_bit; - TestConditionChecker(int64_t begin, int64_t end, int period, int threshold, int min_activation_height, int bit) - : m_begin{begin}, m_end{end}, m_period{period}, m_threshold{threshold}, m_min_activation_height{min_activation_height}, m_bit{bit} + TestConditionChecker(int64_t begin, int64_t end, int period, int threshold, int min_activation_height, int active_duration, int bit) + : m_begin{begin}, m_end{end}, m_period{period}, m_threshold{threshold}, m_min_activation_height{min_activation_height}, m_active_duration{active_duration}, m_bit{bit} { assert(m_period > 0); assert(0 <= m_threshold && m_threshold <= m_period); assert(0 <= m_bit && m_bit < 32 && m_bit < VERSIONBITS_NUM_BITS); assert(0 <= m_min_activation_height); + assert(m_active_duration > 0); } bool Condition(const CBlockIndex* pindex, const Consensus::Params& params) const override { return Condition(pindex->nVersion); } @@ -49,6 +51,7 @@ class TestConditionChecker : public AbstractThresholdConditionChecker int Period(const Consensus::Params& params) const override { return m_period; } int Threshold(const Consensus::Params& params) const override { return m_threshold; } int MinActivationHeight(const Consensus::Params& params) const override { return m_min_activation_height; } + int ActiveDuration(const Consensus::Params& params) const override { return m_active_duration; } ThresholdState GetStateFor(const CBlockIndex* pindexPrev) const { return AbstractThresholdConditionChecker::GetStateFor(pindexPrev, dummy_params, m_cache); } int GetStateSinceHeightFor(const CBlockIndex* pindexPrev) const { return AbstractThresholdConditionChecker::GetStateSinceHeightFor(pindexPrev, dummy_params, m_cache); } @@ -168,8 +171,9 @@ FUZZ_TARGET(versionbits, .init = initialize) timeout = fuzzed_data_provider.ConsumeBool() ? Consensus::BIP9Deployment::NO_TIMEOUT : fuzzed_data_provider.ConsumeIntegral(); } int min_activation = fuzzed_data_provider.ConsumeIntegralInRange(0, period * max_periods); + int active_duration = fuzzed_data_provider.ConsumeBool() ? std::numeric_limits::max() : (fuzzed_data_provider.ConsumeIntegralInRange(1, max_periods) * period); - TestConditionChecker checker(start_time, timeout, period, threshold, min_activation, bit); + TestConditionChecker checker(start_time, timeout, period, threshold, min_activation, active_duration, bit); // Early exit if the versions don't signal sensibly for the deployment if (!checker.Condition(ver_signal)) return; @@ -337,13 +341,22 @@ FUZZ_TARGET(versionbits, .init = initialize) assert(exp_state == ThresholdState::FAILED); } break; + case ThresholdState::EXPIRED: + assert(!always_active_test); + assert(active_duration < std::numeric_limits::max()); + assert(min_activation <= current_block->nHeight + 1); + assert(exp_state == ThresholdState::EXPIRED || exp_state == ThresholdState::ACTIVE); + if (exp_state == ThresholdState::ACTIVE) { + assert(since == exp_since + active_duration); // EXPIRED starts exactly active_duration blocks after ACTIVE started + } + break; default: assert(false); } if (blocks.size() >= period * max_periods) { // we chose the timeout (and block times) so that by the time we have this many blocks it's all over - assert(state == ThresholdState::ACTIVE || state == ThresholdState::FAILED); + assert(state == ThresholdState::ACTIVE || state == ThresholdState::FAILED || state == ThresholdState::EXPIRED); } if (always_active_test) { diff --git a/src/test/versionbits_tests.cpp b/src/test/versionbits_tests.cpp index 7028a3d2c6db..50e2546013b6 100644 --- a/src/test/versionbits_tests.cpp +++ b/src/test/versionbits_tests.cpp @@ -23,6 +23,7 @@ static std::string StateName(ThresholdState state) case ThresholdState::LOCKED_IN: return "LOCKED_IN"; case ThresholdState::ACTIVE: return "ACTIVE"; case ThresholdState::FAILED: return "FAILED"; + case ThresholdState::EXPIRED: return "EXPIRED"; } // no default case, so the compiler can warn about missing cases return ""; } @@ -668,15 +669,15 @@ BOOST_FIXTURE_TEST_CASE(versionbits_active_duration, BasicTestingSetup) { ArgsManager args; // Test with max_activation_height set - // start=0, timeout=NO_TIMEOUT, min_height=288, max_height=432, active_duration=1000 + // start=0, timeout=NO_TIMEOUT, min_height=288, max_height=432, active_duration=1008 (144*7) // NO_TIMEOUT = INT64_MAX = 9223372036854775807 - args.ForceSetArg("-vbparams", "testdummy:0:9223372036854775807:288:432:1000"); + args.ForceSetArg("-vbparams", "testdummy:0:9223372036854775807:288:432:1008"); const auto chainParams = CreateChainParams(args, ChainType::REGTEST); const auto& deployment = chainParams->GetConsensus().vDeployments[Consensus::DEPLOYMENT_TESTDUMMY]; BOOST_CHECK_EQUAL(deployment.min_activation_height, 288); BOOST_CHECK_EQUAL(deployment.max_activation_height, 432); - BOOST_CHECK_EQUAL(deployment.active_duration, 1000); + BOOST_CHECK_EQUAL(deployment.active_duration, 1008); } { @@ -721,4 +722,415 @@ BOOST_FIXTURE_TEST_CASE(versionbits_max_activation_height_parsing, BasicTestingS } } +/** + * Test condition checker for temporary deployments with active_duration. + * After active_duration blocks past activation, the state transitions to EXPIRED. + */ +class TestTemporaryDeploymentConditionChecker : public AbstractThresholdConditionChecker +{ +private: + mutable ThresholdConditionCache cache; + int m_active_duration; + +public: + explicit TestTemporaryDeploymentConditionChecker(int active_duration) : m_active_duration(active_duration) {} + + int64_t BeginTime(const Consensus::Params& params) const override { return 0; } // Start immediately + int64_t EndTime(const Consensus::Params& params) const override { return Consensus::BIP9Deployment::NO_TIMEOUT; } + int Period(const Consensus::Params& params) const override { return 144; } + int Threshold(const Consensus::Params& params) const override { return 108; } // 75% + int ActiveDuration(const Consensus::Params& params) const override { return m_active_duration; } + bool Condition(const CBlockIndex* pindex, const Consensus::Params& params) const override { return (pindex->nVersion & 0x100); } + + ThresholdState GetStateFor(const CBlockIndex* pindexPrev) const { return AbstractThresholdConditionChecker::GetStateFor(pindexPrev, paramsDummy, cache); } + int GetStateSinceHeightFor(const CBlockIndex* pindexPrev) const { return AbstractThresholdConditionChecker::GetStateSinceHeightFor(pindexPrev, paramsDummy, cache); } + void ClearCache() { cache.clear(); } +}; + +BOOST_AUTO_TEST_CASE(versionbits_expired_state) +{ + // Test that a temporary deployment transitions from ACTIVE to EXPIRED + // after active_duration blocks past activation. + // + // Timeline with period=144, active_duration=288: + // - Period 0 (0-143): DEFINED + // - Period 1 (144-287): STARTED, signal enough to lock in + // - Period 2 (288-431): LOCKED_IN + // - Period 3 (432-575): ACTIVE (activation_height=432) + // - Period 4 (576-719): ACTIVE (blocks in this period are ACTIVE) + // - At pindexPrev=719: EXPIRED for block 720+ (720 >= 432 + 288) + + std::vector blocks; + auto cleanup = [&blocks]() { + for (auto* b : blocks) delete b; + blocks.clear(); + }; + + TestTemporaryDeploymentConditionChecker checker(288); + + auto mine_block = [&blocks](int32_t nVersion) -> CBlockIndex* { + CBlockIndex* pindex = new CBlockIndex(); + pindex->nHeight = blocks.size(); + pindex->pprev = blocks.empty() ? nullptr : blocks.back(); + pindex->nTime = 1415926536 + 600 * pindex->nHeight; + pindex->nVersion = nVersion; + pindex->BuildSkip(); + blocks.push_back(pindex); + return pindex; + }; + + // Period 0: DEFINED (blocks 0-143) + for (int i = 0; i < 144; i++) { + mine_block(0); + } + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::STARTED); + + // Period 1: STARTED, signal all blocks to lock in (blocks 144-287) + for (int i = 0; i < 144; i++) { + mine_block(0x100); // Signal + } + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::LOCKED_IN); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 288); + + // Period 2: LOCKED_IN (blocks 288-431) + for (int i = 0; i < 144; i++) { + mine_block(0); + } + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::ACTIVE); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 432); + + // Period 3: ACTIVE (blocks 432-575), activation_height = 432 + for (int i = 0; i < 144; i++) { + mine_block(0); + } + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::ACTIVE); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 432); + + // Period 4 (blocks 576-719): blocks in this period are ACTIVE, + // but at pindexPrev=719 the state for block 720+ is EXPIRED (720 >= 432 + 288) + for (int i = 0; i < 144; i++) { + mine_block(0); + } + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::EXPIRED); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 720); + + // Verify EXPIRED is terminal + for (int i = 0; i < 144; i++) { + mine_block(0x100); // Signal shouldn't matter + } + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::EXPIRED); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 720); + + cleanup(); +} + +BOOST_AUTO_TEST_CASE(versionbits_expired_unaligned_duration_rejected) +{ + // Test that active_duration that is NOT a multiple of the period is rejected. + ArgsManager args; + args.ForceSetArg("-vbparams", "testdummy:0:9223372036854775807:0:432:200"); + BOOST_CHECK_THROW(CreateChainParams(args, ChainType::REGTEST), std::runtime_error); +} + +BOOST_AUTO_TEST_CASE(versionbits_expired_minimum_duration) +{ + // Test active_duration = 1 period (144). Minimum useful duration. + // Activation at 432, so 432+144=576. At pindexPrev=575, state for 576+: + // 576 >= 576 -> EXPIRED. Only one period of ACTIVE. + + std::vector blocks; + auto cleanup = [&blocks]() { + for (auto* b : blocks) delete b; + blocks.clear(); + }; + + TestTemporaryDeploymentConditionChecker checker(144); + + auto mine_block = [&blocks](int32_t nVersion) -> CBlockIndex* { + CBlockIndex* pindex = new CBlockIndex(); + pindex->nHeight = blocks.size(); + pindex->pprev = blocks.empty() ? nullptr : blocks.back(); + pindex->nTime = 1415926536 + 600 * pindex->nHeight; + pindex->nVersion = nVersion; + pindex->BuildSkip(); + blocks.push_back(pindex); + return pindex; + }; + + // Period 0: DEFINED (0-143) + for (int i = 0; i < 144; i++) mine_block(0); + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::STARTED); + + // Period 1: Signal (144-287) + for (int i = 0; i < 144; i++) mine_block(0x100); + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::LOCKED_IN); + + // Period 2: LOCKED_IN (288-431) + for (int i = 0; i < 144; i++) mine_block(0); + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::ACTIVE); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 432); + + // Period 3 (432-575): ACTIVE for this period, but state for 576+: + // 576 >= 432 + 144 = 576 -> EXPIRED immediately at next boundary + for (int i = 0; i < 144; i++) mine_block(0); + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::EXPIRED); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 576); + + cleanup(); +} + +BOOST_AUTO_TEST_CASE(versionbits_expired_cold_cache) +{ + // Test that clearing the cache and re-querying correctly recovers + // the EXPIRED state. This exercises the activation_height recovery + // path in GetStateFor where it calls GetStateSinceHeightFor to find + // when ACTIVE started. + + std::vector blocks; + auto cleanup = [&blocks]() { + for (auto* b : blocks) delete b; + blocks.clear(); + }; + + TestTemporaryDeploymentConditionChecker checker(288); + + auto mine_block = [&blocks](int32_t nVersion) -> CBlockIndex* { + CBlockIndex* pindex = new CBlockIndex(); + pindex->nHeight = blocks.size(); + pindex->pprev = blocks.empty() ? nullptr : blocks.back(); + pindex->nTime = 1415926536 + 600 * pindex->nHeight; + pindex->nVersion = nVersion; + pindex->BuildSkip(); + blocks.push_back(pindex); + return pindex; + }; + + // Build chain through to EXPIRED (same as basic test) + // Period 0: DEFINED (0-143) + for (int i = 0; i < 144; i++) mine_block(0); + // Period 1: Signal (144-287) + for (int i = 0; i < 144; i++) mine_block(0x100); + // Period 2: LOCKED_IN (288-431) + for (int i = 0; i < 144; i++) mine_block(0); + // Period 3: ACTIVE (432-575) + for (int i = 0; i < 144; i++) mine_block(0); + // Period 4: (576-719) -> EXPIRED at 720 + for (int i = 0; i < 144; i++) mine_block(0); + + // Verify EXPIRED with warm cache + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::EXPIRED); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 720); + + // Clear cache completely — simulates node restart + checker.ClearCache(); + + // Re-query: must walk from genesis, recover activation_height, compute EXPIRED + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::EXPIRED); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 720); + + // Also verify intermediate states are correct after cache rebuild + checker.ClearCache(); + // Query at end of period 3 (pindexPrev=575) — should be ACTIVE + BOOST_CHECK(checker.GetStateFor(blocks[575]) == ThresholdState::ACTIVE); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks[575]), 432); + + // Now query at end of period 4 with partially-populated cache + // (period 3 is cached as ACTIVE from the query above) + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::EXPIRED); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 720); + + cleanup(); +} + +BOOST_AUTO_TEST_CASE(versionbits_expired_with_max_activation_height) +{ + // Test interaction: deployment with BOTH max_activation_height AND active_duration. + // max_activation_height forces LOCKED_IN, then active_duration causes EXPIRED. + // + // period=144, max_activation_height=432, active_duration=288 + // - Period 0 (0-143): DEFINED + // - Period 1 (144-287): STARTED (no signaling, but forced LOCKED_IN at boundary + // because 288 >= 432 - 144) + // - Period 2 (288-431): LOCKED_IN + // - Period 3 (432-575): ACTIVE (activation_height=432) + // - Period 4 (576-719): ACTIVE + // - At pindexPrev=719: EXPIRED (720 >= 432 + 288) + + std::vector blocks; + auto cleanup = [&blocks]() { + for (auto* b : blocks) delete b; + blocks.clear(); + }; + + // Need a checker that has both max_activation_height AND active_duration + class TestCombinedChecker : public AbstractThresholdConditionChecker + { + private: + mutable ThresholdConditionCache cache; + public: + int64_t BeginTime(const Consensus::Params& params) const override { return 0; } + int64_t EndTime(const Consensus::Params& params) const override { return Consensus::BIP9Deployment::NO_TIMEOUT; } + int Period(const Consensus::Params& params) const override { return 144; } + int Threshold(const Consensus::Params& params) const override { return 108; } + int MaxActivationHeight(const Consensus::Params& params) const override { return 432; } + int ActiveDuration(const Consensus::Params& params) const override { return 288; } + bool Condition(const CBlockIndex* pindex, const Consensus::Params& params) const override { return (pindex->nVersion & 0x100); } + ThresholdState GetStateFor(const CBlockIndex* pindexPrev) const { return AbstractThresholdConditionChecker::GetStateFor(pindexPrev, paramsDummy, cache); } + int GetStateSinceHeightFor(const CBlockIndex* pindexPrev) const { return AbstractThresholdConditionChecker::GetStateSinceHeightFor(pindexPrev, paramsDummy, cache); } + }; + + TestCombinedChecker checker; + + auto mine_block = [&blocks](int32_t nVersion) -> CBlockIndex* { + CBlockIndex* pindex = new CBlockIndex(); + pindex->nHeight = blocks.size(); + pindex->pprev = blocks.empty() ? nullptr : blocks.back(); + pindex->nTime = 1415926536 + 600 * pindex->nHeight; + pindex->nVersion = nVersion; + pindex->BuildSkip(); + blocks.push_back(pindex); + return pindex; + }; + + // Period 0: DEFINED (0-143), no signaling + for (int i = 0; i < 144; i++) mine_block(0); + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::STARTED); + + // Period 1: STARTED (144-287), no signaling — forced LOCKED_IN by max_activation_height + for (int i = 0; i < 144; i++) mine_block(0); + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::LOCKED_IN); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 288); + + // Period 2: LOCKED_IN (288-431) + for (int i = 0; i < 144; i++) mine_block(0); + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::ACTIVE); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 432); + + // Period 3: ACTIVE (432-575) + for (int i = 0; i < 144; i++) mine_block(0); + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::ACTIVE); + + // Period 4: (576-719) -> EXPIRED at 720 + for (int i = 0; i < 144; i++) mine_block(0); + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::EXPIRED); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 720); + + cleanup(); +} + +BOOST_AUTO_TEST_CASE(versionbits_expired_zero_duration) +{ + // Test active_duration = 0. This means the deployment expires immediately + // after activation — zero blocks of enforcement. + // Activation at 432, so 432 + 0 = 432. At pindexPrev=431 (end of LOCKED_IN), + // state for 432+: LOCKED_IN transitions to ACTIVE, sets activation_height=432. + // But that's computed for this period boundary. The ACTIVE->EXPIRED check + // happens on the NEXT iteration of the walk-forward. + // At pindexPrev=575 (end of period 3): 576 >= 432 + 0 = 432 -> EXPIRED. + // So active_duration=0 gives exactly one period of ACTIVE, same as + // active_duration=144 with period=144 (since transitions are per-period). + + std::vector blocks; + auto cleanup = [&blocks]() { + for (auto* b : blocks) delete b; + blocks.clear(); + }; + + TestTemporaryDeploymentConditionChecker checker(0); + + auto mine_block = [&blocks](int32_t nVersion) -> CBlockIndex* { + CBlockIndex* pindex = new CBlockIndex(); + pindex->nHeight = blocks.size(); + pindex->pprev = blocks.empty() ? nullptr : blocks.back(); + pindex->nTime = 1415926536 + 600 * pindex->nHeight; + pindex->nVersion = nVersion; + pindex->BuildSkip(); + blocks.push_back(pindex); + return pindex; + }; + + // Period 0: DEFINED (0-143) + for (int i = 0; i < 144; i++) mine_block(0); + // Period 1: Signal (144-287) + for (int i = 0; i < 144; i++) mine_block(0x100); + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::LOCKED_IN); + + // Period 2: LOCKED_IN (288-431) -> ACTIVE at 432 + for (int i = 0; i < 144; i++) mine_block(0); + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::ACTIVE); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 432); + + // Period 3 (432-575): ACTIVE, but 576 >= 432+0 -> EXPIRED at next boundary + for (int i = 0; i < 144; i++) mine_block(0); + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::EXPIRED); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 576); + + cleanup(); +} + +BOOST_AUTO_TEST_CASE(versionbits_no_signaling_after_expired) +{ + // Test that ComputeBlockVersion does NOT set the deployment bit after EXPIRED. + // Uses regtest with testdummy deployment configured with active_duration. + // + // ComputeBlockVersion only signals for STARTED and LOCKED_IN states. + // After EXPIRED, the bit should not be set. + + ArgsManager args; + // testdummy: start=0, timeout=never, min_height=0, max_height=INT_MAX, active_duration=144 + args.ForceSetArg("-vbparams", "testdummy:0:9223372036854775807:0:2147483647:144"); + const auto chainParams = CreateChainParams(args, ChainType::REGTEST); + const auto& params = chainParams->GetConsensus(); + + VersionBitsCache vbcache; + const auto dep = Consensus::DEPLOYMENT_TESTDUMMY; + const uint32_t bitmask = vbcache.Mask(params, dep); + const int period = params.nMinerConfirmationWindow; // 144 for regtest + + std::vector blocks; + auto cleanup = [&blocks]() { + for (auto* b : blocks) delete b; + blocks.clear(); + }; + + auto mine_block = [&blocks](int32_t nVersion) -> CBlockIndex* { + CBlockIndex* pindex = new CBlockIndex(); + pindex->nHeight = blocks.size(); + pindex->pprev = blocks.empty() ? nullptr : blocks.back(); + pindex->nTime = 1415926536 + 600 * pindex->nHeight; + pindex->nVersion = nVersion; + pindex->BuildSkip(); + blocks.push_back(pindex); + return pindex; + }; + + // Period 0: DEFINED (0-143) — bit should not be set + for (int i = 0; i < period; i++) mine_block(VERSIONBITS_TOP_BITS); + BOOST_CHECK_EQUAL(vbcache.ComputeBlockVersion(blocks.back(), params) & bitmask, bitmask); // STARTED, bit set + + // Period 1: STARTED — signal to lock in (144-287), bit should be set + for (int i = 0; i < period; i++) mine_block(VERSIONBITS_TOP_BITS | bitmask); + BOOST_CHECK(vbcache.State(blocks.back(), params, dep) == ThresholdState::LOCKED_IN); + BOOST_CHECK_EQUAL(vbcache.ComputeBlockVersion(blocks.back(), params) & bitmask, bitmask); // LOCKED_IN, bit set + + // Period 2: LOCKED_IN (288-431), bit should be set + for (int i = 0; i < period; i++) mine_block(VERSIONBITS_TOP_BITS | bitmask); + BOOST_CHECK(vbcache.State(blocks.back(), params, dep) == ThresholdState::ACTIVE); + // ACTIVE: bit should NOT be set + BOOST_CHECK_EQUAL(vbcache.ComputeBlockVersion(blocks.back(), params) & bitmask, 0u); + + // Period 3: ACTIVE (432-575) + for (int i = 0; i < period; i++) mine_block(VERSIONBITS_TOP_BITS); + BOOST_CHECK(vbcache.State(blocks.back(), params, dep) == ThresholdState::EXPIRED); + // EXPIRED: bit should NOT be set + BOOST_CHECK_EQUAL(vbcache.ComputeBlockVersion(blocks.back(), params) & bitmask, 0u); + + // Period 4: EXPIRED (576+) — verify bit stays off + for (int i = 0; i < period; i++) mine_block(VERSIONBITS_TOP_BITS); + BOOST_CHECK(vbcache.State(blocks.back(), params, dep) == ThresholdState::EXPIRED); + BOOST_CHECK_EQUAL(vbcache.ComputeBlockVersion(blocks.back(), params) & bitmask, 0u); + + cleanup(); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/src/versionbits.cpp b/src/versionbits.cpp index bfd9152d3d8d..05d9de797bc5 100644 --- a/src/versionbits.cpp +++ b/src/versionbits.cpp @@ -6,12 +6,14 @@ #include #include +// NOLINTBEGIN(misc-no-recursion) ThresholdState AbstractThresholdConditionChecker::GetStateFor(const CBlockIndex* pindexPrev, const Consensus::Params& params, ThresholdConditionCache& cache) const { int nPeriod = Period(params); int nThreshold = Threshold(params); int min_activation_height = MinActivationHeight(params); int max_activation_height = MaxActivationHeight(params); + int active_duration = ActiveDuration(params); int64_t nTimeStart = BeginTime(params); int64_t nTimeTimeout = EndTime(params); @@ -51,6 +53,23 @@ ThresholdState AbstractThresholdConditionChecker::GetStateFor(const CBlockIndex* assert(cache.count(pindexPrev)); ThresholdState state = cache[pindexPrev]; + // Everything is already cached. Return immediately. + if (vToCompute.empty()) { + return state; + } + + // For temporary deployments, we need to know when ACTIVE started to determine the + // ACTIVE -> EXPIRED transition. We get this by calling GetStateSinceHeightFor, which + // internally calls GetStateFor on earlier periods. Those calls could recurse back here + // and call GetStateSinceHeightFor again, but the early return above prevents this: + // the walk-back above guarantees all periods before pindexPrev are already cached, + // and GetStateSinceHeightFor only walks backwards, so its GetStateFor calls always hit + // the cache, have empty vToCompute, and return immediately via the early return. + int activation_height = 0; + if (state == ThresholdState::ACTIVE && active_duration < std::numeric_limits::max()) { + activation_height = GetStateSinceHeightFor(pindexPrev, params, cache); + } + // Now walk forward and compute the state of descendants of pindexPrev while (!vToCompute.empty()) { ThresholdState stateNext = state; @@ -92,11 +111,21 @@ ThresholdState AbstractThresholdConditionChecker::GetStateFor(const CBlockIndex* // Progresses into ACTIVE provided activation height will have been reached. if (pindexPrev->nHeight + 1 >= min_activation_height) { stateNext = ThresholdState::ACTIVE; + if (active_duration < std::numeric_limits::max()) { + activation_height = pindexPrev->nHeight + 1; + } } break; } - case ThresholdState::FAILED: case ThresholdState::ACTIVE: { + if (active_duration < std::numeric_limits::max() && + pindexPrev->nHeight + 1 >= activation_height + active_duration) { + stateNext = ThresholdState::EXPIRED; + } + break; + } + case ThresholdState::FAILED: + case ThresholdState::EXPIRED: { // Nothing happens, these are terminal states. break; } @@ -106,6 +135,7 @@ ThresholdState AbstractThresholdConditionChecker::GetStateFor(const CBlockIndex* return state; } +// NOLINTEND(misc-no-recursion) BIP9Stats AbstractThresholdConditionChecker::GetStateStatisticsFor(const CBlockIndex* pindex, const Consensus::Params& params, std::vector* signalling_blocks) const { @@ -145,6 +175,13 @@ BIP9Stats AbstractThresholdConditionChecker::GetStateStatisticsFor(const CBlockI return stats; } +// WARNING: This function is called from GetStateFor and calls GetStateFor in turn. +// The recursion is safe because this function calls GetStateFor first (which populates +// the cache), then only walks BACKWARDS through periods that are now cached. GetStateFor +// returns immediately for cached entries (via the early return when vToCompute is empty). +// If the backwards walk is ever changed to query uncached periods, infinite recursion +// will result. +// NOLINTBEGIN(misc-no-recursion) int AbstractThresholdConditionChecker::GetStateSinceHeightFor(const CBlockIndex* pindexPrev, const Consensus::Params& params, ThresholdConditionCache& cache) const { int64_t start_time = BeginTime(params); @@ -179,6 +216,7 @@ int AbstractThresholdConditionChecker::GetStateSinceHeightFor(const CBlockIndex* // Adjust the result because right now we point to the parent block. return pindexPrev->nHeight + 1; } +// NOLINTEND(misc-no-recursion) namespace { @@ -194,6 +232,7 @@ class VersionBitsConditionChecker : public AbstractThresholdConditionChecker { int64_t EndTime(const Consensus::Params& params) const override { return params.vDeployments[id].nTimeout; } int MinActivationHeight(const Consensus::Params& params) const override { return params.vDeployments[id].min_activation_height; } int MaxActivationHeight(const Consensus::Params& params) const override { return params.vDeployments[id].max_activation_height; } + int ActiveDuration(const Consensus::Params& params) const override { return params.vDeployments[id].active_duration; } int Period(const Consensus::Params& params) const override { return params.nMinerConfirmationWindow; } int Threshold(const Consensus::Params& params) const override { // Use per-deployment threshold if set, otherwise fall back to global diff --git a/src/versionbits.h b/src/versionbits.h index 82c11ce12563..a06db818762f 100644 --- a/src/versionbits.h +++ b/src/versionbits.h @@ -28,8 +28,9 @@ enum class ThresholdState { DEFINED, // First state that each softfork starts out as. The genesis block is by definition in this state for each deployment. STARTED, // For blocks past the starttime. LOCKED_IN, // For at least one retarget period after the first retarget period with STARTED blocks of which at least threshold have the associated bit set in nVersion, until min_activation_height is reached. - ACTIVE, // For all blocks after the LOCKED_IN retarget period (final state) + ACTIVE, // For all blocks after the LOCKED_IN retarget period (final state for permanent deployments) FAILED, // For all blocks once the first retarget period after the timeout time is hit, if LOCKED_IN wasn't already reached (final state) + EXPIRED, // For temporary deployments: all blocks after active_duration periods past activation (final state) }; // A map that gives the state for blocks whose height is a multiple of Period(). @@ -61,6 +62,7 @@ class AbstractThresholdConditionChecker { virtual int64_t EndTime(const Consensus::Params& params) const =0; virtual int MinActivationHeight(const Consensus::Params& params) const { return 0; } virtual int MaxActivationHeight(const Consensus::Params& params) const { return std::numeric_limits::max(); } + virtual int ActiveDuration(const Consensus::Params& params) const { return std::numeric_limits::max(); } virtual int Period(const Consensus::Params& params) const =0; virtual int Threshold(const Consensus::Params& params) const =0; From 1bf0c3524932dc3efb42465e1fda71b20fedb981 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Tue, 18 Nov 2025 23:50:11 -0600 Subject: [PATCH 08/34] versionbits: add mandatory signaling enforcement for max_activation_height and set vbrequired during mandatory signaling --- src/deploymentstatus.h | 13 +++++++++++++ src/rpc/mining.cpp | 6 +++++- src/validation.cpp | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/deploymentstatus.h b/src/deploymentstatus.h index 550f38154bd6..26c66e8be759 100644 --- a/src/deploymentstatus.h +++ b/src/deploymentstatus.h @@ -57,4 +57,17 @@ inline bool DeploymentEnabled(const Consensus::Params& params, Consensus::Deploy return params.vDeployments[dep].nStartTime != Consensus::BIP9Deployment::NEVER_ACTIVE; } +/** Determine if mandatory signaling is required for a deployment at the next block */ +inline bool DeploymentMustSignalAfter(const CBlockIndex* pindexPrev, const Consensus::Params& params, Consensus::DeploymentPos dep, ThresholdState state) +{ + assert(Consensus::ValidDeployment(dep)); + const auto& deployment = params.vDeployments[dep]; + if (deployment.max_activation_height >= std::numeric_limits::max()) return false; + if (state != ThresholdState::STARTED) return false; // If must_signal height is reached before start time, abstain from enforcement + const int nPeriod = params.nMinerConfirmationWindow; + const int nHeight = pindexPrev == nullptr ? 0 : pindexPrev->nHeight + 1; + return nHeight >= deployment.max_activation_height - (2 * nPeriod) + && nHeight < deployment.max_activation_height - nPeriod; +} + #endif // BITCOIN_DEPLOYMENTSTATUS_H diff --git a/src/rpc/mining.cpp b/src/rpc/mining.cpp index f5d82f67f5b7..cf9f5cf10fd0 100644 --- a/src/rpc/mining.cpp +++ b/src/rpc/mining.cpp @@ -1020,6 +1020,7 @@ static UniValue TemplateToJSON(const Consensus::Params& consensusParams, const C } UniValue vbavailable(UniValue::VOBJ); + uint32_t vbrequired = 0; for (int j = 0; j < (int)Consensus::MAX_VERSION_BITS_DEPLOYMENTS; ++j) { Consensus::DeploymentPos pos = Consensus::DeploymentPos(j); ThresholdState state = chainman.m_versionbitscache.State(pindexPrev, consensusParams, pos); @@ -1037,6 +1038,9 @@ static UniValue TemplateToJSON(const Consensus::Params& consensusParams, const C { const struct VBDeploymentInfo& vbinfo = VersionBitsDeploymentInfo[pos]; vbavailable.pushKV(gbt_vb_name(pos), consensusParams.vDeployments[pos].bit); + if (DeploymentMustSignalAfter(pindexPrev, consensusParams, pos, state)) { + vbrequired |= chainman.m_versionbitscache.Mask(consensusParams, pos); + } if (setClientRules.find(vbinfo.name) == setClientRules.end()) { if (!vbinfo.gbt_force) { // If the client doesn't support this, don't indicate it in the [default] version @@ -1063,7 +1067,7 @@ static UniValue TemplateToJSON(const Consensus::Params& consensusParams, const C result.pushKV("version", block_header.nVersion); result.pushKV("rules", std::move(aRules)); result.pushKV("vbavailable", std::move(vbavailable)); - result.pushKV("vbrequired", int(0)); + result.pushKV("vbrequired", vbrequired); result.pushKV("previousblockhash", block.hashPrevBlock.GetHex()); result.pushKV("transactions", std::move(transactions)); diff --git a/src/validation.cpp b/src/validation.cpp index 4011ed00385c..e36dea2d5b8d 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -2683,6 +2684,7 @@ static unsigned int GetBlockScriptFlags(const CBlockIndex& block_index, const Ch return flags; } +static bool ContextualCheckBlockHeaderVolatile(const CBlockHeader& block, BlockValidationState& state, const ChainstateManager& chainman, const CBlockIndex* pindexPrev) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); /** Apply the effects of this block (with given index) on the UTXO set represented by coins. * Validity checks that depend on the UTXO set are also done; ConnectBlock() @@ -2728,6 +2730,11 @@ bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state, uint256 hashPrevBlock = pindex->pprev == nullptr ? uint256() : pindex->pprev->GetBlockHash(); assert(hashPrevBlock == view.GetBestBlock()); + if (!ContextualCheckBlockHeaderVolatile(block, state, m_chainman, pindex->pprev)) { + LogError("%s: Consensus::ContextualCheckBlockHeaderVolatile: %s\n", __func__, state.ToString()); + return false; + } + m_chainman.num_blocks_total++; // Special case for the genesis block, skipping connection of its transactions @@ -4587,6 +4594,38 @@ static bool ContextualCheckBlockHeader(const CBlockHeader& block, BlockValidatio } } + if (!ContextualCheckBlockHeaderVolatile(block, state, chainman, pindexPrev)) return false; + + return true; +} + +/** Context-dependent validity checks, but rechecked in ConnectBlock(). + * Note that -reindex-chainstate skips the validation that happens here! + */ +static bool ContextualCheckBlockHeaderVolatile(const CBlockHeader& block, BlockValidationState& state, const ChainstateManager& chainman, const CBlockIndex* pindexPrev) EXCLUSIVE_LOCKS_REQUIRED(::cs_main) +{ + const Consensus::Params& consensusParams = chainman.GetConsensus(); + + // Mandatory signaling for deployments approaching max_activation_height + for (int i = 0; i < (int)Consensus::MAX_VERSION_BITS_DEPLOYMENTS; i++) { + const Consensus::DeploymentPos pos = static_cast(i); + const ThresholdState deployment_state = chainman.m_versionbitscache.State(pindexPrev, consensusParams, pos); + + if (DeploymentMustSignalAfter(pindexPrev, consensusParams, pos, deployment_state)) { + const auto& deployment = consensusParams.vDeployments[pos]; + const bool fVersionBits = (block.nVersion & VERSIONBITS_TOP_MASK) == VERSIONBITS_TOP_BITS; + const bool fDeploymentBit = (block.nVersion & (uint32_t{1} << deployment.bit)) != 0; + + if (!(fVersionBits && fDeploymentBit)) { + const std::string deployment_name = VersionBitsDeploymentInfo[i].name; + return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, + "bad-version-" + deployment_name, + strprintf("Block must signal for %s approaching max_activation_height=%d", + deployment_name, deployment.max_activation_height)); + } + } + } + return true; } From f664525123fab22b37c181e6503ed96a5c52299f Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Fri, 3 Oct 2025 14:20:12 +0000 Subject: [PATCH 09/34] script: use TAPROOT_CONTROL_MAX_NODE_COUNT_REDUCED for non-consensus Taproot logic; adapt tests Co-Authored-By: Dathon Ohm --- src/psbt.h | 2 +- src/script/descriptor.cpp | 4 ++-- src/script/interpreter.h | 2 ++ src/script/signingprovider.cpp | 4 ++-- src/test/descriptor_tests.cpp | 2 +- src/test/script_standard_tests.cpp | 7 ++++--- 6 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/psbt.h b/src/psbt.h index 6d49864b3cdb..88a22a84aeb4 100644 --- a/src/psbt.h +++ b/src/psbt.h @@ -872,7 +872,7 @@ struct PSBTOutput s_tree >> depth; s_tree >> leaf_ver; s_tree >> script; - if (depth > TAPROOT_CONTROL_MAX_NODE_COUNT) { + if (depth > TAPROOT_CONTROL_MAX_NODE_COUNT_REDUCED) { throw std::ios_base::failure("Output Taproot tree has as leaf greater than Taproot maximum depth"); } if ((leaf_ver & ~TAPROOT_LEAF_MASK) != 0) { diff --git a/src/script/descriptor.cpp b/src/script/descriptor.cpp index 88966b1a3c43..391780034ba7 100644 --- a/src/script/descriptor.cpp +++ b/src/script/descriptor.cpp @@ -1967,8 +1967,8 @@ std::vector> ParseScript(uint32_t& key_exp_index // First process all open braces. while (Const("{", expr)) { branches.push_back(false); // new left branch - if (branches.size() > TAPROOT_CONTROL_MAX_NODE_COUNT) { - error = strprintf("tr() supports at most %i nesting levels", TAPROOT_CONTROL_MAX_NODE_COUNT); + if (branches.size() > TAPROOT_CONTROL_MAX_NODE_COUNT_REDUCED) { + error = strprintf("tr() supports at most %i nesting levels", TAPROOT_CONTROL_MAX_NODE_COUNT_REDUCED); return {}; } } diff --git a/src/script/interpreter.h b/src/script/interpreter.h index b56933c64456..15b4d09dd3ce 100644 --- a/src/script/interpreter.h +++ b/src/script/interpreter.h @@ -234,6 +234,8 @@ static constexpr size_t TAPROOT_CONTROL_BASE_SIZE = 33; static constexpr size_t TAPROOT_CONTROL_NODE_SIZE = 32; static constexpr size_t TAPROOT_CONTROL_MAX_NODE_COUNT = 128; static constexpr size_t TAPROOT_CONTROL_MAX_SIZE = TAPROOT_CONTROL_BASE_SIZE + TAPROOT_CONTROL_NODE_SIZE * TAPROOT_CONTROL_MAX_NODE_COUNT; +static constexpr size_t TAPROOT_CONTROL_MAX_NODE_COUNT_REDUCED = 7; +static constexpr size_t TAPROOT_CONTROL_MAX_SIZE_REDUCED = TAPROOT_CONTROL_BASE_SIZE + TAPROOT_CONTROL_NODE_SIZE * TAPROOT_CONTROL_MAX_NODE_COUNT_REDUCED; extern const HashWriter HASHER_TAPSIGHASH; //!< Hasher with tag "TapSighash" pre-fed to it. extern const HashWriter HASHER_TAPLEAF; //!< Hasher with tag "TapLeaf" pre-fed to it. diff --git a/src/script/signingprovider.cpp b/src/script/signingprovider.cpp index d029ee1a96e4..e2f85adfa075 100644 --- a/src/script/signingprovider.cpp +++ b/src/script/signingprovider.cpp @@ -365,7 +365,7 @@ void TaprootBuilder::Insert(TaprootBuilder::NodeInfo&& node, int depth) // as what Insert() performs on the m_branch variable. Instead of // storing a NodeInfo object, just remember whether or not there is one // at that depth. - if (depth < 0 || (size_t)depth > TAPROOT_CONTROL_MAX_NODE_COUNT) return false; + if (depth < 0 || (size_t)depth > TAPROOT_CONTROL_MAX_NODE_COUNT_REDUCED) return false; if ((size_t)depth + 1 < branch.size()) return false; while (branch.size() > (size_t)depth && branch[depth]) { branch.pop_back(); @@ -478,7 +478,7 @@ std::optional, int>>> Inf // Skip script records with nonsensical leaf version. if (leaf_ver < 0 || leaf_ver >= 0x100 || leaf_ver & 1) continue; // Skip script records with invalid control block sizes. - if (control.size() < TAPROOT_CONTROL_BASE_SIZE || control.size() > TAPROOT_CONTROL_MAX_SIZE || + if (control.size() < TAPROOT_CONTROL_BASE_SIZE || control.size() > TAPROOT_CONTROL_MAX_SIZE_REDUCED || ((control.size() - TAPROOT_CONTROL_BASE_SIZE) % TAPROOT_CONTROL_NODE_SIZE) != 0) continue; // Skip script records that don't match the control block. if ((control[0] & TAPROOT_LEAF_MASK) != leaf_ver) continue; diff --git a/src/test/descriptor_tests.cpp b/src/test/descriptor_tests.cpp index 63c53a842c1f..baf288b4d67f 100644 --- a/src/test/descriptor_tests.cpp +++ b/src/test/descriptor_tests.cpp @@ -1006,7 +1006,7 @@ BOOST_AUTO_TEST_CASE(descriptor_test) CheckUnparsable("sh(and_v(vc:andor(pk(L4gM1FBdyHNpkzsFh9ipnofLhpZRp2mwobpeULy1a6dBTvw8Ywtd),pk_k(Kx9HCDjGiwFcgVNhTrS5z5NeZdD6veeam61eDxLDCkGWujvL4Gnn),and_v(v:older(1),pk_k(L4o2kDvXXDRH2VS9uBnouScLduWt4dZnM25se7kvEjJeQ285en2A))),after(10)))", "sh(and_v(vc:andor(pk(03cdabb7f2dce7bfbd8a0b9570c6fd1e712e5d64045e9d6b517b3d5072251dc204),pk_k(032707170c71d8f75e4ca4e3fce870b9409dcaf12b051d3bcadff74747fa7619c0),and_v(v:older(1),pk_k(02aa27e5eb2c185e87cd1dbc3e0efc9cb1175235e0259df1713424941c3cb40402))),after(10)))", "Miniscript expressions can only be used in wsh or tr."); CheckUnparsable("tr(and_v(vc:andor(pk(L4gM1FBdyHNpkzsFh9ipnofLhpZRp2mwobpeULy1a6dBTvw8Ywtd),pk_k(Kx9HCDjGiwFcgVNhTrS5z5NeZdD6veeam61eDxLDCkGWujvL4Gnn),and_v(v:older(1),pk_k(L4o2kDvXXDRH2VS9uBnouScLduWt4dZnM25se7kvEjJeQ285en2A))),after(10)))", "tr(and_v(vc:andor(pk(03cdabb7f2dce7bfbd8a0b9570c6fd1e712e5d64045e9d6b517b3d5072251dc204),pk_k(032707170c71d8f75e4ca4e3fce870b9409dcaf12b051d3bcadff74747fa7619c0),and_v(v:older(1),pk_k(02aa27e5eb2c185e87cd1dbc3e0efc9cb1175235e0259df1713424941c3cb40402))),after(10)))", "tr(): key 'and_v(vc:andor(pk(03cdabb7f2dce7bfbd8a0b9570c6fd1e712e5d64045e9d6b517b3d5072251dc204),pk_k(032707170c71d8f75e4ca4e3fce870b9409dcaf12b051d3bcadff74747fa7619c0),and_v(v:older(1),pk_k(02aa27e5eb2c185e87cd1dbc3e0efc9cb1175235e0259df1713424941c3cb40402))),after(10))' is not valid"); CheckUnparsable("raw(and_v(vc:andor(pk(L4gM1FBdyHNpkzsFh9ipnofLhpZRp2mwobpeULy1a6dBTvw8Ywtd),pk_k(Kx9HCDjGiwFcgVNhTrS5z5NeZdD6veeam61eDxLDCkGWujvL4Gnn),and_v(v:older(1),pk_k(L4o2kDvXXDRH2VS9uBnouScLduWt4dZnM25se7kvEjJeQ285en2A))),after(10)))", "sh(and_v(vc:andor(pk(03cdabb7f2dce7bfbd8a0b9570c6fd1e712e5d64045e9d6b517b3d5072251dc204),pk_k(032707170c71d8f75e4ca4e3fce870b9409dcaf12b051d3bcadff74747fa7619c0),and_v(v:older(1),pk_k(02aa27e5eb2c185e87cd1dbc3e0efc9cb1175235e0259df1713424941c3cb40402))),after(10)))", "Miniscript expressions can only be used in wsh or tr."); - CheckUnparsable("", "tr(034D2224bbbbbbbbbbcbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb40,{{{{{{{{{{{{{{{{{{{{{{multi(1,xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc/967808'/9,xprvA1RpRA33e1JQ7ifknakTFNpgXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc/968/2/5/8/5/2/5/58/58/2/5/5/5/58/588/2/6/8/5/2/8/2/5/8/2/58/2/5/8/5/2/8/5/8/3/4/5/58/55/2/5/58/58/2/5/5/5/8/5/2/8/5/85/2/8/2/5/8/5/2/5/58/58/2/5/58/58/588/2/58/2/8/5/8/5/4/5/585/2/5/58/58/2/5/5/58/588/2/58/2/5/8/5/2/8/2/5/8/5/5/58/588/2/6/8/5/2/8/2/5/8/5/2/5/58/58/2/5/58/58/2/0/8/5/2/8/5/8/5/4/5/58/588/2/6/8/5/2/8/2/5/8/5/2/5/58/58/2/5/58/58/588/2/58/2/5/8/5/8/24/5/58/52/5/8/5/2/8/24/5/58/588/246/8/5/2/8/2/5/8/5/2/5/58/58/2/5/5/5/58/588/2/6/8/5/2/8/2/5/8/2/58/2/5/8/5/2/8/5/8/5/4/5/58/55/58/2/5/8/55/2/5/8/58/555/58/2/5/8/4//2/5/58/5w/2/5/8/5/2/4/5/58/5558'/2/5/58/58/2/5/5/58/588/2/58/2/5/8/5/2/8/2/5/8/5/5/8/58/2/5/58/58/2/5/8/9/588/2/58/2/5/8/5/2/8/5/8/5/4/5/58/588/2/6/8/5/2/8/2/5/8/5/2/5/58/58/2/5/5/58/588/2/58/2/5/8/5/2/82/5/8/5/5/58/52/6/8/5/2/8/{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{}{{{{{{{{{DDD2/5/8/5/2/5/58/58/2/5/58/58/588/2/58/2/8/5/8/5/4/5/58/588/2/6/8/5/2/8/2/5/8588/246/8/5/2DLDDDDDDDbbD3DDDD/8/2/5/8/5/2/5/58/58/2/5/5/5/58/588/2/6/8/5/2/8/2/5/8/2/58/2/5/8/5/2/8/5/8/3/4/5/58/55/2/5/58/58/2/5/5/5/8/5/2/8/5/85/2/8/2/5/8D)/5/2/5/58/58/2/5/58/58/58/588/2/58/2/5/8/5/25/58/58/2/5/58/58/2/5/8/9/588/2/58/2/6780,xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFW/8/5/2/5/58678008')", "'multi(1,xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc/967808'/9,xprvA1RpRA33e1JQ7ifknakTFNpgXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc/968/2/5/8/5/2/5/58/58/2/5/5/5/58/588/2/6/8/5/2/8/2/5/8/2/58/2/5/8/5/2/8/5/8/3/4/5/58/55/2/5/58/58/2/5/5/5/8/5/2/8/5/85/2/8/2/5/8/5/2/5/58/58/2/5/58/58/588/2/58/2/8/5/8/5/4/5/585/2/5/58/58/2/5/5/58/588/2/58/2/5/8/5/2/8/2/5/8/5/5/58/588/2/6/8/5/2/8/2/5/8/5/2/5/58/58/2/5/58/58/2/0/8/5/2/8/5/8/5/4/5/58/588/2/6/8/5/2/8/2/5/8/5/2/5/58/58/2/5/58/58/588/2/58/2/5/8/5/8/24/5/58/52/5/8/5/2/8/24/5/58/588/246/8/5/2/8/2/5/8/5/2/5/58/58/2/5/5/5/58/588/2/6/8/5/2/8/2/5/8/2/58/2/5/8/5/2/8/5/8/5/4/5/58/55/58/2/5/8/55/2/5/8/58/555/58/2/5/8/4//2/5/58/5w/2/5/8/5/2/4/5/58/5558'/2/5/58/58/2/5/5/58/588/2/58/2/5/8/5/2/8/2/5/8/5/5/8/58/2/5/58/58/2/5/8/9/588/2/58/2/5/8/5/2/8/5/8/5/4/5/58/588/2/6/8/5/2/8/2/5/8/5/2/5/58/58/2/5/5/58/588/2/58/2/5/8/5/2/82/5/8/5/5/58/52/6/8/5/2/8/{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{}{{{{{{{{{DDD2/5/8/5/2/5/58/58/2/5/58/58/588/2/58/2/8/5/8/5/4/5/58/588/2/6/8/5/2/8/2/5/8588/246/8/5/2DLDDDDDDDbbD3DDDD/8/2/5/8/5/2/5/58/58/2/5/5/5/58/588/2/6/8/5/2/8/2/5/8/2/58/2/5/8/5/2/8/5/8/3/4/5/58/55/2/5/58/58/2/5/5/5/8/5/2/8/5/85/2/8/2/5/8D)/5/2/5/58/58/2/5/58/58/58/588/2/58/2/5/8/5/25/58/58/2/5/58/58/2/5/8/9/588/2/58/2/6780,xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFW/8/5/2/5/58678008'' is not a valid descriptor function"); + CheckUnparsable("", "tr(034D2224bbbbbbbbbbcbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb40,{{{{{{{{{{{{{{{{{{{{{{multi(1,xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc/967808'/9,xprvA1RpRA33e1JQ7ifknakTFNpgXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc/968/2/5/8/5/2/5/58/58/2/5/5/5/58/588/2/6/8/5/2/8/2/5/8/2/58/2/5/8/5/2/8/5/8/3/4/5/58/55/2/5/58/58/2/5/5/5/8/5/2/8/5/85/2/8/2/5/8/5/2/5/58/58/2/5/58/58/588/2/58/2/8/5/8/5/4/5/585/2/5/58/58/2/5/5/58/588/2/58/2/5/8/5/2/8/2/5/8/5/5/58/588/2/6/8/5/2/8/2/5/8/5/2/5/58/58/2/5/58/58/2/0/8/5/2/8/5/8/5/4/5/58/588/2/6/8/5/2/8/2/5/8/5/2/5/58/58/2/5/58/58/588/2/58/2/5/8/5/8/24/5/58/52/5/8/5/2/8/24/5/58/588/246/8/5/2/8/2/5/8/5/2/5/58/58/2/5/5/5/58/588/2/6/8/5/2/8/2/5/8/2/58/2/5/8/5/2/8/5/8/5/4/5/58/55/58/2/5/8/55/2/5/8/58/555/58/2/5/8/4//2/5/58/5w/2/5/8/5/2/4/5/58/5558'/2/5/58/58/2/5/5/58/588/2/58/2/5/8/5/2/8/2/5/8/5/5/8/58/2/5/58/58/2/5/8/9/588/2/58/2/5/8/5/2/8/5/8/5/4/5/58/588/2/6/8/5/2/8/2/5/8/5/2/5/58/58/2/5/5/58/588/2/58/2/5/8/5/2/82/5/8/5/5/58/52/6/8/5/2/8/{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{}{{{{{{{{{DDD2/5/8/5/2/5/58/58/2/5/58/58/588/2/58/2/8/5/8/5/4/5/58/588/2/6/8/5/2/8/2/5/8588/246/8/5/2DLDDDDDDDbbD3DDDD/8/2/5/8/5/2/5/58/58/2/5/5/5/58/588/2/6/8/5/2/8/2/5/8/2/58/2/5/8/5/2/8/5/8/3/4/5/58/55/2/5/58/58/2/5/5/5/8/5/2/8/5/85/2/8/2/5/8D)/5/2/5/58/58/2/5/58/58/58/588/2/58/2/5/8/5/25/58/58/2/5/58/58/2/5/8/9/588/2/58/2/6780,xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFW/8/5/2/5/58678008')", "tr() supports at most 7 nesting levels"); // No uncompressed keys allowed CheckUnparsable("", "wsh(and_v(vc:andor(pk(03cdabb7f2dce7bfbd8a0b9570c6fd1e712e5d64045e9d6b517b3d5072251dc204),pk_k(032707170c71d8f75e4ca4e3fce870b9409dcaf12b051d3bcadff74747fa7619c0),and_v(v:older(1),pk_k(049228de6902abb4f541791f6d7f925b10e2078ccb1298856e5ea5cc5fd667f930eac37a00cc07f9a91ef3c2d17bf7a17db04552ff90ac312a5b8b4caca6c97aa4))),after(10)))", "Uncompressed keys are not allowed"); // No hybrid keys allowed diff --git a/src/test/script_standard_tests.cpp b/src/test/script_standard_tests.cpp index e9ce82ca8a6c..042a6c6275d8 100644 --- a/src/test/script_standard_tests.cpp +++ b/src/test/script_standard_tests.cpp @@ -385,9 +385,10 @@ BOOST_AUTO_TEST_CASE(script_standard_taproot_builder) BOOST_CHECK_EQUAL(TaprootBuilder::ValidDepths({2,2,0}), false); BOOST_CHECK_EQUAL(TaprootBuilder::ValidDepths({2,2,1}), true); BOOST_CHECK_EQUAL(TaprootBuilder::ValidDepths({2,2,2}), false); - BOOST_CHECK_EQUAL(TaprootBuilder::ValidDepths({2,2,2,3,4,5,6,7,8,9,10,11,12,14,14,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,31,31,31,31,31,31,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,128}), true); - BOOST_CHECK_EQUAL(TaprootBuilder::ValidDepths({128,128,127,126,125,124,123,122,121,120,119,118,117,116,115,114,113,112,111,110,109,108,107,106,105,104,103,102,101,100,99,98,97,96,95,94,93,92,91,90,89,88,87,86,85,84,83,82,81,80,79,78,77,76,75,74,73,72,71,70,69,68,67,66,65,64,63,62,61,60,59,58,57,56,55,54,53,52,51,50,49,48,47,46,45,44,43,42,41,40,39,38,37,36,35,34,33,32,31,30,29,28,27,26,25,24,23,22,21,20,19,18,17,16,15,14,13,12,11,10,9,8,7,6,5,4,3,2,1}), true); - BOOST_CHECK_EQUAL(TaprootBuilder::ValidDepths({129,129,128,127,126,125,124,123,122,121,120,119,118,117,116,115,114,113,112,111,110,109,108,107,106,105,104,103,102,101,100,99,98,97,96,95,94,93,92,91,90,89,88,87,86,85,84,83,82,81,80,79,78,77,76,75,74,73,72,71,70,69,68,67,66,65,64,63,62,61,60,59,58,57,56,55,54,53,52,51,50,49,48,47,46,45,44,43,42,41,40,39,38,37,36,35,34,33,32,31,30,29,28,27,26,25,24,23,22,21,20,19,18,17,16,15,14,13,12,11,10,9,8,7,6,5,4,3,2,1}), false); + // REDUCED_DATA limits Taproot tree depth to 7 instead of 128 + BOOST_CHECK_EQUAL(TaprootBuilder::ValidDepths({2,2,2,3,4,5,6,7,7}), true); + BOOST_CHECK_EQUAL(TaprootBuilder::ValidDepths({7,7,6,5,4,3,2,1}), true); + BOOST_CHECK_EQUAL(TaprootBuilder::ValidDepths({8,8,7,6,5,4,3,2,1}), false); XOnlyPubKey key_inner{"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"_hex_u8}; XOnlyPubKey key_1{"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"_hex_u8}; From 41abcea87ff514ed9b88817687582dd9caca4562 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Sat, 4 Oct 2025 12:58:18 +0000 Subject: [PATCH 10/34] policy: enforce SCRIPT_VERIFY_REDUCED_DATA as a policy rule Co-Authored-By: Dathon Ohm --- src/policy/policy.h | 3 ++- src/script/interpreter.h | 3 +++ src/test/transaction_tests.cpp | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/policy/policy.h b/src/policy/policy.h index 9dbab66a75e0..d19433285e05 100644 --- a/src/policy/policy.h +++ b/src/policy/policy.h @@ -170,7 +170,8 @@ static constexpr unsigned int STANDARD_SCRIPT_VERIFY_FLAGS{MANDATORY_SCRIPT_VERI SCRIPT_VERIFY_CONST_SCRIPTCODE | SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_TAPROOT_VERSION | SCRIPT_VERIFY_DISCOURAGE_OP_SUCCESS | - SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_PUBKEYTYPE}; + SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_PUBKEYTYPE | + SCRIPT_VERIFY_REDUCED_DATA}; /** For convenience, standard but not mandatory verify flags. */ static constexpr unsigned int STANDARD_NOT_MANDATORY_VERIFY_FLAGS{STANDARD_SCRIPT_VERIFY_FLAGS & ~MANDATORY_SCRIPT_VERIFY_FLAGS}; diff --git a/src/script/interpreter.h b/src/script/interpreter.h index 15b4d09dd3ce..22e234fb76b2 100644 --- a/src/script/interpreter.h +++ b/src/script/interpreter.h @@ -143,6 +143,9 @@ enum : uint32_t { // Making unknown public key versions (in BIP 342 scripts) non-standard SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_PUBKEYTYPE = (1U << 20), + // TBD + SCRIPT_VERIFY_REDUCED_DATA = (1U << 21), + // Constants to point to the highest flag in use. Add new flags above this line. // SCRIPT_VERIFY_END_MARKER diff --git a/src/test/transaction_tests.cpp b/src/test/transaction_tests.cpp index f21f1f2ca2c8..58ed2a9c1be3 100644 --- a/src/test/transaction_tests.cpp +++ b/src/test/transaction_tests.cpp @@ -71,6 +71,7 @@ static std::map mapFlagNames = { {std::string("DISCOURAGE_UPGRADABLE_PUBKEYTYPE"), (unsigned int)SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_PUBKEYTYPE}, {std::string("DISCOURAGE_OP_SUCCESS"), (unsigned int)SCRIPT_VERIFY_DISCOURAGE_OP_SUCCESS}, {std::string("DISCOURAGE_UPGRADABLE_TAPROOT_VERSION"), (unsigned int)SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_TAPROOT_VERSION}, + {std::string("REDUCED_DATA"), (unsigned int)SCRIPT_VERIFY_REDUCED_DATA}, }; unsigned int ParseScriptFlags(std::string strFlags) From f595e6a47d3210302b9be60419b206674f470102 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Mon, 6 Oct 2025 15:29:51 +0000 Subject: [PATCH 11/34] script: Define SCRIPT_VERIFY_REDUCED_DATA verification flag (unused) to reduce data push size limit to 256 bytes (except for P2SH redeemScript push); adapt tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Dathon Ohm Co-Authored-By: Léo Haf --- src/script/interpreter.cpp | 22 ++++++++++++++++++++-- src/script/interpreter.h | 3 ++- src/script/script.h | 1 + src/test/data/tx_valid.json | 6 +++--- src/test/fuzz/miniscript.cpp | 19 +++++++++++++------ src/test/miniscript_tests.cpp | 19 +++++++++++++------ 6 files changed, 52 insertions(+), 18 deletions(-) diff --git a/src/script/interpreter.cpp b/src/script/interpreter.cpp index 7d32fec1f188..78354c8d5736 100644 --- a/src/script/interpreter.cpp +++ b/src/script/interpreter.cpp @@ -433,6 +433,8 @@ bool EvalScript(std::vector >& stack, const CScript& execdata.m_codeseparator_pos = 0xFFFFFFFFUL; execdata.m_codeseparator_pos_init = true; + const unsigned int max_element_size = (flags & SCRIPT_VERIFY_REDUCED_DATA) ? MAX_SCRIPT_ELEMENT_SIZE_REDUCED : MAX_SCRIPT_ELEMENT_SIZE; + try { for (; pc < pend; ++opcode_pos) { @@ -443,7 +445,7 @@ bool EvalScript(std::vector >& stack, const CScript& // if (!script.GetOp(pc, opcode, vchPushValue)) return set_error(serror, SCRIPT_ERR_BAD_OPCODE); - if (vchPushValue.size() > MAX_SCRIPT_ELEMENT_SIZE) + if (vchPushValue.size() > max_element_size) return set_error(serror, SCRIPT_ERR_PUSH_SIZE); if (sigversion == SigVersion::BASE || sigversion == SigVersion::WITNESS_V0) { @@ -1858,8 +1860,9 @@ static bool ExecuteWitnessScript(const Span& stack_span, const CS } // Disallow stack item size > MAX_SCRIPT_ELEMENT_SIZE in witness stack + const unsigned int max_element_size = (flags & SCRIPT_VERIFY_REDUCED_DATA) ? MAX_SCRIPT_ELEMENT_SIZE_REDUCED : MAX_SCRIPT_ELEMENT_SIZE; for (const valtype& elem : stack) { - if (elem.size() > MAX_SCRIPT_ELEMENT_SIZE) return set_error(serror, SCRIPT_ERR_PUSH_SIZE); + if (elem.size() > max_element_size) return set_error(serror, SCRIPT_ERR_PUSH_SIZE); } // Run the script interpreter. @@ -2018,6 +2021,12 @@ bool VerifyScript(const CScript& scriptSig, const CScript& scriptPubKey, const C // scriptSig and scriptPubKey must be evaluated sequentially on the same stack // rather than being simply concatenated (see CVE-2010-5141) std::vector > stack, stackCopy; + if (scriptPubKey.IsPayToScriptHash()) { + // Disable SCRIPT_VERIFY_REDUCED_DATA for pushing the P2SH redeemScript + if (!EvalScript(stack, scriptSig, flags & ~SCRIPT_VERIFY_REDUCED_DATA, checker, SigVersion::BASE, serror)) + // serror is set + return false; + } else if (!EvalScript(stack, scriptSig, flags, checker, SigVersion::BASE, serror)) // serror is set return false; @@ -2069,6 +2078,15 @@ bool VerifyScript(const CScript& scriptSig, const CScript& scriptPubKey, const C CScript pubKey2(pubKeySerialized.begin(), pubKeySerialized.end()); popstack(stack); + if (flags & SCRIPT_VERIFY_REDUCED_DATA) { + // We bypassed the reduced data check above to exempt redeemScript + // Now enforce it on the rest of the stack items here + // This is sufficient because P2SH requires scriptSig to be push-only + for (const valtype& elem : stack) { + if (elem.size() > MAX_SCRIPT_ELEMENT_SIZE_REDUCED) return set_error(serror, SCRIPT_ERR_PUSH_SIZE); + } + } + if (!EvalScript(stack, pubKey2, flags, checker, SigVersion::BASE, serror)) // serror is set return false; diff --git a/src/script/interpreter.h b/src/script/interpreter.h index 22e234fb76b2..e637bdbd38c8 100644 --- a/src/script/interpreter.h +++ b/src/script/interpreter.h @@ -143,7 +143,8 @@ enum : uint32_t { // Making unknown public key versions (in BIP 342 scripts) non-standard SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_PUBKEYTYPE = (1U << 20), - // TBD + // Enforce MAX_SCRIPT_ELEMENT_SIZE_REDUCED instead of MAX_SCRIPT_ELEMENT_SIZE + // The P2SH redeemScript push is exempted SCRIPT_VERIFY_REDUCED_DATA = (1U << 21), // Constants to point to the highest flag in use. Add new flags above this line. diff --git a/src/script/script.h b/src/script/script.h index f38d15811953..2e532f9c5500 100644 --- a/src/script/script.h +++ b/src/script/script.h @@ -26,6 +26,7 @@ // Maximum number of bytes pushable to the stack static const unsigned int MAX_SCRIPT_ELEMENT_SIZE = 520; +static const unsigned int MAX_SCRIPT_ELEMENT_SIZE_REDUCED = 256; // Maximum number of non-push operations per script static const int MAX_OPS_PER_SCRIPT = 201; diff --git a/src/test/data/tx_valid.json b/src/test/data/tx_valid.json index 70df0d0f697d..547deefe2c20 100644 --- a/src/test/data/tx_valid.json +++ b/src/test/data/tx_valid.json @@ -414,9 +414,9 @@ ["0000000000000000000000000000000000000000000000000000000000000100", 2, "0x51", 3000]], "0100000000010300010000000000000000000000000000000000000000000000000000000000000000000000ffffffff00010000000000000000000000000000000000000000000000000000000000000100000000ffffffff00010000000000000000000000000000000000000000000000000000000000000200000000ffffffff03e8030000000000000151d0070000000000000151b80b00000000000001510002483045022100a3cec69b52cba2d2de623ffffffffff1606184ea55476c0f8189fda231bc9cbb022003181ad597f7c380a7d1c740286b1d022b8b04ded028b833282e055e03b8efef812103596d3451025c19dbbdeb932d6bf8bfb4ad499b95b6f88db8899efac102e5fc710000000000", "DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM"], -["Witness with a push of 520 bytes"], -[[["0000000000000000000000000000000000000000000000000000000000000100", 0, "0x00 0x20 0x33198a9bfef674ebddb9ffaa52928017b8472791e54c609cb95f278ac6b1e349", 1000]], -"0100000000010100010000000000000000000000000000000000000000000000000000000000000000000000ffffffff010000000000000000015102fd08020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002755100000000", "NONE"], +["Witness with a push of 256 bytes (REDUCED_DATA limit)"], +[[["0000000000000000000000000000000000000000000000000000000000000100", 0, "0x00 0x20 0xa57e25ffadd285772f5627ec6fa613bc8fb49b4db475c371dfd4eb76f25c5073", 1000]], +"0100000000010100010000000000000000000000000000000000000000000000000000000000000000000000ffffffff010000000000000000015101fd05014d000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000755100000000", "NONE"], ["Transaction mixing all SigHash, segwit and normal inputs"], [[["0000000000000000000000000000000000000000000000000000000000000100", 0, "0x00 0x14 0x4c9c3dfac4207d5d8cb89df5722cb3d712385e3f", 1000], diff --git a/src/test/fuzz/miniscript.cpp b/src/test/fuzz/miniscript.cpp index 60d096bb5a99..aae7f99f3e13 100644 --- a/src/test/fuzz/miniscript.cpp +++ b/src/test/fuzz/miniscript.cpp @@ -1132,13 +1132,17 @@ void TestNode(const MsCtx script_ctx, const NodeRef& node, FuzzedDataProvider& p SatisfactionToWitness(script_ctx, witness_nonmal, script, builder); ScriptError serror; bool res = VerifyScript(DUMMY_SCRIPTSIG, script_pubkey, &witness_nonmal, STANDARD_SCRIPT_VERIFY_FLAGS, CHECKER_CTX, &serror); - // Non-malleable satisfactions are guaranteed to be valid if ValidSatisfactions(). - if (node->ValidSatisfactions()) assert(res); + // Non-malleable satisfactions are guaranteed to be valid if ValidSatisfactions(), unless REDUCED_DATA rules are violated. + if (node->ValidSatisfactions()) { + assert(res || + serror == ScriptError::SCRIPT_ERR_PUSH_SIZE); + } // More detailed: non-malleable satisfactions must be valid, or could fail with ops count error (if CheckOpsLimit failed), - // or with a stack size error (if CheckStackSize check failed). + // or with a stack size error (if CheckStackSize check failed), or with REDUCED_DATA-related errors. assert(res || (!node->CheckOpsLimit() && serror == ScriptError::SCRIPT_ERR_OP_COUNT) || - (!node->CheckStackSize() && serror == ScriptError::SCRIPT_ERR_STACK_SIZE)); + (!node->CheckStackSize() && serror == ScriptError::SCRIPT_ERR_STACK_SIZE) || + serror == ScriptError::SCRIPT_ERR_PUSH_SIZE); } if (mal_success && (!nonmal_success || witness_mal.stack != witness_nonmal.stack)) { @@ -1148,8 +1152,11 @@ void TestNode(const MsCtx script_ctx, const NodeRef& node, FuzzedDataProvider& p ScriptError serror; bool res = VerifyScript(DUMMY_SCRIPTSIG, script_pubkey, &witness_mal, STANDARD_SCRIPT_VERIFY_FLAGS, CHECKER_CTX, &serror); // Malleable satisfactions are not guaranteed to be valid under any conditions, but they can only - // fail due to stack or ops limits. - assert(res || serror == ScriptError::SCRIPT_ERR_OP_COUNT || serror == ScriptError::SCRIPT_ERR_STACK_SIZE); + // fail due to stack or ops limits, or REDUCED_DATA-related errors. + assert(res || + serror == ScriptError::SCRIPT_ERR_OP_COUNT || + serror == ScriptError::SCRIPT_ERR_STACK_SIZE || + serror == ScriptError::SCRIPT_ERR_PUSH_SIZE); } if (node->IsSane()) { diff --git a/src/test/miniscript_tests.cpp b/src/test/miniscript_tests.cpp index 47fc45df4ac5..8c3ec66b6e2f 100644 --- a/src/test/miniscript_tests.cpp +++ b/src/test/miniscript_tests.cpp @@ -393,13 +393,17 @@ void TestSatisfy(const KeyConverter& converter, const std::string& testcase, con // Test non-malleable satisfaction. ScriptError serror; bool res = VerifyScript(CScript(), script_pubkey, &witness_nonmal, STANDARD_SCRIPT_VERIFY_FLAGS, checker, &serror); - // Non-malleable satisfactions are guaranteed to be valid if ValidSatisfactions(). - if (node->ValidSatisfactions()) BOOST_CHECK(res); + // Non-malleable satisfactions are guaranteed to be valid if ValidSatisfactions(), unless REDUCED_DATA rules are violated. + if (node->ValidSatisfactions()) { + BOOST_CHECK(res || + serror == ScriptError::SCRIPT_ERR_PUSH_SIZE); + } // More detailed: non-malleable satisfactions must be valid, or could fail with ops count error (if CheckOpsLimit failed), - // or with a stack size error (if CheckStackSize check fails). + // or with a stack size error (if CheckStackSize check fails), or with REDUCED_DATA-related errors. BOOST_CHECK(res || (!node->CheckOpsLimit() && serror == ScriptError::SCRIPT_ERR_OP_COUNT) || - (!node->CheckStackSize() && serror == ScriptError::SCRIPT_ERR_STACK_SIZE)); + (!node->CheckStackSize() && serror == ScriptError::SCRIPT_ERR_STACK_SIZE) || + (serror == ScriptError::SCRIPT_ERR_PUSH_SIZE)); } if (mal_success && (!nonmal_success || witness_mal.stack != witness_nonmal.stack)) { @@ -407,8 +411,11 @@ void TestSatisfy(const KeyConverter& converter, const std::string& testcase, con ScriptError serror; bool res = VerifyScript(CScript(), script_pubkey, &witness_mal, STANDARD_SCRIPT_VERIFY_FLAGS, checker, &serror); // Malleable satisfactions are not guaranteed to be valid under any conditions, but they can only - // fail due to stack or ops limits. - BOOST_CHECK(res || serror == ScriptError::SCRIPT_ERR_OP_COUNT || serror == ScriptError::SCRIPT_ERR_STACK_SIZE); + // fail due to stack or ops limits, or REDUCED_DATA-related errors. + BOOST_CHECK(res || + serror == ScriptError::SCRIPT_ERR_OP_COUNT || + serror == ScriptError::SCRIPT_ERR_STACK_SIZE || + serror == ScriptError::SCRIPT_ERR_PUSH_SIZE); } if (node->IsSane()) { From 9efe1415e200c89d24d0b491a835d722bfacf846 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Fri, 3 Oct 2025 13:32:53 +0000 Subject: [PATCH 12/34] script: forbid Taproot annex with SCRIPT_VERIFY_REDUCED_DATA --- src/script/interpreter.cpp | 3 +++ src/script/interpreter.h | 1 + 2 files changed, 4 insertions(+) diff --git a/src/script/interpreter.cpp b/src/script/interpreter.cpp index 78354c8d5736..0b1fc8aa4ecd 100644 --- a/src/script/interpreter.cpp +++ b/src/script/interpreter.cpp @@ -1956,6 +1956,9 @@ static bool VerifyWitnessProgram(const CScriptWitness& witness, int witversion, if (stack.size() >= 2 && !stack.back().empty() && stack.back()[0] == ANNEX_TAG) { // Drop annex (this is non-standard; see IsWitnessStandard) const valtype& annex = SpanPopBack(stack); + if (flags & SCRIPT_VERIFY_REDUCED_DATA) { + return set_error(serror, SCRIPT_ERR_PUSH_SIZE); + } execdata.m_annex_hash = (HashWriter{} << annex).GetSHA256(); execdata.m_annex_present = true; } else { diff --git a/src/script/interpreter.h b/src/script/interpreter.h index e637bdbd38c8..0ca44a032589 100644 --- a/src/script/interpreter.h +++ b/src/script/interpreter.h @@ -145,6 +145,7 @@ enum : uint32_t { // Enforce MAX_SCRIPT_ELEMENT_SIZE_REDUCED instead of MAX_SCRIPT_ELEMENT_SIZE // The P2SH redeemScript push is exempted + // Taproot annex is also invalid SCRIPT_VERIFY_REDUCED_DATA = (1U << 21), // Constants to point to the highest flag in use. Add new flags above this line. From e1ae973baffa67f1a8da6186e3b0eff1db0c6787 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Fri, 3 Oct 2025 14:42:16 +0000 Subject: [PATCH 13/34] script: Forbid OP_IF in Tapscript with SCRIPT_VERIFY_REDUCED_DATA (still unused) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Dathon Ohm Co-Authored-By: Léo Haf --- src/script/interpreter.cpp | 5 +++++ src/script/interpreter.h | 1 + src/test/descriptor_tests.cpp | 4 +++- src/test/fuzz/miniscript.cpp | 9 ++++++--- src/test/miniscript_tests.cpp | 9 ++++++--- test/functional/feature_taproot.py | 10 ++++++++-- 6 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/script/interpreter.cpp b/src/script/interpreter.cpp index 0b1fc8aa4ecd..1dcece04394e 100644 --- a/src/script/interpreter.cpp +++ b/src/script/interpreter.cpp @@ -618,6 +618,11 @@ bool EvalScript(std::vector >& stack, const CScript& if (vch.size() > 1 || (vch.size() == 1 && vch[0] != 1)) { return set_error(serror, SCRIPT_ERR_TAPSCRIPT_MINIMALIF); } + // REDUCED_DATA bans OP_IF/OP_NOTIF entirely in tapscript; + // reuses MINIMALIF error code as this is a stricter form of the same restriction + if (flags & SCRIPT_VERIFY_REDUCED_DATA) { + return set_error(serror, SCRIPT_ERR_TAPSCRIPT_MINIMALIF); + } } // Under witness v0 rules it is only a policy rule, enabled through SCRIPT_VERIFY_MINIMALIF. if (sigversion == SigVersion::WITNESS_V0 && (flags & SCRIPT_VERIFY_MINIMALIF)) { diff --git a/src/script/interpreter.h b/src/script/interpreter.h index 0ca44a032589..fd7dc151089c 100644 --- a/src/script/interpreter.h +++ b/src/script/interpreter.h @@ -146,6 +146,7 @@ enum : uint32_t { // Enforce MAX_SCRIPT_ELEMENT_SIZE_REDUCED instead of MAX_SCRIPT_ELEMENT_SIZE // The P2SH redeemScript push is exempted // Taproot annex is also invalid + // OP_IF is also forbidden inside Tapscript SCRIPT_VERIFY_REDUCED_DATA = (1U << 21), // Constants to point to the highest flag in use. Add new flags above this line. diff --git a/src/test/descriptor_tests.cpp b/src/test/descriptor_tests.cpp index baf288b4d67f..223d2934acf5 100644 --- a/src/test/descriptor_tests.cpp +++ b/src/test/descriptor_tests.cpp @@ -1006,6 +1006,7 @@ BOOST_AUTO_TEST_CASE(descriptor_test) CheckUnparsable("sh(and_v(vc:andor(pk(L4gM1FBdyHNpkzsFh9ipnofLhpZRp2mwobpeULy1a6dBTvw8Ywtd),pk_k(Kx9HCDjGiwFcgVNhTrS5z5NeZdD6veeam61eDxLDCkGWujvL4Gnn),and_v(v:older(1),pk_k(L4o2kDvXXDRH2VS9uBnouScLduWt4dZnM25se7kvEjJeQ285en2A))),after(10)))", "sh(and_v(vc:andor(pk(03cdabb7f2dce7bfbd8a0b9570c6fd1e712e5d64045e9d6b517b3d5072251dc204),pk_k(032707170c71d8f75e4ca4e3fce870b9409dcaf12b051d3bcadff74747fa7619c0),and_v(v:older(1),pk_k(02aa27e5eb2c185e87cd1dbc3e0efc9cb1175235e0259df1713424941c3cb40402))),after(10)))", "Miniscript expressions can only be used in wsh or tr."); CheckUnparsable("tr(and_v(vc:andor(pk(L4gM1FBdyHNpkzsFh9ipnofLhpZRp2mwobpeULy1a6dBTvw8Ywtd),pk_k(Kx9HCDjGiwFcgVNhTrS5z5NeZdD6veeam61eDxLDCkGWujvL4Gnn),and_v(v:older(1),pk_k(L4o2kDvXXDRH2VS9uBnouScLduWt4dZnM25se7kvEjJeQ285en2A))),after(10)))", "tr(and_v(vc:andor(pk(03cdabb7f2dce7bfbd8a0b9570c6fd1e712e5d64045e9d6b517b3d5072251dc204),pk_k(032707170c71d8f75e4ca4e3fce870b9409dcaf12b051d3bcadff74747fa7619c0),and_v(v:older(1),pk_k(02aa27e5eb2c185e87cd1dbc3e0efc9cb1175235e0259df1713424941c3cb40402))),after(10)))", "tr(): key 'and_v(vc:andor(pk(03cdabb7f2dce7bfbd8a0b9570c6fd1e712e5d64045e9d6b517b3d5072251dc204),pk_k(032707170c71d8f75e4ca4e3fce870b9409dcaf12b051d3bcadff74747fa7619c0),and_v(v:older(1),pk_k(02aa27e5eb2c185e87cd1dbc3e0efc9cb1175235e0259df1713424941c3cb40402))),after(10))' is not valid"); CheckUnparsable("raw(and_v(vc:andor(pk(L4gM1FBdyHNpkzsFh9ipnofLhpZRp2mwobpeULy1a6dBTvw8Ywtd),pk_k(Kx9HCDjGiwFcgVNhTrS5z5NeZdD6veeam61eDxLDCkGWujvL4Gnn),and_v(v:older(1),pk_k(L4o2kDvXXDRH2VS9uBnouScLduWt4dZnM25se7kvEjJeQ285en2A))),after(10)))", "sh(and_v(vc:andor(pk(03cdabb7f2dce7bfbd8a0b9570c6fd1e712e5d64045e9d6b517b3d5072251dc204),pk_k(032707170c71d8f75e4ca4e3fce870b9409dcaf12b051d3bcadff74747fa7619c0),and_v(v:older(1),pk_k(02aa27e5eb2c185e87cd1dbc3e0efc9cb1175235e0259df1713424941c3cb40402))),after(10)))", "Miniscript expressions can only be used in wsh or tr."); + // REDUCED_DATA limits Taproot nesting to 7 levels, so this test now hits that limit before the multi() error CheckUnparsable("", "tr(034D2224bbbbbbbbbbcbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb40,{{{{{{{{{{{{{{{{{{{{{{multi(1,xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc/967808'/9,xprvA1RpRA33e1JQ7ifknakTFNpgXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc/968/2/5/8/5/2/5/58/58/2/5/5/5/58/588/2/6/8/5/2/8/2/5/8/2/58/2/5/8/5/2/8/5/8/3/4/5/58/55/2/5/58/58/2/5/5/5/8/5/2/8/5/85/2/8/2/5/8/5/2/5/58/58/2/5/58/58/588/2/58/2/8/5/8/5/4/5/585/2/5/58/58/2/5/5/58/588/2/58/2/5/8/5/2/8/2/5/8/5/5/58/588/2/6/8/5/2/8/2/5/8/5/2/5/58/58/2/5/58/58/2/0/8/5/2/8/5/8/5/4/5/58/588/2/6/8/5/2/8/2/5/8/5/2/5/58/58/2/5/58/58/588/2/58/2/5/8/5/8/24/5/58/52/5/8/5/2/8/24/5/58/588/246/8/5/2/8/2/5/8/5/2/5/58/58/2/5/5/5/58/588/2/6/8/5/2/8/2/5/8/2/58/2/5/8/5/2/8/5/8/5/4/5/58/55/58/2/5/8/55/2/5/8/58/555/58/2/5/8/4//2/5/58/5w/2/5/8/5/2/4/5/58/5558'/2/5/58/58/2/5/5/58/588/2/58/2/5/8/5/2/8/2/5/8/5/5/8/58/2/5/58/58/2/5/8/9/588/2/58/2/5/8/5/2/8/5/8/5/4/5/58/588/2/6/8/5/2/8/2/5/8/5/2/5/58/58/2/5/5/58/588/2/58/2/5/8/5/2/82/5/8/5/5/58/52/6/8/5/2/8/{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{}{{{{{{{{{DDD2/5/8/5/2/5/58/58/2/5/58/58/588/2/58/2/8/5/8/5/4/5/58/588/2/6/8/5/2/8/2/5/8588/246/8/5/2DLDDDDDDDbbD3DDDD/8/2/5/8/5/2/5/58/58/2/5/5/5/58/588/2/6/8/5/2/8/2/5/8/2/58/2/5/8/5/2/8/5/8/3/4/5/58/55/2/5/58/58/2/5/5/5/8/5/2/8/5/85/2/8/2/5/8D)/5/2/5/58/58/2/5/58/58/58/588/2/58/2/5/8/5/25/58/58/2/5/58/58/2/5/8/9/588/2/58/2/6780,xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFW/8/5/2/5/58678008')", "tr() supports at most 7 nesting levels"); // No uncompressed keys allowed CheckUnparsable("", "wsh(and_v(vc:andor(pk(03cdabb7f2dce7bfbd8a0b9570c6fd1e712e5d64045e9d6b517b3d5072251dc204),pk_k(032707170c71d8f75e4ca4e3fce870b9409dcaf12b051d3bcadff74747fa7619c0),and_v(v:older(1),pk_k(049228de6902abb4f541791f6d7f925b10e2078ccb1298856e5ea5cc5fd667f930eac37a00cc07f9a91ef3c2d17bf7a17db04552ff90ac312a5b8b4caca6c97aa4))),after(10)))", "Uncompressed keys are not allowed"); @@ -1047,7 +1048,8 @@ BOOST_AUTO_TEST_CASE(descriptor_test) Check("wsh(and_v(v:hash256(ae253ca2a54debcac7ecf414f6734f48c56421a08bb59182ff9f39a6fffdb588),pk(xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc)))", "wsh(and_v(v:hash256(ae253ca2a54debcac7ecf414f6734f48c56421a08bb59182ff9f39a6fffdb588),pk(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL)))", "wsh(and_v(v:hash256(ae253ca2a54debcac7ecf414f6734f48c56421a08bb59182ff9f39a6fffdb588),pk(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL)))", SIGNABLE_FAILS, {{"0020cf62bf97baf977aec69cbc290c372899f913337a9093e8f066ab59b8657a365c"}}, OutputType::BECH32, /*op_desc_id=*/uint256{"8412ba3ac20ba3a30f81442d10d32e0468fa52814960d04e959bf84a9b813b88"}, {{}}, /*spender_nlocktime=*/0, /*spender_nsequence=*/CTxIn::SEQUENCE_FINAL, {}); Check("wsh(and_v(v:hash256(ae253ca2a54debcac7ecf414f6734f48c56421a08bb59182ff9f39a6fffdb588),pk(xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc)))", "wsh(and_v(v:hash256(ae253ca2a54debcac7ecf414f6734f48c56421a08bb59182ff9f39a6fffdb588),pk(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL)))", "wsh(and_v(v:hash256(ae253ca2a54debcac7ecf414f6734f48c56421a08bb59182ff9f39a6fffdb588),pk(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL)))", SIGNABLE, {{"0020cf62bf97baf977aec69cbc290c372899f913337a9093e8f066ab59b8657a365c"}}, OutputType::BECH32, /*op_desc_id=*/uint256{"8412ba3ac20ba3a30f81442d10d32e0468fa52814960d04e959bf84a9b813b88"}, {{}}, /*spender_nlocktime=*/0, /*spender_nsequence=*/CTxIn::SEQUENCE_FINAL, {{"ae253ca2a54debcac7ecf414f6734f48c56421a08bb59182ff9f39a6fffdb588"_hex_v_u8, "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"_hex_v_u8}}); // Can have a Miniscript expression under tr() if it's alone. - Check("tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,thresh(2,pk(L1NKM8dVA1h52mwDrmk1YreTWkAZZTu2vmKLpmLEbFRqGQYjHeEV),s:pk(Kz3iCBy3HNGP5CZWDsAMmnCMFNwqdDohudVN9fvkrN7tAkzKNtM7),adv:older(42)))", "tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,thresh(2,pk(30a6069f344fb784a2b4c99540a91ee727c91e3a25ef6aae867d9c65b5f23529),s:pk(9918d400c1b8c3c478340a40117ced4054b6b58f48cdb3c89b836bdfee1f5766),adv:older(42)))", "tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,thresh(2,pk(30a6069f344fb784a2b4c99540a91ee727c91e3a25ef6aae867d9c65b5f23529),s:pk(9918d400c1b8c3c478340a40117ced4054b6b58f48cdb3c89b836bdfee1f5766),adv:older(42)))", MISSING_PRIVKEYS | XONLY_KEYS | SIGNABLE, {{"512033982eebe204dc66508e4b19cfc31b5ffc6e1bfcbf6e5597dfc2521a52270795"}}, OutputType::BECH32M); + // Note: thresh() uses OP_IF which is forbidden with REDUCED_DATA, so using and_v() instead + Check("tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,and_v(v:pk(L1NKM8dVA1h52mwDrmk1YreTWkAZZTu2vmKLpmLEbFRqGQYjHeEV),pk(Kz3iCBy3HNGP5CZWDsAMmnCMFNwqdDohudVN9fvkrN7tAkzKNtM7)))", "tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,and_v(v:pk(30a6069f344fb784a2b4c99540a91ee727c91e3a25ef6aae867d9c65b5f23529),pk(9918d400c1b8c3c478340a40117ced4054b6b58f48cdb3c89b836bdfee1f5766)))", "tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,and_v(v:pk(30a6069f344fb784a2b4c99540a91ee727c91e3a25ef6aae867d9c65b5f23529),pk(9918d400c1b8c3c478340a40117ced4054b6b58f48cdb3c89b836bdfee1f5766)))", MISSING_PRIVKEYS | XONLY_KEYS | SIGNABLE, {{"51202aca0fdcbfbc513549e2c9490e60ba54e3c345ff01d667c4f846c802c0e7b8f4"}}, OutputType::BECH32M); // Can have a pkh() expression alone as tr() script path (because pkh() is valid Miniscript). Check("tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,pkh(L1NKM8dVA1h52mwDrmk1YreTWkAZZTu2vmKLpmLEbFRqGQYjHeEV))", "tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,pkh(30a6069f344fb784a2b4c99540a91ee727c91e3a25ef6aae867d9c65b5f23529))", "tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,pkh(30a6069f344fb784a2b4c99540a91ee727c91e3a25ef6aae867d9c65b5f23529))", MISSING_PRIVKEYS | XONLY_KEYS | SIGNABLE, {{"51201e9875f690f5847404e4c5951e2f029887df0525691ee11a682afd37b608aad4"}}, OutputType::BECH32M); // Can have a Miniscript expression under tr() if it's part of a tree. diff --git a/src/test/fuzz/miniscript.cpp b/src/test/fuzz/miniscript.cpp index aae7f99f3e13..7488245e699d 100644 --- a/src/test/fuzz/miniscript.cpp +++ b/src/test/fuzz/miniscript.cpp @@ -1135,14 +1135,16 @@ void TestNode(const MsCtx script_ctx, const NodeRef& node, FuzzedDataProvider& p // Non-malleable satisfactions are guaranteed to be valid if ValidSatisfactions(), unless REDUCED_DATA rules are violated. if (node->ValidSatisfactions()) { assert(res || - serror == ScriptError::SCRIPT_ERR_PUSH_SIZE); + serror == ScriptError::SCRIPT_ERR_PUSH_SIZE || + serror == ScriptError::SCRIPT_ERR_TAPSCRIPT_MINIMALIF); } // More detailed: non-malleable satisfactions must be valid, or could fail with ops count error (if CheckOpsLimit failed), // or with a stack size error (if CheckStackSize check failed), or with REDUCED_DATA-related errors. assert(res || (!node->CheckOpsLimit() && serror == ScriptError::SCRIPT_ERR_OP_COUNT) || (!node->CheckStackSize() && serror == ScriptError::SCRIPT_ERR_STACK_SIZE) || - serror == ScriptError::SCRIPT_ERR_PUSH_SIZE); + serror == ScriptError::SCRIPT_ERR_PUSH_SIZE || + serror == ScriptError::SCRIPT_ERR_TAPSCRIPT_MINIMALIF); } if (mal_success && (!nonmal_success || witness_mal.stack != witness_nonmal.stack)) { @@ -1156,7 +1158,8 @@ void TestNode(const MsCtx script_ctx, const NodeRef& node, FuzzedDataProvider& p assert(res || serror == ScriptError::SCRIPT_ERR_OP_COUNT || serror == ScriptError::SCRIPT_ERR_STACK_SIZE || - serror == ScriptError::SCRIPT_ERR_PUSH_SIZE); + serror == ScriptError::SCRIPT_ERR_PUSH_SIZE || + serror == ScriptError::SCRIPT_ERR_TAPSCRIPT_MINIMALIF); } if (node->IsSane()) { diff --git a/src/test/miniscript_tests.cpp b/src/test/miniscript_tests.cpp index 8c3ec66b6e2f..28bb6c8744f9 100644 --- a/src/test/miniscript_tests.cpp +++ b/src/test/miniscript_tests.cpp @@ -396,14 +396,16 @@ void TestSatisfy(const KeyConverter& converter, const std::string& testcase, con // Non-malleable satisfactions are guaranteed to be valid if ValidSatisfactions(), unless REDUCED_DATA rules are violated. if (node->ValidSatisfactions()) { BOOST_CHECK(res || - serror == ScriptError::SCRIPT_ERR_PUSH_SIZE); + serror == ScriptError::SCRIPT_ERR_PUSH_SIZE || + serror == ScriptError::SCRIPT_ERR_TAPSCRIPT_MINIMALIF); } // More detailed: non-malleable satisfactions must be valid, or could fail with ops count error (if CheckOpsLimit failed), // or with a stack size error (if CheckStackSize check fails), or with REDUCED_DATA-related errors. BOOST_CHECK(res || (!node->CheckOpsLimit() && serror == ScriptError::SCRIPT_ERR_OP_COUNT) || (!node->CheckStackSize() && serror == ScriptError::SCRIPT_ERR_STACK_SIZE) || - (serror == ScriptError::SCRIPT_ERR_PUSH_SIZE)); + (serror == ScriptError::SCRIPT_ERR_PUSH_SIZE) || + (serror == ScriptError::SCRIPT_ERR_TAPSCRIPT_MINIMALIF)); } if (mal_success && (!nonmal_success || witness_mal.stack != witness_nonmal.stack)) { @@ -415,7 +417,8 @@ void TestSatisfy(const KeyConverter& converter, const std::string& testcase, con BOOST_CHECK(res || serror == ScriptError::SCRIPT_ERR_OP_COUNT || serror == ScriptError::SCRIPT_ERR_STACK_SIZE || - serror == ScriptError::SCRIPT_ERR_PUSH_SIZE); + serror == ScriptError::SCRIPT_ERR_PUSH_SIZE || + serror == ScriptError::SCRIPT_ERR_TAPSCRIPT_MINIMALIF); } if (node->IsSane()) { diff --git a/test/functional/feature_taproot.py b/test/functional/feature_taproot.py index 1f91fe1743b6..f2b3a4b079eb 100755 --- a/test/functional/feature_taproot.py +++ b/test/functional/feature_taproot.py @@ -776,6 +776,7 @@ def spenders_taproot_active(): tap = taproot_construct(pubs[0], scripts) add_spender(spenders, "sighash/pk_codesep", tap=tap, leaf="pk_codesep", key=secs[1], **common, **SINGLE_SIG, **SIGHASH_BITFLIP, **ERR_SIG_SCHNORR) add_spender(spenders, "sighash/codesep_pk", tap=tap, leaf="codesep_pk", key=secs[1], codeseppos=0, **common, **SINGLE_SIG, **SIGHASH_BITFLIP, **ERR_SIG_SCHNORR) + common['standard'] = False add_spender(spenders, "sighash/branched_codesep/left", tap=tap, leaf="branched_codesep", key=secs[0], codeseppos=3, **common, inputs=[getter("sign"), b'\x01'], **SIGHASH_BITFLIP, **ERR_SIG_SCHNORR) add_spender(spenders, "sighash/branched_codesep/right", tap=tap, leaf="branched_codesep", key=secs[1], codeseppos=6, **common, inputs=[getter("sign"), b''], **SIGHASH_BITFLIP, **ERR_SIG_SCHNORR) @@ -1039,10 +1040,13 @@ def big_spend_inputs(ctx): add_spender(spenders, "tapscript/disabled_checkmultisig", leaf="t1", **common, **SINGLE_SIG, failure={"leaf": "t3"}, **ERR_TAPSCRIPT_CHECKMULTISIG) add_spender(spenders, "tapscript/disabled_checkmultisigverify", leaf="t2", **common, **SINGLE_SIG, failure={"leaf": "t4"}, **ERR_TAPSCRIPT_CHECKMULTISIG) # Test that OP_IF and OP_NOTIF do not accept non-0x01 as truth value (the MINIMALIF rule is consensus in Tapscript) + assert 'standard' not in common + common['standard'] = False add_spender(spenders, "tapscript/minimalif", leaf="t5", **common, inputs=[getter("sign"), b'\x01'], failure={"inputs": [getter("sign"), b'\x02']}, **ERR_MINIMALIF) add_spender(spenders, "tapscript/minimalnotif", leaf="t6", **common, inputs=[getter("sign"), b'\x01'], failure={"inputs": [getter("sign"), b'\x03']}, **ERR_MINIMALIF) add_spender(spenders, "tapscript/minimalif", leaf="t5", **common, inputs=[getter("sign"), b'\x01'], failure={"inputs": [getter("sign"), b'\x0001']}, **ERR_MINIMALIF) add_spender(spenders, "tapscript/minimalnotif", leaf="t6", **common, inputs=[getter("sign"), b'\x01'], failure={"inputs": [getter("sign"), b'\x0100']}, **ERR_MINIMALIF) + del common['standard'] # Test that 1-byte public keys (which are unknown) are acceptable but nonstandard with unrelated signatures, but 0-byte public keys are not valid. add_spender(spenders, "tapscript/unkpk/checksig", leaf="t16", standard=False, **common, **SINGLE_SIG, failure={"leaf": "t7"}, **ERR_UNKNOWN_PUBKEY) add_spender(spenders, "tapscript/unkpk/checksigadd", leaf="t17", standard=False, **common, **SINGLE_SIG, failure={"leaf": "t10"}, **ERR_UNKNOWN_PUBKEY) @@ -1131,11 +1135,13 @@ def predict_sigops_ratio(n, dummy_size): dummylen = 0 while not predict_sigops_ratio(n, dummylen): dummylen += 1 - scripts = [("s", fn(n, pubkey)[0])] + script = fn(n, pubkey)[0] + scripts = [("s", script)] for _ in range(merkledepth): scripts = [scripts, random.choice(PARTNER_MERKLE_FN)] tap = taproot_construct(pubs[0], scripts) - standard = annex is None and dummylen <= 80 and len(pubkey) == 32 + has_conditional = any(op in (OP_IF, OP_NOTIF) for op, _, _ in script.raw_iter()) + standard = annex is None and dummylen <= 80 and len(pubkey) == 32 and not has_conditional add_spender(spenders, "tapscript/sigopsratio_%i" % fn_num, tap=tap, leaf="s", annex=annex, hashtype=hashtype, key=secs[1], inputs=[getter("sign"), random.randbytes(dummylen)], standard=standard, failure={"inputs": [getter("sign"), random.randbytes(dummylen - 1)]}, **ERR_SIGOPS_RATIO) # Future leaf versions From 4743b6662290db7462bc5a812058903e7e227558 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Fri, 3 Oct 2025 14:07:23 +0000 Subject: [PATCH 14/34] script: Limit Taproot control block to 257 bytes for SCRIPT_VERIFY_REDUCED_DATA (still unused) --- src/script/interpreter.cpp | 3 ++- src/script/interpreter.h | 1 + test/functional/feature_taproot.py | 8 ++++++-- test/functional/test_framework/script.py | 1 + 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/script/interpreter.cpp b/src/script/interpreter.cpp index 1dcece04394e..c823a615e3d6 100644 --- a/src/script/interpreter.cpp +++ b/src/script/interpreter.cpp @@ -1980,7 +1980,8 @@ static bool VerifyWitnessProgram(const CScriptWitness& witness, int witversion, // Script path spending (stack size is >1 after removing optional annex) const valtype& control = SpanPopBack(stack); const valtype& script = SpanPopBack(stack); - if (control.size() < TAPROOT_CONTROL_BASE_SIZE || control.size() > TAPROOT_CONTROL_MAX_SIZE || ((control.size() - TAPROOT_CONTROL_BASE_SIZE) % TAPROOT_CONTROL_NODE_SIZE) != 0) { + const unsigned int max_control_size = (flags & SCRIPT_VERIFY_REDUCED_DATA) ? TAPROOT_CONTROL_MAX_SIZE_REDUCED : TAPROOT_CONTROL_MAX_SIZE; + if (control.size() < TAPROOT_CONTROL_BASE_SIZE || control.size() > max_control_size || ((control.size() - TAPROOT_CONTROL_BASE_SIZE) % TAPROOT_CONTROL_NODE_SIZE) != 0) { return set_error(serror, SCRIPT_ERR_TAPROOT_WRONG_CONTROL_SIZE); } execdata.m_tapleaf_hash = ComputeTapleafHash(control[0] & TAPROOT_LEAF_MASK, script); diff --git a/src/script/interpreter.h b/src/script/interpreter.h index fd7dc151089c..0f641acc8736 100644 --- a/src/script/interpreter.h +++ b/src/script/interpreter.h @@ -145,6 +145,7 @@ enum : uint32_t { // Enforce MAX_SCRIPT_ELEMENT_SIZE_REDUCED instead of MAX_SCRIPT_ELEMENT_SIZE // The P2SH redeemScript push is exempted + // Taproot control blocks are limited to TAPROOT_CONTROL_MAX_SIZE_REDUCED // Taproot annex is also invalid // OP_IF is also forbidden inside Tapscript SCRIPT_VERIFY_REDUCED_DATA = (1U << 21), diff --git a/test/functional/feature_taproot.py b/test/functional/feature_taproot.py index f2b3a4b079eb..c0980dea9f55 100755 --- a/test/functional/feature_taproot.py +++ b/test/functional/feature_taproot.py @@ -80,6 +80,7 @@ SIGHASH_ANYONECANPAY, SegwitV0SignatureMsg, TaggedHash, + TAPROOT_CONTROL_MAX_NODE_COUNT_REDUCED, TaprootSignatureMsg, is_op_success, taproot_construct, @@ -892,6 +893,8 @@ def mutate(spk): scripts = [scripts, random.choice(PARTNER_MERKLE_FN)] tap = taproot_construct(pubs[0], scripts) # Test that spends with a depth of 128 work, but 129 doesn't (even with a tree with weird Merkle branches in it). + assert 'standard' not in SINGLE_SIG + SINGLE_SIG['standard'] = False add_spender(spenders, "spendpath/merklelimit", tap=tap, leaf="128deep", **SINGLE_SIG, key=secs[0], failure={"leaf": "129deep"}, **ERR_CONTROLBLOCK_SIZE) # Test that flipping the negation bit invalidates spends. add_spender(spenders, "spendpath/negflag", tap=tap, leaf="128deep", **SINGLE_SIG, key=secs[0], failure={"negflag": lambda ctx: 1 - default_negflag(ctx)}, **ERR_WITNESS_PROGRAM_MISMATCH) @@ -905,6 +908,7 @@ def mutate(spk): add_spender(spenders, "spendpath/padlongcontrol", tap=tap, leaf="128deep", **SINGLE_SIG, key=secs[0], failure={"controlblock": lambda ctx: default_controlblock(ctx) + random.randbytes(random.randrange(1, 32))}, **ERR_CONTROLBLOCK_SIZE) # Test that truncating the control block invalidates it. add_spender(spenders, "spendpath/trunclongcontrol", tap=tap, leaf="128deep", **SINGLE_SIG, key=secs[0], failure={"controlblock": lambda ctx: default_merklebranch(ctx)[0:random.randrange(1, 32)]}, **ERR_CONTROLBLOCK_SIZE) + del SINGLE_SIG['standard'] scripts = [("s", CScript([pubs[0], OP_CHECKSIG]))] tap = taproot_construct(pubs[1], scripts) @@ -1023,7 +1027,7 @@ def big_spend_inputs(ctx): ("t36", CScript([])), ] # Add many dummies to test huge trees - for j in range(100000): + for j in range(min(100000, 2**TAPROOT_CONTROL_MAX_NODE_COUNT_REDUCED - len(scripts))): scripts.append((None, CScript([OP_RETURN, random.randrange(100000)]))) random.shuffle(scripts) tap = taproot_construct(pubs[0], scripts) @@ -1141,7 +1145,7 @@ def predict_sigops_ratio(n, dummy_size): scripts = [scripts, random.choice(PARTNER_MERKLE_FN)] tap = taproot_construct(pubs[0], scripts) has_conditional = any(op in (OP_IF, OP_NOTIF) for op, _, _ in script.raw_iter()) - standard = annex is None and dummylen <= 80 and len(pubkey) == 32 and not has_conditional + standard = annex is None and dummylen <= 80 and len(pubkey) == 32 and not has_conditional and merkledepth <= TAPROOT_CONTROL_MAX_NODE_COUNT_REDUCED add_spender(spenders, "tapscript/sigopsratio_%i" % fn_num, tap=tap, leaf="s", annex=annex, hashtype=hashtype, key=secs[1], inputs=[getter("sign"), random.randbytes(dummylen)], standard=standard, failure={"inputs": [getter("sign"), random.randbytes(dummylen - 1)]}, **ERR_SIGOPS_RATIO) # Future leaf versions diff --git a/test/functional/test_framework/script.py b/test/functional/test_framework/script.py index d510cf9b1cac..97008bda4ddb 100644 --- a/test/functional/test_framework/script.py +++ b/test/functional/test_framework/script.py @@ -29,6 +29,7 @@ LOCKTIME_THRESHOLD = 500000000 ANNEX_TAG = 0x50 +TAPROOT_CONTROL_MAX_NODE_COUNT_REDUCED = 7 LEAF_VERSION_TAPSCRIPT = 0xc0 def hash160(s): From 19847696792187318c208b6f220e5a28356b2f8b Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Thu, 12 Feb 2026 00:28:04 -0600 Subject: [PATCH 15/34] script: require empty witness for P2A spends --- src/script/interpreter.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/script/interpreter.cpp b/src/script/interpreter.cpp index c823a615e3d6..eab89bbdbc27 100644 --- a/src/script/interpreter.cpp +++ b/src/script/interpreter.cpp @@ -2001,7 +2001,7 @@ static bool VerifyWitnessProgram(const CScriptWitness& witness, int witversion, } return set_success(serror); } - } else if (!is_p2sh && CScript::IsPayToAnchor(witversion, program)) { + } else if (stack.empty() && !is_p2sh && CScript::IsPayToAnchor(witversion, program)) { return true; } else { if (flags & SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM) { From f58d34bcd668d05604e0fa72225c224bbc99e44a Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Fri, 3 Oct 2025 15:06:45 +0000 Subject: [PATCH 16/34] consensus: Add no-op flags to CheckTxInputs function --- src/consensus/tx_verify.cpp | 2 +- src/consensus/tx_verify.h | 25 ++++++++++++++++++++++++- src/test/fuzz/coins_view.cpp | 2 +- src/txmempool.cpp | 4 +++- src/validation.cpp | 4 ++-- 5 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/consensus/tx_verify.cpp b/src/consensus/tx_verify.cpp index 95466b759cbb..42d1c804d209 100644 --- a/src/consensus/tx_verify.cpp +++ b/src/consensus/tx_verify.cpp @@ -161,7 +161,7 @@ int64_t GetTransactionSigOpCost(const CTransaction& tx, const CCoinsViewCache& i return nSigOps; } -bool Consensus::CheckTxInputs(const CTransaction& tx, TxValidationState& state, const CCoinsViewCache& inputs, int nSpendHeight, CAmount& txfee) +bool Consensus::CheckTxInputs(const CTransaction& tx, TxValidationState& state, const CCoinsViewCache& inputs, int nSpendHeight, CAmount& txfee, const CheckTxInputsRules rules) { // are the actual inputs available? if (!inputs.HaveInputs(tx)) { diff --git a/src/consensus/tx_verify.h b/src/consensus/tx_verify.h index d2cf792cf3f6..5d8eea9d1777 100644 --- a/src/consensus/tx_verify.h +++ b/src/consensus/tx_verify.h @@ -17,6 +17,29 @@ class TxValidationState; /** Transaction validation functions */ +class CheckTxInputsRules { + using underlying_type = unsigned int; + underlying_type m_flags; + constexpr explicit CheckTxInputsRules(underlying_type flags) noexcept : m_flags(flags) {} + + enum class Rule { + None = 0, + }; + +public: + using enum Rule; + + constexpr CheckTxInputsRules(Rule rule) noexcept : m_flags(static_cast(rule)) {} + + [[nodiscard]] constexpr bool test(CheckTxInputsRules rules) const noexcept { + return (m_flags & rules.m_flags) == rules.m_flags; + } + + [[nodiscard]] constexpr CheckTxInputsRules operator|(const CheckTxInputsRules other) const noexcept { + return CheckTxInputsRules{m_flags | other.m_flags}; + } +}; + namespace Consensus { /** * Check whether all inputs of this transaction are valid (no double spends and amounts) @@ -24,7 +47,7 @@ namespace Consensus { * @param[out] txfee Set to the transaction fee if successful. * Preconditions: tx.IsCoinBase() is false. */ -[[nodiscard]] bool CheckTxInputs(const CTransaction& tx, TxValidationState& state, const CCoinsViewCache& inputs, int nSpendHeight, CAmount& txfee); +[[nodiscard]] bool CheckTxInputs(const CTransaction& tx, TxValidationState& state, const CCoinsViewCache& inputs, int nSpendHeight, CAmount& txfee, CheckTxInputsRules rules); } // namespace Consensus /** Auxiliary functions for transaction validation (ideally should not be exposed) */ diff --git a/src/test/fuzz/coins_view.cpp b/src/test/fuzz/coins_view.cpp index 06145e0323b1..b74a605f14ca 100644 --- a/src/test/fuzz/coins_view.cpp +++ b/src/test/fuzz/coins_view.cpp @@ -256,7 +256,7 @@ FUZZ_TARGET(coins_view, .init = initialize_coins_view) // It is not allowed to call CheckTxInputs if CheckTransaction failed return; } - if (Consensus::CheckTxInputs(transaction, state, coins_view_cache, fuzzed_data_provider.ConsumeIntegralInRange(0, std::numeric_limits::max()), tx_fee_out)) { + if (Consensus::CheckTxInputs(transaction, state, coins_view_cache, fuzzed_data_provider.ConsumeIntegralInRange(0, std::numeric_limits::max()), tx_fee_out, CheckTxInputsRules::None)) { assert(MoneyRange(tx_fee_out)); } }, diff --git a/src/txmempool.cpp b/src/txmempool.cpp index dd76521e4e28..4e1d35782560 100644 --- a/src/txmempool.cpp +++ b/src/txmempool.cpp @@ -877,7 +877,9 @@ void CTxMemPool::check(const CCoinsViewCache& active_coins_tip, int64_t spendhei TxValidationState dummy_state; // Not used. CheckTxInputs() should always pass CAmount txfee = 0; assert(!tx.IsCoinBase()); - assert(Consensus::CheckTxInputs(tx, dummy_state, mempoolDuplicate, spendheight, txfee)); + // Skip output size checks (CheckTxInputsRules::None), as these transactions already passed + // output size limits at mempool acceptance; this check only verifies UTXO consistency + assert(Consensus::CheckTxInputs(tx, dummy_state, mempoolDuplicate, spendheight, txfee, CheckTxInputsRules::None)); for (const auto& input: tx.vin) mempoolDuplicate.SpendCoin(input.prevout); AddCoins(mempoolDuplicate, tx, std::numeric_limits::max()); } diff --git a/src/validation.cpp b/src/validation.cpp index e36dea2d5b8d..f7501c547552 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -986,7 +986,7 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws) // The mempool holds txs for the next block, so pass height+1 to CheckTxInputs const auto block_height_current = m_active_chainstate.m_chain.Height(); const auto block_height_next = block_height_current + 1; - if (!Consensus::CheckTxInputs(tx, state, m_view, block_height_next, ws.m_base_fees)) { + if (!Consensus::CheckTxInputs(tx, state, m_view, block_height_next, ws.m_base_fees, CheckTxInputsRules::None)) { return false; // state filled in by CheckTxInputs } @@ -2911,7 +2911,7 @@ bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state, { CAmount txfee = 0; TxValidationState tx_state; - if (!Consensus::CheckTxInputs(tx, tx_state, view, pindex->nHeight, txfee)) { + if (!Consensus::CheckTxInputs(tx, tx_state, view, pindex->nHeight, txfee, CheckTxInputsRules::None)) { // Any transaction validation failure in ConnectBlock is a block consensus failure state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, tx_state.GetRejectReason(), From 34621bab1b1678dcd74c0ea976773e9ed6973f48 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Tue, 7 Oct 2025 13:13:00 +0000 Subject: [PATCH 17/34] consensus: Define CheckTxInputsRules::OutputSizeLimit flag (unused) to cap output scripts at 83 bytes --- src/consensus/consensus.h | 2 ++ src/consensus/tx_verify.cpp | 9 +++++++++ src/consensus/tx_verify.h | 1 + src/test/fuzz/coins_view.cpp | 2 +- 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/consensus/consensus.h b/src/consensus/consensus.h index cffe9cdafd79..0bad6c0a4b1f 100644 --- a/src/consensus/consensus.h +++ b/src/consensus/consensus.h @@ -34,4 +34,6 @@ static constexpr unsigned int LOCKTIME_VERIFY_SEQUENCE = (1 << 0); */ static constexpr int64_t MAX_TIMEWARP = 600; +static constexpr unsigned int MAX_OUTPUT_SCRIPT_SIZE{83}; + #endif // BITCOIN_CONSENSUS_CONSENSUS_H diff --git a/src/consensus/tx_verify.cpp b/src/consensus/tx_verify.cpp index 42d1c804d209..a2ebd99f7d78 100644 --- a/src/consensus/tx_verify.cpp +++ b/src/consensus/tx_verify.cpp @@ -169,6 +169,15 @@ bool Consensus::CheckTxInputs(const CTransaction& tx, TxValidationState& state, strprintf("%s: inputs missing/spent", __func__)); } + // NOTE: CheckTransaction is arguably the more logical place to do this, but it's context-independent, so this is probably the next best place for now + if (rules.test(CheckTxInputsRules::OutputSizeLimit)) { + for (const auto& txout : tx.vout) { + if (txout.scriptPubKey.size() > MAX_OUTPUT_SCRIPT_SIZE) { + return state.Invalid(TxValidationResult::TX_PREMATURE_SPEND, "bad-txns-vout-script-toolarge"); + } + } + } + CAmount nValueIn = 0; for (unsigned int i = 0; i < tx.vin.size(); ++i) { const COutPoint &prevout = tx.vin[i].prevout; diff --git a/src/consensus/tx_verify.h b/src/consensus/tx_verify.h index 5d8eea9d1777..65e705abd496 100644 --- a/src/consensus/tx_verify.h +++ b/src/consensus/tx_verify.h @@ -24,6 +24,7 @@ class CheckTxInputsRules { enum class Rule { None = 0, + OutputSizeLimit = 1 << 0, }; public: diff --git a/src/test/fuzz/coins_view.cpp b/src/test/fuzz/coins_view.cpp index b74a605f14ca..6cf72784204c 100644 --- a/src/test/fuzz/coins_view.cpp +++ b/src/test/fuzz/coins_view.cpp @@ -256,7 +256,7 @@ FUZZ_TARGET(coins_view, .init = initialize_coins_view) // It is not allowed to call CheckTxInputs if CheckTransaction failed return; } - if (Consensus::CheckTxInputs(transaction, state, coins_view_cache, fuzzed_data_provider.ConsumeIntegralInRange(0, std::numeric_limits::max()), tx_fee_out, CheckTxInputsRules::None)) { + if (Consensus::CheckTxInputs(transaction, state, coins_view_cache, fuzzed_data_provider.ConsumeIntegralInRange(0, std::numeric_limits::max()), tx_fee_out, CheckTxInputsRules::OutputSizeLimit)) { assert(MoneyRange(tx_fee_out)); } }, From 091c18c8c27661344d6b73b93e5e55bc84d3697e Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Tue, 7 Oct 2025 13:48:39 +0000 Subject: [PATCH 18/34] consensus: When CheckTxInputsRules::OutputSizeLimit is enforced (still never), limit non-OP_RETURN scripts to 34 bytes and OP_RETURN outputs to 83 bytes MAX_OUTPUT_DATA_SIZE constant definition moved here from its original commit (4a8d8d0490) due to commit reordering. Co-Authored-By: Dathon Ohm --- src/consensus/consensus.h | 3 ++- src/consensus/tx_verify.cpp | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/consensus/consensus.h b/src/consensus/consensus.h index 0bad6c0a4b1f..b02773f4900b 100644 --- a/src/consensus/consensus.h +++ b/src/consensus/consensus.h @@ -34,6 +34,7 @@ static constexpr unsigned int LOCKTIME_VERIFY_SEQUENCE = (1 << 0); */ static constexpr int64_t MAX_TIMEWARP = 600; -static constexpr unsigned int MAX_OUTPUT_SCRIPT_SIZE{83}; +static constexpr unsigned int MAX_OUTPUT_SCRIPT_SIZE{34}; +static constexpr unsigned int MAX_OUTPUT_DATA_SIZE{83}; #endif // BITCOIN_CONSENSUS_CONSENSUS_H diff --git a/src/consensus/tx_verify.cpp b/src/consensus/tx_verify.cpp index a2ebd99f7d78..91215f5a1dc8 100644 --- a/src/consensus/tx_verify.cpp +++ b/src/consensus/tx_verify.cpp @@ -172,7 +172,8 @@ bool Consensus::CheckTxInputs(const CTransaction& tx, TxValidationState& state, // NOTE: CheckTransaction is arguably the more logical place to do this, but it's context-independent, so this is probably the next best place for now if (rules.test(CheckTxInputsRules::OutputSizeLimit)) { for (const auto& txout : tx.vout) { - if (txout.scriptPubKey.size() > MAX_OUTPUT_SCRIPT_SIZE) { + if (txout.scriptPubKey.empty()) continue; + if (txout.scriptPubKey.size() > ((txout.scriptPubKey[0] == OP_RETURN) ? MAX_OUTPUT_DATA_SIZE : MAX_OUTPUT_SCRIPT_SIZE)) { return state.Invalid(TxValidationResult::TX_PREMATURE_SPEND, "bad-txns-vout-script-toolarge"); } } From d2200410b815fd776df7ea893005c9651870650f Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Sat, 4 Oct 2025 14:32:23 +0000 Subject: [PATCH 19/34] policy: limit datacarriersize config to MAX_OUTPUT_DATA_SIZE (=83 B) --- src/init.cpp | 3 ++- src/node/mempool_args.cpp | 4 ++++ test/functional/test_framework/test_node.py | 6 ++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/init.cpp b/src/init.cpp index 7b6b188c257d..585bb9220ce9 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -716,7 +716,8 @@ void SetupServerArgs(ArgsManager& argsman, bool can_listen_ipc) argsman.AddArg("-datacarriercost", strprintf("Treat extra data in transactions as at least N vbytes per actual byte (default: %s)", DEFAULT_WEIGHT_PER_DATA_BYTE / 4.0), ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY); argsman.AddArg("-datacarrierfullcount", strprintf("Apply datacarriersize limit to all known datacarrier methods (default: %u)", DEFAULT_DATACARRIER_FULLCOUNT), ArgsManager::ALLOW_ANY | (DEFAULT_DATACARRIER_FULLCOUNT ? uint32_t{ArgsManager::DEBUG_ONLY} : 0), OptionsCategory::NODE_RELAY); argsman.AddArg("-datacarriersize", - strprintf("Maximum size of data in data carrier transactions we relay and mine, in bytes (default: %u)", + strprintf("Maximum size of data in data carrier transactions we relay and mine, in bytes (maximum %s, default: %u)", + MAX_OUTPUT_DATA_SIZE, MAX_OP_RETURN_RELAY), ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY); argsman.AddArg("-maxscriptsize", strprintf("Maximum size of scripts (including the entire witness stack) we relay and mine, in bytes (default: %s)", DEFAULT_SCRIPT_SIZE_POLICY_LIMIT), ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY); diff --git a/src/node/mempool_args.cpp b/src/node/mempool_args.cpp index 1d82d8f4426f..f2f36931cf11 100644 --- a/src/node/mempool_args.cpp +++ b/src/node/mempool_args.cpp @@ -207,6 +207,10 @@ util::Result ApplyArgsManOptions(const ArgsManager& argsman, const CChainP if (argsman.GetBoolArg("-datacarrier", DEFAULT_ACCEPT_DATACARRIER)) { mempool_opts.max_datacarrier_bytes = argsman.GetIntArg("-datacarriersize", MAX_OP_RETURN_RELAY); + if (mempool_opts.max_datacarrier_bytes.value() > MAX_OUTPUT_DATA_SIZE) { + LogWarning("Limiting datacarriersize to %s", MAX_OUTPUT_DATA_SIZE); + mempool_opts.max_datacarrier_bytes = MAX_OUTPUT_DATA_SIZE; + } } else { mempool_opts.max_datacarrier_bytes = std::nullopt; } diff --git a/test/functional/test_framework/test_node.py b/test/functional/test_framework/test_node.py index 35f98efcdd32..c883acb3b951 100755 --- a/test/functional/test_framework/test_node.py +++ b/test/functional/test_framework/test_node.py @@ -28,6 +28,7 @@ serialization_fallback, ) from .descriptors import descsum_create +from .messages import MAX_OP_RETURN_RELAY from .messages import NODE_P2P_V2 from .p2p import P2P_SERVICES, P2P_SUBVERSION from .util import ( @@ -266,6 +267,11 @@ def start(self, extra_args=None, *, cwd=None, stdout=None, stderr=None, env=None if env is not None: subp_env.update(env) + for arg in extra_args: + if arg.startswith('-datacarriersize=') and int(arg[17:]) > MAX_OP_RETURN_RELAY: + extra_args = list(extra_args) + extra_args.append('-acceptnonstdtxn=1') + self.process = subprocess.Popen(self.args + extra_args, env=subp_env, stdout=stdout, stderr=stderr, cwd=cwd, **kwargs) self.running = True From 7860d8db2f386be914bc9e5ea9d41bd71794c20e Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Wed, 19 Nov 2025 15:01:24 -0600 Subject: [PATCH 20/34] chainparams: add DEPLOYMENT_REDUCED_DATA temporary BIP9 deployment --- src/consensus/params.h | 1 + src/deploymentinfo.cpp | 4 ++++ src/kernel/chainparams.cpp | 35 +++++++++++++++++++++++++++++++++++ src/rpc/blockchain.cpp | 1 + 4 files changed, 41 insertions(+) diff --git a/src/consensus/params.h b/src/consensus/params.h index 08f5e599ac0e..c2ac24bfb5e6 100644 --- a/src/consensus/params.h +++ b/src/consensus/params.h @@ -32,6 +32,7 @@ constexpr bool ValidDeployment(BuriedDeployment dep) { return dep <= DEPLOYMENT_ enum DeploymentPos : uint16_t { DEPLOYMENT_TESTDUMMY, DEPLOYMENT_TAPROOT, // Deployment of Schnorr/Taproot (BIPs 340-342) + DEPLOYMENT_REDUCED_DATA, // ReducedData Temporary Softfork (RDTS) // NOTE: Also add new deployments to VersionBitsDeploymentInfo in deploymentinfo.cpp MAX_VERSION_BITS_DEPLOYMENTS }; diff --git a/src/deploymentinfo.cpp b/src/deploymentinfo.cpp index 185a7dcb54ce..200f5fd26300 100644 --- a/src/deploymentinfo.cpp +++ b/src/deploymentinfo.cpp @@ -17,6 +17,10 @@ const struct VBDeploymentInfo VersionBitsDeploymentInfo[Consensus::MAX_VERSION_B /*.name =*/ "taproot", /*.gbt_force =*/ true, }, + { + /*.name =*/ "reduced_data", + /*.gbt_force =*/ true, + }, }; std::string DeploymentName(Consensus::BuriedDeployment dep) diff --git a/src/kernel/chainparams.cpp b/src/kernel/chainparams.cpp index 2daddfc5af99..ac01837397ae 100644 --- a/src/kernel/chainparams.cpp +++ b/src/kernel/chainparams.cpp @@ -117,6 +117,15 @@ class CMainParams : public CChainParams { consensus.vDeployments[Consensus::DEPLOYMENT_TAPROOT].nTimeout = 1628640000; // August 11th, 2021 consensus.vDeployments[Consensus::DEPLOYMENT_TAPROOT].min_activation_height = 709632; // Approximately November 12th, 2021 + // ReducedData Temporary Softfork (RDTS) + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].bit = 4; + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].nStartTime = 1764547200; // December 1st, 2025 + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].nTimeout = Consensus::BIP9Deployment::NO_TIMEOUT; + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].min_activation_height = 0; + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].max_activation_height = 965664; // ~September 1st, 2026 + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].active_duration = 52416; // ~1 year + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].threshold = 1109; // 55% of 2016 + consensus.nMinimumChainWork = uint256{"0000000000000000000000000000000000000000dee8e2a309ad8a9820433c68"}; consensus.defaultAssumeValid = uint256{"00000000000000000000611fd22f2df7c8fbd0688745c3a6c3bb5109cc2a12cb"}; // 912683 @@ -280,6 +289,14 @@ class CTestNetParams : public CChainParams { consensus.vDeployments[Consensus::DEPLOYMENT_TAPROOT].nTimeout = 1628640000; // August 11th, 2021 consensus.vDeployments[Consensus::DEPLOYMENT_TAPROOT].min_activation_height = 0; // No activation delay + // ReducedData Temporary Softfork (RDTS) + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].bit = 4; + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].nStartTime = 1764547200; // December 1st, 2025 + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].nTimeout = Consensus::BIP9Deployment::NO_TIMEOUT; + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].min_activation_height = 0; + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].active_duration = 52416; // ~1 year + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].threshold = 1109; // 55% of 2016 + consensus.nMinimumChainWork = uint256{"0000000000000000000000000000000000000000000015f5e0c9f13455b0eb17"}; consensus.defaultAssumeValid = uint256{"00000000000003fc7967410ba2d0a8a8d50daedc318d43e8baf1a9782c236a57"}; // 3974606 @@ -379,6 +396,14 @@ class CTestNet4Params : public CChainParams { consensus.vDeployments[Consensus::DEPLOYMENT_TAPROOT].nTimeout = Consensus::BIP9Deployment::NO_TIMEOUT; consensus.vDeployments[Consensus::DEPLOYMENT_TAPROOT].min_activation_height = 0; // No activation delay + // ReducedData Temporary Softfork (RDTS) + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].bit = 4; + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].nStartTime = 1764547200; // December 1st, 2025 + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].nTimeout = Consensus::BIP9Deployment::NO_TIMEOUT; + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].min_activation_height = 0; + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].active_duration = 52416; // ~1 year + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].threshold = 1109; // 55% of 2016 + consensus.nMinimumChainWork = uint256{"0000000000000000000000000000000000000000000001d6dce8651b6094e4c1"}; consensus.defaultAssumeValid = uint256{"0000000000003ed4f08dbdf6f7d6b271a6bcffce25675cb40aa9fa43179a89f3"}; // 72600 @@ -517,6 +542,11 @@ class SigNetParams : public CChainParams { consensus.vDeployments[Consensus::DEPLOYMENT_TAPROOT].nTimeout = Consensus::BIP9Deployment::NO_TIMEOUT; consensus.vDeployments[Consensus::DEPLOYMENT_TAPROOT].min_activation_height = 0; // No activation delay + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].bit = 4; + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].nStartTime = Consensus::BIP9Deployment::NEVER_ACTIVE; + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].nTimeout = Consensus::BIP9Deployment::NO_TIMEOUT; + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].min_activation_height = 0; + // message start is defined as the first 4 bytes of the sha256d of the block script HashWriter h{}; h << consensus.signet_challenge; @@ -592,6 +622,11 @@ class CRegTestParams : public CChainParams consensus.vDeployments[Consensus::DEPLOYMENT_TAPROOT].nTimeout = Consensus::BIP9Deployment::NO_TIMEOUT; consensus.vDeployments[Consensus::DEPLOYMENT_TAPROOT].min_activation_height = 0; // No activation delay + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].bit = 4; + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].nStartTime = Consensus::BIP9Deployment::NEVER_ACTIVE; + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].nTimeout = Consensus::BIP9Deployment::NO_TIMEOUT; + consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].min_activation_height = 0; + consensus.nMinimumChainWork = uint256{}; consensus.defaultAssumeValid = uint256{}; diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp index c36ec039d72e..05890057d126 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -1872,6 +1872,7 @@ UniValue DeploymentInfo(const CBlockIndex* blockindex, const ChainstateManager& SoftForkDescPushBack(blockindex, softforks, chainman, Consensus::DEPLOYMENT_SEGWIT); SoftForkDescPushBack(blockindex, softforks, chainman, Consensus::DEPLOYMENT_TESTDUMMY); SoftForkDescPushBack(blockindex, softforks, chainman, Consensus::DEPLOYMENT_TAPROOT); + SoftForkDescPushBack(blockindex, softforks, chainman, Consensus::DEPLOYMENT_REDUCED_DATA); return softforks; } } // anon namespace From 0c9c50e39041fb440c2c6a056ac7907ecd407772 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Sat, 4 Oct 2025 13:17:18 +0000 Subject: [PATCH 21/34] consensus: Enforce SCRIPT_VERIFY_REDUCED_DATA if DEPLOYMENT_REDUCED_DATA is active --- src/validation.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/validation.cpp b/src/validation.cpp index f7501c547552..18e78ce8c887 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -2681,6 +2681,10 @@ static unsigned int GetBlockScriptFlags(const CBlockIndex& block_index, const Ch flags |= SCRIPT_VERIFY_NULLDUMMY; } + if (DeploymentActiveAt(block_index, chainman, Consensus::DEPLOYMENT_REDUCED_DATA)) { + flags |= SCRIPT_VERIFY_REDUCED_DATA; + } + return flags; } From 45c004e30e3a50f314f1f5b2fc03ece353f57243 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Sat, 4 Oct 2025 14:34:08 +0000 Subject: [PATCH 22/34] consensus: Enforce CheckTxInputsRules::OutputSizeLimit when DEPLOYMENT_REDUCED_DATA is active; adapt tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire OutputSizeLimit to deployment with unconditional mempool enforcement. Adapt test framework and functional tests for 34-byte output script limit and 83-byte OP_RETURN limit. Test fixes from: 8257367348, c609a453a0, 9194f6f05f, b141420e59, e011d53b4d, fc62079ba1, a065a596b1, 4f371cfa2c, 5ee8102c82, ebe821e7c0 (partial) Co-Authored-By: Dathon Ohm Co-Authored-By: 3c853b6299 <3c853b6299@pm.me> Co-Authored-By: moneybadger1 Co-Authored-By: Léo Haf --- src/rpc/mempool.cpp | 3 + src/validation.cpp | 6 +- test/functional/data/invalid_txs.py | 6 +- test/functional/feature_cltv.py | 21 ++- test/functional/feature_dersig.py | 13 +- test/functional/feature_segwit.py | 16 +- test/functional/mempool_accept.py | 1 + test/functional/mempool_accept_wtxid.py | 21 ++- test/functional/mempool_dust.py | 82 +++++++- test/functional/mempool_limit.py | 56 ++++-- test/functional/mempool_sigoplimit.py | 176 +++++++++++++++--- test/functional/p2p_1p1c_network.py | 15 +- test/functional/p2p_segwit.py | 7 +- test/functional/rpc_getdescriptoractivity.py | 2 +- test/functional/rpc_packages.py | 21 ++- .../functional/test_framework/mempool_util.py | 9 + test/functional/test_framework/util.py | 26 ++- test/functional/test_framework/wallet.py | 38 ++-- test/functional/tool_utxo_to_sqlite.py | 50 +++-- 19 files changed, 441 insertions(+), 128 deletions(-) diff --git a/src/rpc/mempool.cpp b/src/rpc/mempool.cpp index e26de95b0863..43a0b01a0e99 100644 --- a/src/rpc/mempool.cpp +++ b/src/rpc/mempool.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -179,6 +180,7 @@ static RPCHelpMan testmempoolaccept() {RPCResult::Type::BOOL, "allowed", /*optional=*/true, "Whether this tx would be accepted to the mempool and pass client-specified maxfeerate. " "If not present, the tx was not fully validated due to a failure in another tx in the list."}, {RPCResult::Type::NUM, "vsize", /*optional=*/true, "Virtual transaction size as defined in BIP 141. This is different from actual serialized size for witness transactions as witness data is discounted (only present when 'allowed' is true)"}, + {RPCResult::Type::NUM, "usage", "Memory usage of transaction for this node"}, {RPCResult::Type::OBJ, "fees", /*optional=*/true, "Transaction fees (only present if 'allowed' is true)", { {RPCResult::Type::STR_AMOUNT, "base", "transaction fee in " + CURRENCY_UNIT}, @@ -254,6 +256,7 @@ static RPCHelpMan testmempoolaccept() UniValue result_inner(UniValue::VOBJ); result_inner.pushKV("txid", tx->GetHash().GetHex()); result_inner.pushKV("wtxid", tx->GetWitnessHash().GetHex()); + result_inner.pushKV("usage", RecursiveDynamicUsage(tx)); if (package_result.m_state.GetResult() == PackageValidationResult::PCKG_POLICY) { result_inner.pushKV("package-error", package_result.m_state.ToString()); } diff --git a/src/validation.cpp b/src/validation.cpp index 18e78ce8c887..747214c2f608 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -986,7 +986,7 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws) // The mempool holds txs for the next block, so pass height+1 to CheckTxInputs const auto block_height_current = m_active_chainstate.m_chain.Height(); const auto block_height_next = block_height_current + 1; - if (!Consensus::CheckTxInputs(tx, state, m_view, block_height_next, ws.m_base_fees, CheckTxInputsRules::None)) { + if (!Consensus::CheckTxInputs(tx, state, m_view, block_height_next, ws.m_base_fees, CheckTxInputsRules::OutputSizeLimit)) { return false; // state filled in by CheckTxInputs } @@ -2899,6 +2899,8 @@ bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state, CCheckQueueControl control(fScriptChecks && parallel_script_checks ? &m_chainman.GetCheckQueue() : nullptr); std::vector txsdata(block.vtx.size()); + const auto chk_input_rules{DeploymentActiveAt(*pindex, m_chainman, Consensus::DEPLOYMENT_REDUCED_DATA) ? CheckTxInputsRules::OutputSizeLimit : CheckTxInputsRules::None}; + std::vector prevheights; CAmount nFees = 0; int nInputs = 0; @@ -2915,7 +2917,7 @@ bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state, { CAmount txfee = 0; TxValidationState tx_state; - if (!Consensus::CheckTxInputs(tx, tx_state, view, pindex->nHeight, txfee, CheckTxInputsRules::None)) { + if (!Consensus::CheckTxInputs(tx, tx_state, view, pindex->nHeight, txfee, chk_input_rules)) { // Any transaction validation failure in ConnectBlock is a block consensus failure state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, tx_state.GetRejectReason(), diff --git a/test/functional/data/invalid_txs.py b/test/functional/data/invalid_txs.py index f96059d4ee80..51945fc875fa 100644 --- a/test/functional/data/invalid_txs.py +++ b/test/functional/data/invalid_txs.py @@ -223,10 +223,14 @@ class TooManySigops(BadTxTemplate): block_reject_reason = "bad-blk-sigops, out-of-bounds SigOpCount" def get_tx(self): + # Put OP_CHECKSIGs in scriptSig (input) instead of scriptPubKey (output) + # to avoid violating MAX_OUTPUT_SCRIPT_SIZE=34 consensus limit. + # Sigops are counted from both input and output scripts. lotsa_checksigs = CScript([OP_CHECKSIG] * (MAX_BLOCK_SIGOPS)) return create_tx_with_script( self.spend_tx, 0, - output_script=lotsa_checksigs, + script_sig=lotsa_checksigs, + output_script=basic_p2sh, # 23-byte P2SH, well under 34-byte limit amount=1) def getDisabledOpcodeTemplate(opcode): diff --git a/test/functional/feature_cltv.py b/test/functional/feature_cltv.py index 81cc10a5adfe..08108884e5c4 100755 --- a/test/functional/feature_cltv.py +++ b/test/functional/feature_cltv.py @@ -167,16 +167,17 @@ def run_test(self): # rejected from the mempool for exactly that reason. spendtx_txid = spendtx.hash spendtx_wtxid = spendtx.getwtxid() - assert_equal( - [{ - 'txid': spendtx_txid, - 'wtxid': spendtx_wtxid, - 'allowed': False, - 'reject-reason': tx_rej + expected_cltv_reject_reason, - 'reject-details': tx_rej + expected_cltv_reject_reason + f", input 0 of {spendtx_txid} (wtxid {spendtx_wtxid}), spending {coin_txid}:{coin_vout}" - }], - self.nodes[0].testmempoolaccept(rawtxs=[spendtx.serialize().hex()], maxfeerate=0), - ) + expected = { + 'txid': spendtx_txid, + 'wtxid': spendtx_wtxid, + 'allowed': False, + 'reject-reason': tx_rej + expected_cltv_reject_reason, + 'reject-details': tx_rej + expected_cltv_reject_reason + f", input 0 of {spendtx_txid} (wtxid {spendtx_wtxid}), spending {coin_txid}:{coin_vout}", + } + result = self.nodes[0].testmempoolaccept(rawtxs=[spendtx.serialize().hex()], maxfeerate=0)[0] + # skip for now + result.pop('usage') + assert_equal(result, expected) # Now we verify that a block with this transaction is also invalid. block.vtx[1] = spendtx diff --git a/test/functional/feature_dersig.py b/test/functional/feature_dersig.py index 2a7eb0d0f473..8b79a92df03f 100755 --- a/test/functional/feature_dersig.py +++ b/test/functional/feature_dersig.py @@ -118,17 +118,18 @@ def run_test(self): # rejected from the mempool for exactly that reason. spendtx_txid = spendtx.hash spendtx_wtxid = spendtx.getwtxid() - assert_equal( - [{ + expected = { 'txid': spendtx_txid, 'wtxid': spendtx_wtxid, 'allowed': False, 'reject-reason': 'mempool-script-verify-flag-failed (Non-canonical DER signature)', 'reject-details': 'mempool-script-verify-flag-failed (Non-canonical DER signature), ' + - f"input 0 of {spendtx_txid} (wtxid {spendtx_wtxid}), spending {coin_txid}:0" - }], - self.nodes[0].testmempoolaccept(rawtxs=[spendtx.serialize().hex()], maxfeerate=0), - ) + f"input 0 of {spendtx_txid} (wtxid {spendtx_wtxid}), spending {coin_txid}:0", + } + result = self.nodes[0].testmempoolaccept(rawtxs=[spendtx.serialize().hex()], maxfeerate=0)[0] + # skip for now + result.pop('usage') + assert_equal(result, expected) # Now we verify that a block with this transaction is also invalid. block.vtx.append(spendtx) diff --git a/test/functional/feature_segwit.py b/test/functional/feature_segwit.py index a2fab6714b6a..ef0c13552f5a 100755 --- a/test/functional/feature_segwit.py +++ b/test/functional/feature_segwit.py @@ -402,14 +402,12 @@ def run_test(self): [bare, p2sh, p2wsh, p2sh_p2wsh] = self.p2sh_address_to_script(v) # p2sh multisig with compressed keys should always be spendable spendable_anytime.extend([p2sh]) - # bare multisig can be watched and signed, but is not treated as ours - solvable_after_importaddress.extend([bare]) # P2WSH and P2SH(P2WSH) multisig with compressed keys are spendable after direct importaddress spendable_after_importaddress.extend([p2wsh, p2sh_p2wsh]) else: [p2wpkh, p2sh_p2wpkh, p2pk, p2pkh, p2sh_p2pk, p2sh_p2pkh, p2wsh_p2pk, p2wsh_p2pkh, p2sh_p2wsh_p2pk, p2sh_p2wsh_p2pkh] = self.p2pkh_address_to_script(v) # normal P2PKH and P2PK with compressed keys should always be spendable - spendable_anytime.extend([p2pkh, p2pk]) + spendable_anytime.extend([p2pkh]) # P2SH_P2PK, P2SH_P2PKH with compressed keys are spendable after direct importaddress spendable_after_importaddress.extend([p2sh_p2pk, p2sh_p2pkh, p2wsh_p2pk, p2wsh_p2pkh, p2sh_p2wsh_p2pk, p2sh_p2wsh_p2pkh]) # P2WPKH and P2SH_P2WPKH with compressed keys should always be spendable @@ -421,14 +419,12 @@ def run_test(self): [bare, p2sh, p2wsh, p2sh_p2wsh] = self.p2sh_address_to_script(v) # p2sh multisig with uncompressed keys should always be spendable spendable_anytime.extend([p2sh]) - # bare multisig can be watched and signed, but is not treated as ours - solvable_after_importaddress.extend([bare]) # P2WSH and P2SH(P2WSH) multisig with uncompressed keys are never seen unseen_anytime.extend([p2wsh, p2sh_p2wsh]) else: [p2wpkh, p2sh_p2wpkh, p2pk, p2pkh, p2sh_p2pk, p2sh_p2pkh, p2wsh_p2pk, p2wsh_p2pkh, p2sh_p2wsh_p2pk, p2sh_p2wsh_p2pkh] = self.p2pkh_address_to_script(v) # normal P2PKH and P2PK with uncompressed keys should always be spendable - spendable_anytime.extend([p2pkh, p2pk]) + spendable_anytime.extend([p2pkh]) # P2SH_P2PK and P2SH_P2PKH are spendable after direct importaddress spendable_after_importaddress.extend([p2sh_p2pk, p2sh_p2pkh]) # Witness output types with uncompressed keys are never seen @@ -439,11 +435,11 @@ def run_test(self): if v['isscript']: # Multisig without private is not seen after addmultisigaddress, but seen after importaddress [bare, p2sh, p2wsh, p2sh_p2wsh] = self.p2sh_address_to_script(v) - solvable_after_importaddress.extend([bare, p2sh, p2wsh, p2sh_p2wsh]) + solvable_after_importaddress.extend([p2sh, p2wsh, p2sh_p2wsh]) else: [p2wpkh, p2sh_p2wpkh, p2pk, p2pkh, p2sh_p2pk, p2sh_p2pkh, p2wsh_p2pk, p2wsh_p2pkh, p2sh_p2wsh_p2pk, p2sh_p2wsh_p2pkh] = self.p2pkh_address_to_script(v) # normal P2PKH, P2PK, P2WPKH and P2SH_P2WPKH with compressed keys should always be seen - solvable_anytime.extend([p2pkh, p2pk, p2wpkh, p2sh_p2wpkh]) + solvable_anytime.extend([p2pkh, p2wpkh, p2sh_p2wpkh]) # P2SH_P2PK, P2SH_P2PKH with compressed keys are seen after direct importaddress solvable_after_importaddress.extend([p2sh_p2pk, p2sh_p2pkh, p2wsh_p2pk, p2wsh_p2pkh, p2sh_p2wsh_p2pk, p2sh_p2wsh_p2pkh]) @@ -452,13 +448,13 @@ def run_test(self): if v['isscript']: [bare, p2sh, p2wsh, p2sh_p2wsh] = self.p2sh_address_to_script(v) # Base uncompressed multisig without private is not seen after addmultisigaddress, but seen after importaddress - solvable_after_importaddress.extend([bare, p2sh]) + solvable_after_importaddress.extend([p2sh]) # P2WSH and P2SH(P2WSH) multisig with uncompressed keys are never seen unseen_anytime.extend([p2wsh, p2sh_p2wsh]) else: [p2wpkh, p2sh_p2wpkh, p2pk, p2pkh, p2sh_p2pk, p2sh_p2pkh, p2wsh_p2pk, p2wsh_p2pkh, p2sh_p2wsh_p2pk, p2sh_p2wsh_p2pkh] = self.p2pkh_address_to_script(v) # normal P2PKH and P2PK with uncompressed keys should always be seen - solvable_anytime.extend([p2pkh, p2pk]) + solvable_anytime.extend([p2pkh]) # P2SH_P2PK, P2SH_P2PKH with uncompressed keys are seen after direct importaddress solvable_after_importaddress.extend([p2sh_p2pk, p2sh_p2pkh]) # Witness output types with uncompressed keys are never seen diff --git a/test/functional/mempool_accept.py b/test/functional/mempool_accept.py index 38dd5b5001bf..47a1ba21f161 100755 --- a/test/functional/mempool_accept.py +++ b/test/functional/mempool_accept.py @@ -69,6 +69,7 @@ def check_mempool_result(self, result_expected, *args, **kwargs): for r in result_test: # Skip these checks for now r.pop('wtxid') + r.pop('usage') if "fees" in r: r["fees"].pop("effective-feerate") r["fees"].pop("effective-includes") diff --git a/test/functional/mempool_accept_wtxid.py b/test/functional/mempool_accept_wtxid.py index f74d00e37ccc..610b4a096250 100755 --- a/test/functional/mempool_accept_wtxid.py +++ b/test/functional/mempool_accept_wtxid.py @@ -96,20 +96,29 @@ def run_test(self): assert_equal(node.getmempoolinfo()["unbroadcastcount"], 0) # testmempoolaccept reports the "already in mempool" error - assert_equal(node.testmempoolaccept([child_one.serialize().hex()]), [{ + expected = { "txid": child_one_txid, "wtxid": child_one_wtxid, "allowed": False, "reject-reason": "txn-already-in-mempool", - "reject-details": "txn-already-in-mempool" - }]) - assert_equal(node.testmempoolaccept([child_two.serialize().hex()])[0], { + "reject-details": "txn-already-in-mempool", + } + result = node.testmempoolaccept([child_one.serialize().hex()])[0] + # skip for now + result.pop('usage') + assert_equal(result, expected) + + expected = { "txid": child_two_txid, "wtxid": child_two_wtxid, "allowed": False, "reject-reason": "txn-same-nonwitness-data-in-mempool", - "reject-details": "txn-same-nonwitness-data-in-mempool" - }) + "reject-details": "txn-same-nonwitness-data-in-mempool", + } + result = node.testmempoolaccept([child_two.serialize().hex()])[0] + # skip for now + result.pop('usage') + assert_equal(result, expected) # sendrawtransaction will not throw but quits early when the exact same transaction is already in mempool node.sendrawtransaction(child_one.serialize().hex()) diff --git a/test/functional/mempool_dust.py b/test/functional/mempool_dust.py index 937e77fbd41b..1a25838c85af 100755 --- a/test/functional/mempool_dust.py +++ b/test/functional/mempool_dust.py @@ -99,18 +99,92 @@ def test_dustrelay(self): assert_equal(self.nodes[0].getrawmempool(), []) + def test_output_size_limit(self): + """Test that outputs exceeding MAX_OUTPUT_SCRIPT_SIZE (34 bytes) are rejected""" + self.log.info("Test MAX_OUTPUT_SCRIPT_SIZE limit (34 bytes)") + + node = self.nodes[0] + _, pubkey = generate_keypair(compressed=True) + + # Test Case 1: Scripts at or under 34 bytes should be accepted + self.log.info("-> Testing scripts at or under 34-byte limit (should pass)") + + passing_scripts = [ + (key_to_p2pkh_script(pubkey), "P2PKH", 25), + (key_to_p2wpkh_script(pubkey), "P2WPKH", 22), + (script_to_p2wsh_script(CScript([OP_TRUE])), "P2WSH", 34), + (script_to_p2sh_script(CScript([OP_TRUE])), "P2SH", 23), + (output_key_to_p2tr_script(pubkey[1:]), "P2TR", 34), + ] + + for script, name, expected_size in passing_scripts: + assert_equal(len(script), expected_size) + tx = self.wallet.create_self_transfer()["tx"] + tx.vout.append(CTxOut(nValue=1000, scriptPubKey=script)) + res = node.testmempoolaccept([tx.serialize().hex()])[0] + assert_equal(res['allowed'], True) + self.log.info(f" ✓ {name} ({expected_size} bytes) accepted") + + # Test Case 2: P2PK with compressed pubkey (35 bytes) should be rejected + self.log.info("-> Testing P2PK compressed (35 bytes) - should be rejected") + p2pk_script = key_to_p2pk_script(pubkey) + assert_equal(len(p2pk_script), 35) + + tx = self.wallet.create_self_transfer()["tx"] + tx.vout.append(CTxOut(nValue=1000, scriptPubKey=p2pk_script)) + res = node.testmempoolaccept([tx.serialize().hex()])[0] + assert_equal(res['allowed'], False) + assert 'output-script-size' in res['reject-reason'].lower() or \ + 'bad-txns' in res['reject-reason'].lower(), \ + f"Expected output-script-size error, got: {res['reject-reason']}" + self.log.info(f" ✓ P2PK compressed (35 bytes) correctly rejected: {res['reject-reason']}") + + # Test Case 3: 1-of-1 bare multisig (37 bytes) should be rejected + self.log.info("-> Testing 1-of-1 bare multisig (37 bytes) - should be rejected") + multisig_script = keys_to_multisig_script([pubkey], k=1) + assert_equal(len(multisig_script), 37) + + tx = self.wallet.create_self_transfer()["tx"] + tx.vout.append(CTxOut(nValue=1000, scriptPubKey=multisig_script)) + res = node.testmempoolaccept([tx.serialize().hex()])[0] + assert_equal(res['allowed'], False) + assert 'output-script-size' in res['reject-reason'].lower() or \ + 'bad-txns' in res['reject-reason'].lower(), \ + f"Expected output-script-size error, got: {res['reject-reason']}" + self.log.info(f" ✓ 1-of-1 bare multisig (37 bytes) correctly rejected: {res['reject-reason']}") + + # Test Case 4: Boundary testing (exactly 34 vs 35 bytes) + self.log.info("-> Testing boundary conditions") + + # Exactly 34 bytes should pass (create a witness program v0 with 32-byte data) + script_34 = CScript([0, bytes(32)]) # OP_0 + 32 bytes = 34 bytes + assert_equal(len(script_34), 34) + tx = self.wallet.create_self_transfer()["tx"] + tx.vout.append(CTxOut(nValue=1000, scriptPubKey=script_34)) + res = node.testmempoolaccept([tx.serialize().hex()])[0] + assert_equal(res['allowed'], True) + self.log.info(" ✓ Exactly 34 bytes accepted (boundary)") + + # 35 bytes should fail (create a witness program v0 with 33-byte data - invalid but tests size) + script_35 = CScript([0, bytes(33)]) # OP_0 + 33 bytes = 35 bytes + assert_equal(len(script_35), 35) + tx = self.wallet.create_self_transfer()["tx"] + tx.vout.append(CTxOut(nValue=1000, scriptPubKey=script_35)) + res = node.testmempoolaccept([tx.serialize().hex()])[0] + assert_equal(res['allowed'], False) + self.log.info(f" ✓ 35 bytes rejected (boundary): {res['reject-reason']}") + def run_test(self): self.wallet = MiniWallet(self.nodes[0]) self.test_dustrelay() + self.test_output_size_limit() # prepare output scripts of each standard type _, uncompressed_pubkey = generate_keypair(compressed=False) _, pubkey = generate_keypair(compressed=True) output_scripts = ( - (key_to_p2pk_script(uncompressed_pubkey), "P2PK (uncompressed)"), - (key_to_p2pk_script(pubkey), "P2PK (compressed)"), (key_to_p2pkh_script(pubkey), "P2PKH"), (script_to_p2sh_script(CScript([OP_TRUE])), "P2SH"), (key_to_p2wpkh_script(pubkey), "P2WPKH"), @@ -118,9 +192,7 @@ def run_test(self): (output_key_to_p2tr_script(pubkey[1:]), "P2TR"), # witness programs for segwitv2+ can be between 2 and 40 bytes (program_to_witness_script(2, b'\x66' * 2), "P2?? (future witness version 2)"), - (program_to_witness_script(16, b'\x77' * 40), "P2?? (future witness version 16)"), - # largest possible output script considered standard - (keys_to_multisig_script([uncompressed_pubkey]*3), "bare multisig (m-of-3)"), + (program_to_witness_script(16, b'\x77' * 32), "P2?? (future witness version 16)"), (CScript([OP_RETURN, b'superimportanthash']), "null data (OP_RETURN)"), ) diff --git a/test/functional/mempool_limit.py b/test/functional/mempool_limit.py index 5051f8a03016..41ebd72b4c64 100755 --- a/test/functional/mempool_limit.py +++ b/test/functional/mempool_limit.py @@ -5,7 +5,9 @@ """Test mempool limiting together/eviction with the wallet.""" from decimal import Decimal +import time +from test_framework.authproxy import JSONRPCException from test_framework.mempool_util import ( fill_mempool, ) @@ -121,7 +123,7 @@ def test_mid_package_eviction_success(self): num_big_parents = 3 # Need to be large enough to trigger eviction # (note that the mempool usage of a tx is about three times its vsize) - assert_greater_than(parent_vsize * num_big_parents * 3, current_info["maxmempool"] - current_info["bytes"]) + assert_greater_than(parent_vsize * num_big_parents * 3, current_info["maxmempool"] - current_info["usage"]) big_parent_txids = [] big_parent_wtxids = [] @@ -159,7 +161,7 @@ def test_mid_package_eviction_success(self): assert_equal(len(package_res["tx-results"][wtxid]["fees"]["effective-includes"]), 1) # Maximum size must never be exceeded. - assert_greater_than(node.getmempoolinfo()["maxmempool"], node.getmempoolinfo()["bytes"]) + assert_greater_than(node.getmempoolinfo()["maxmempool"], node.getmempoolinfo()["usage"]) # Package found in mempool still resulting_mempool_txids = node.getrawmempool() @@ -200,12 +202,42 @@ def test_mid_package_eviction(self): # Mempool transaction which is evicted due to being at the "bottom" of the mempool when the # mempool overflows and evicts by descendant score. It's important that the eviction doesn't # happen in the middle of package evaluation, as it can invalidate the coins cache. - mempool_evicted_tx = self.wallet.send_self_transfer( - from_node=node, - fee_rate=mempoolmin_feerate, - target_vsize=evicted_vsize, - confirmed_only=True - ) + # + # NOTE: On 32-bit systems (i686), there's a race condition where concurrent transaction additions + # can cause the mempool to repeatedly exceed the limit, causing immediate eviction of low-fee + # transactions. We retry with exponential backoff to handle this scenario. + mempool_evicted_tx = None + max_retries = 20 + for attempt in range(max_retries): + try: + # Brief backoff on retries to let concurrent operations settle + if attempt > 0: + backoff = min(0.05 * (2 ** (attempt - 1)), 2.0) # Exponential backoff, max 2 seconds + self.log.info(f"Retry attempt {attempt + 1}/{max_retries} after {backoff:.2f}s backoff...") + time.sleep(backoff) + # Rescan UTXOs to recover any that failed to be added + self.wallet.rescan_utxos() + # Update minimum feerate as it may have increased during retries + mempoolmin_feerate = node.getmempoolinfo()["mempoolminfee"] + + mempool_evicted_tx = self.wallet.send_self_transfer( + from_node=node, + fee_rate=mempoolmin_feerate, + target_vsize=evicted_vsize, + confirmed_only=True + ) + if attempt > 0: + self.log.info(f"Successfully added transaction on attempt {attempt + 1}") + break + except JSONRPCException as e: + if e.error['code'] == -26: # mempool full or min fee not met + if attempt < max_retries - 1: + continue + else: + self.log.error(f"Failed to add transaction after {max_retries} attempts due to race condition") + raise + + assert mempool_evicted_tx is not None, "Failed to add transaction after retries" # Already in mempool when package is submitted. assert mempool_evicted_tx["txid"] in node.getrawmempool() @@ -229,7 +261,7 @@ def test_mid_package_eviction(self): num_big_parents = 3 # Need to be large enough to trigger eviction # (note that the mempool usage of a tx is about three times its vsize) - assert_greater_than(parent_vsize * num_big_parents * 3, current_info["maxmempool"] - current_info["bytes"]) + assert_greater_than(parent_vsize * num_big_parents * 3, current_info["maxmempool"] - current_info["usage"]) parent_feerate = 10 * mempoolmin_feerate big_parent_txids = [] @@ -260,7 +292,7 @@ def test_mid_package_eviction(self): assert_equal(node.submitpackage(package_hex)["package_msg"], "transaction failed") # Maximum size must never be exceeded. - assert_greater_than(node.getmempoolinfo()["maxmempool"], node.getmempoolinfo()["bytes"]) + assert_greater_than(node.getmempoolinfo()["maxmempool"], node.getmempoolinfo()["usage"]) # Evicted transaction and its descendants must not be in mempool. resulting_mempool_txids = node.getrawmempool() @@ -329,7 +361,7 @@ def test_mid_package_replacement(self): assert len([tx_res for _, tx_res in res["tx-results"].items() if "error" in tx_res and tx_res["error"] == "bad-txns-inputs-missingorspent"]) # Maximum size must never be exceeded. - assert_greater_than(node.getmempoolinfo()["maxmempool"], node.getmempoolinfo()["bytes"]) + assert_greater_than(node.getmempoolinfo()["maxmempool"], node.getmempoolinfo()["usage"]) resulting_mempool_txids = node.getrawmempool() # The replacement should be successful. @@ -406,7 +438,7 @@ def run_test(self): # Needs to be large enough to trigger eviction # (note that the mempool usage of a tx is about three times its vsize) target_vsize_each = 50000 - assert_greater_than(target_vsize_each * 2 * 3, node.getmempoolinfo()["maxmempool"] - node.getmempoolinfo()["bytes"]) + assert_greater_than(target_vsize_each * 2 * 3, node.getmempoolinfo()["maxmempool"] - node.getmempoolinfo()["usage"]) # Should be a true CPFP: parent's feerate is just below mempool min feerate parent_feerate = mempoolmin_feerate - Decimal("0.0000001") # 0.01 sats/vbyte below min feerate # Parent + child is above mempool minimum feerate diff --git a/test/functional/mempool_sigoplimit.py b/test/functional/mempool_sigoplimit.py index 4696a846cf9e..16c2a4275001 100755 --- a/test/functional/mempool_sigoplimit.py +++ b/test/functional/mempool_sigoplimit.py @@ -13,11 +13,13 @@ CTxIn, CTxInWitness, CTxOut, + MAX_OP_RETURN_RELAY, WITNESS_SCALE_FACTOR, tx_from_hex, ) from test_framework.script import ( CScript, + OP_1, OP_2DUP, OP_CHECKMULTISIG, OP_CHECKSIG, @@ -87,16 +89,84 @@ def test_sigops_limit(self, bytes_per_sigop, num_sigops): [OP_CHECKSIG]*num_singlesigops + [OP_ENDIF, OP_TRUE] ) - # use a 256-byte data-push as lower bound in the output script, in order - # to avoid having to compensate for tx size changes caused by varying - # length serialization sizes (both for scriptPubKey and data-push lengths) - tx = self.create_p2wsh_spending_tx(witness_script, CScript([OP_RETURN, b'X'*256])) - # bump the tx to reach the sigop-limit equivalent size by padding the datacarrier output - assert_greater_than_or_equal(sigop_equivalent_vsize, tx.get_vsize()) - vsize_to_pad = sigop_equivalent_vsize - tx.get_vsize() - tx.vout[0].scriptPubKey = CScript([OP_RETURN, b'X'*(256+vsize_to_pad)]) - assert_equal(sigop_equivalent_vsize, tx.get_vsize()) + # Create transaction ONCE with a small output + # This creates ONE funding transaction in the mempool + tx = self.create_p2wsh_spending_tx(witness_script, CScript([OP_RETURN, b'test123'])) + + # Helper function to pad transaction to target vsize using multiple OP_RETURN outputs + def pad_tx_to_vsize(tx, target_vsize): + """Adjust transaction size by adding/removing multiple OP_RETURN outputs""" + # Keep only the first output, remove all padding outputs + while len(tx.vout) > 1: + tx.vout.pop() + + # MAX_OP_RETURN_RELAY = 83, so max script is: OP_RETURN + 82 bytes data + max_script_size = MAX_OP_RETURN_RELAY + + # Iteratively add outputs until we reach or slightly exceed the target + while True: + current_vsize = tx.get_vsize() + if current_vsize >= target_vsize: + break + + vsize_needed = target_vsize - current_vsize + + # CTxOut serialization: nValue (8) + compact_size(script_len) + script + # For script_len <= 252: compact_size = 1 byte + # So total = 8 + 1 + script_len = 9 + script_len + + # Maximum output: 8 + 1 + 83 = 92 vbytes + if vsize_needed >= 92: + # Add a max-size output + tx.vout.append(CTxOut(nValue=0, scriptPubKey=CScript([OP_RETURN] + [OP_1] * (max_script_size - 1)))) + elif vsize_needed >= 10: + # Need to add exactly vsize_needed bytes + # 8 + 1 + script_len = vsize_needed + # script_len = vsize_needed - 9 + script_len = vsize_needed - 9 + # Script is [OP_RETURN] + data, so len = 1 + data_len + # data_len = script_len - 1 + data_len = script_len - 1 + if data_len >= 0: + tx.vout.append(CTxOut(nValue=0, scriptPubKey=CScript([OP_RETURN] + [OP_1] * data_len))) + else: + # Just add the minimum and overshoot slightly + tx.vout.append(CTxOut(nValue=0, scriptPubKey=CScript([OP_RETURN]))) + break + else: + # vsize_needed < 10, can't add a new output + # Instead, adjust the first output's size by adding to its script + if vsize_needed > 0 and len(tx.vout[0].scriptPubKey) < max_script_size: + # Extend the first output's script + current_script = tx.vout[0].scriptPubKey + # Add vsize_needed more bytes to the script + new_script = bytes(current_script) + bytes([1] * vsize_needed) + # But cap at max_script_size + if len(new_script) <= max_script_size: + tx.vout[0].scriptPubKey = CScript(new_script) + break + + # If we overshot, try to trim the last output + if tx.get_vsize() > target_vsize and len(tx.vout) > 1: + tx.vout.pop() + # Try again with a smaller output + current_vsize = tx.get_vsize() + vsize_needed = target_vsize - current_vsize + if vsize_needed >= 10: + script_len = vsize_needed - 9 + data_len = script_len - 1 + if data_len >= 0: + tx.vout.append(CTxOut(nValue=0, scriptPubKey=CScript([OP_RETURN] + [OP_1] * data_len))) + + # Pad to reach sigop-limit equivalent size + pad_tx_to_vsize(tx, sigop_equivalent_vsize) + if tx.get_vsize() != sigop_equivalent_vsize: + self.log.error(f"Padding failed: got {tx.get_vsize()}, expected {sigop_equivalent_vsize}") + self.log.error(f"Number of outputs: {len(tx.vout)}") + for i, out in enumerate(tx.vout): + self.log.error(f"Output {i}: scriptPubKey len={len(out.scriptPubKey)}, vout entry size={8 + 1 + len(out.scriptPubKey)}") + assert_equal(tx.get_vsize(), sigop_equivalent_vsize) res = self.nodes[0].testmempoolaccept([tx.serialize().hex()])[0] assert_equal(res['allowed'], True) @@ -104,7 +174,7 @@ def test_sigops_limit(self, bytes_per_sigop, num_sigops): # increase the tx's vsize to be right above the sigop-limit equivalent size # => tx's vsize in mempool should also grow accordingly - tx.vout[0].scriptPubKey = CScript([OP_RETURN, b'X'*(256+vsize_to_pad+1)]) + pad_tx_to_vsize(tx, sigop_equivalent_vsize + 1) res = self.nodes[0].testmempoolaccept([tx.serialize().hex()])[0] assert_equal(res['allowed'], True) assert_equal(res['vsize'], sigop_equivalent_vsize+1) @@ -113,7 +183,7 @@ def test_sigops_limit(self, bytes_per_sigop, num_sigops): # => tx's vsize in mempool should stick at the sigop-limit equivalent # bytes level, as it is higher than the tx's serialized vsize # (the maximum of both is taken) - tx.vout[0].scriptPubKey = CScript([OP_RETURN, b'X'*(256+vsize_to_pad-1)]) + pad_tx_to_vsize(tx, sigop_equivalent_vsize - 1) res = self.nodes[0].testmempoolaccept([tx.serialize().hex()])[0] assert_equal(res['allowed'], True) assert_equal(res['vsize'], sigop_equivalent_vsize) @@ -123,6 +193,8 @@ def test_sigops_limit(self, bytes_per_sigop, num_sigops): # (to keep it simple, we only test the case here where the sigop vsize # is much larger than the serialized vsize, i.e. we create a small child # tx by getting rid of the large padding output) + while len(tx.vout) > 1: + tx.vout.pop() tx.vout[0].scriptPubKey = CScript([OP_RETURN, b'test123']) assert_greater_than(sigop_equivalent_vsize, tx.get_vsize()) self.nodes[0].sendrawtransaction(hexstring=tx.serialize().hex(), maxburnamount='1.0') @@ -147,33 +219,77 @@ def test_sigops_package(self): self.log.info("Test a overly-large sigops-vbyte hits package limits") # Make a 2-transaction package which fails vbyte checks even though # separately they would work. - self.restart_node(0, extra_args=["-bytespersigop=5000","-permitbaremultisig=1"] + self.extra_args[0]) - - def create_bare_multisig_tx(utxo_to_spend=None): - _, pubkey = generate_keypair() - amount_for_bare = 50000 - tx_dict = self.wallet.create_self_transfer(fee=Decimal("3"), utxo_to_spend=utxo_to_spend) - tx_utxo = tx_dict["new_utxo"] - tx = tx_dict["tx"] - tx.vout.append(CTxOut(amount_for_bare, keys_to_multisig_script([pubkey], k=1))) - tx.vout[0].nValue -= amount_for_bare - tx_utxo["txid"] = tx.rehash() - tx_utxo["value"] -= Decimal("0.00005000") - return (tx_utxo, tx) - - tx_parent_utxo, tx_parent = create_bare_multisig_tx() - _tx_child_utxo, tx_child = create_bare_multisig_tx(tx_parent_utxo) + # + # Using P2WSH multisig instead of bare multisig to comply with REDUCED_DATA + # output size limits (34 bytes max). Witness sigops are discounted by 4x, + # so we use multiple CHECKMULTISIG ops to achieve sufficient sigop-adjusted vsize. + self.restart_node(0, extra_args=["-bytespersigop=5000"] + self.extra_args[0]) + + # With -bytespersigop=5000 and witness discount of 4: + # - Each CHECKMULTISIG = 20 sigops + # - Adjusted vsize per CHECKMULTISIG = 20 * 5000 / 4 = 25,000 + # - Need > 101,000 / 2 = 50,500 per tx to exceed limit as package + # - Use 3 CHECKMULTISIG ops = 60 sigops = 75,000 adjusted vsize per tx + # - Two txs together = 150,000 > 101,000 (fails package limit) + # - Each tx alone = 75,000 < 101,000 (passes individually) + NUM_CHECKMULTISIG_OPS = 3 + expected_sigops_per_tx = NUM_CHECKMULTISIG_OPS * MAX_PUBKEYS_PER_MULTISIG # 60 + expected_vsize_per_tx = expected_sigops_per_tx * 5000 // WITNESS_SCALE_FACTOR # 75,000 + + # Create witness script with multiple CHECKMULTISIG ops (sigops counted even in unexecuted branches) + witness_script = CScript( + [OP_FALSE, OP_IF] + + [OP_CHECKMULTISIG] * NUM_CHECKMULTISIG_OPS + + [OP_ENDIF, OP_TRUE] + ) + p2wsh_script = script_to_p2wsh_script(witness_script) + + # Pre-fund two P2WSH outputs that we'll spend as parent and child + funding_amount = 1000000 + fund_parent = self.wallet.send_to( + from_node=self.nodes[0], + scriptPubKey=p2wsh_script, + amount=funding_amount, + ) + fund_child = self.wallet.send_to( + from_node=self.nodes[0], + scriptPubKey=p2wsh_script, + amount=funding_amount, + ) + self.generate(self.nodes[0], 1) + + # Parent tx: spends first P2WSH (high sigops), outputs to wallet + tx_parent = CTransaction() + tx_parent.vin = [CTxIn(COutPoint(int(fund_parent["txid"], 16), fund_parent["sent_vout"]))] + tx_parent.wit.vtxinwit = [CTxInWitness()] + tx_parent.wit.vtxinwit[0].scriptWitness.stack = [bytes(witness_script)] + # Output back to a standard address (MiniWallet's default) + tx_parent.vout = [CTxOut(funding_amount - 10000, self.wallet.get_output_script())] + tx_parent.rehash() + + # Child tx: spends second P2WSH (high sigops) AND spends parent's output (to form package) + tx_child = CTransaction() + tx_child.vin = [ + CTxIn(COutPoint(int(fund_child["txid"], 16), fund_child["sent_vout"])), # P2WSH input (sigops) + CTxIn(COutPoint(tx_parent.sha256, 0)), # Parent's output (links as child) + ] + tx_child.wit.vtxinwit = [CTxInWitness(), CTxInWitness()] + tx_child.wit.vtxinwit[0].scriptWitness.stack = [bytes(witness_script)] # For P2WSH input + tx_child.wit.vtxinwit[1].scriptWitness.stack = [b''] # Placeholder for wallet input + tx_child.vout = [CTxOut(2 * funding_amount - 30000, self.wallet.get_output_script())] + tx_child.rehash() # Separately, the parent tx is ok parent_individual_testres = self.nodes[0].testmempoolaccept([tx_parent.serialize().hex()])[0] + if not parent_individual_testres["allowed"]: + self.log.error(f"Parent tx rejected: {parent_individual_testres}") assert parent_individual_testres["allowed"] - max_multisig_vsize = MAX_PUBKEYS_PER_MULTISIG * 5000 - assert_equal(parent_individual_testres["vsize"], max_multisig_vsize) + assert_equal(parent_individual_testres["vsize"], expected_vsize_per_tx) # But together, it's exceeding limits in the *package* context. If sigops adjusted vsize wasn't being checked # here, it would get further in validation and give too-long-mempool-chain error instead. packet_test = self.nodes[0].testmempoolaccept([tx_parent.serialize().hex(), tx_child.serialize().hex()]) - expected_package_error = f"package-mempool-limits, package size {2*max_multisig_vsize} exceeds ancestor size limit [limit: 101000]" + expected_package_error = f"package-mempool-limits, package size {2*expected_vsize_per_tx} exceeds ancestor size limit [limit: 101000]" assert_equal([x["package-error"] for x in packet_test], [expected_package_error] * 2) # When we actually try to submit, the parent makes it into the mempool, but the child would exceed ancestor vsize limits diff --git a/test/functional/p2p_1p1c_network.py b/test/functional/p2p_1p1c_network.py index 4f03542168d8..ab549393fa50 100755 --- a/test/functional/p2p_1p1c_network.py +++ b/test/functional/p2p_1p1c_network.py @@ -53,6 +53,10 @@ def raise_network_minfee(self): assert_equal(node.getmempoolinfo()['minrelaytxfee'], Decimal(DEFAULT_MIN_RELAY_TX_FEE) / COIN) assert_greater_than(node.getmempoolinfo()['mempoolminfee'], Decimal(DEFAULT_MIN_RELAY_TX_FEE) / COIN) + # Store mempoolminfee for dynamic feerate calculation + self.mempoolminfee = self.nodes[0].getmempoolinfo()['mempoolminfee'] + self.log.info(f"mempoolminfee after fill_mempool: {self.mempoolminfee} BTC/kvB ({self.mempoolminfee * 100000:.4f} sat/vB)") + def create_basic_1p1c(self, wallet): low_fee_parent = wallet.create_self_transfer(fee_rate=Decimal(DEFAULT_MIN_RELAY_TX_FEE) / COIN, confirmed_only=True) high_fee_child = wallet.create_self_transfer(utxo_to_spend=low_fee_parent["new_utxo"], fee_rate=999*Decimal(DEFAULT_MIN_RELAY_TX_FEE)/ COIN) @@ -86,8 +90,15 @@ def create_package_2outs(self, wallet): return [low_fee_parent_2outs["hex"], high_fee_child_2outs["hex"]], low_fee_parent_2outs["tx"], high_fee_child_2outs["tx"] def create_package_2p1c(self, wallet): - parent1 = wallet.create_self_transfer(fee_rate=Decimal(DEFAULT_MIN_RELAY_TX_FEE) / COIN * 10, confirmed_only=True) - parent2 = wallet.create_self_transfer(fee_rate=Decimal(DEFAULT_MIN_RELAY_TX_FEE) / COIN * 20, confirmed_only=True) + # Use dynamic feerates based on actual mempoolminfee to ensure parents are above eviction threshold + # Set parent1 at 2x threshold, parent2 at 4x threshold (same relative ratio as before) + parent1_feerate = self.mempoolminfee * 2 + parent2_feerate = self.mempoolminfee * 4 + + self.log.info(f"Creating 2p1c package with parent1={parent1_feerate} BTC/kvB, parent2={parent2_feerate} BTC/kvB") + + parent1 = wallet.create_self_transfer(fee_rate=parent1_feerate, confirmed_only=True) + parent2 = wallet.create_self_transfer(fee_rate=parent2_feerate, confirmed_only=True) child = wallet.create_self_transfer_multi( utxos_to_spend=[parent1["new_utxo"], parent2["new_utxo"]], fee_per_output=999*parent1["tx"].get_vsize(), diff --git a/test/functional/p2p_segwit.py b/test/functional/p2p_segwit.py index 694d35550600..3c4ed7b521c9 100755 --- a/test/functional/p2p_segwit.py +++ b/test/functional/p2p_segwit.py @@ -82,6 +82,7 @@ from test_framework.test_framework import BitcoinTestFramework from test_framework.util import ( assert_equal, + assert_equal_without_usage, assert_raises_rpc_error, ensure_for, softfork_active, @@ -621,7 +622,7 @@ def test_standardness_v0(self): testres3 = self.nodes[0].testmempoolaccept([tx3.serialize_with_witness().hex()]) testres3[0]["fees"].pop("effective-feerate") testres3[0]["fees"].pop("effective-includes") - assert_equal(testres3, + assert_equal_without_usage(testres3, [{ 'txid': tx3.hash, 'wtxid': tx3.getwtxid(), @@ -640,7 +641,7 @@ def test_standardness_v0(self): testres3_replaced = self.nodes[0].testmempoolaccept([tx3.serialize_with_witness().hex()]) testres3_replaced[0]["fees"].pop("effective-feerate") testres3_replaced[0]["fees"].pop("effective-includes") - assert_equal(testres3_replaced, + assert_equal_without_usage(testres3_replaced, [{ 'txid': tx3.hash, 'wtxid': tx3.getwtxid(), @@ -1355,7 +1356,7 @@ def test_segwit_versions(self): # First try to spend to a future version segwit script_pubkey. if version == OP_1: # Don't use 32-byte v1 witness (used by Taproot; see BIP 341) - script_pubkey = CScript([CScriptOp(version), witness_hash + b'\x00']) + script_pubkey = CScript([CScriptOp(version), witness_hash[:31]]) else: script_pubkey = CScript([CScriptOp(version), witness_hash]) tx.vin = [CTxIn(COutPoint(self.utxo[0].sha256, self.utxo[0].n), b"")] diff --git a/test/functional/rpc_getdescriptoractivity.py b/test/functional/rpc_getdescriptoractivity.py index a1d5add13889..c756aed7e766 100755 --- a/test/functional/rpc_getdescriptoractivity.py +++ b/test/functional/rpc_getdescriptoractivity.py @@ -206,7 +206,7 @@ def test_receive_then_spend(self, node, wallet): def test_no_address(self, node, wallet): self.log.info("Test that activity is still reported for scripts without an associated address") - raw_wallet = MiniWallet(self.nodes[0], mode=MiniWalletMode.RAW_P2PK) + raw_wallet = MiniWallet(self.nodes[0], mode=MiniWalletMode.RAW_OP_TRUE) self.generate(raw_wallet, 100) no_addr_tx = raw_wallet.send_self_transfer(from_node=node) diff --git a/test/functional/rpc_packages.py b/test/functional/rpc_packages.py index 539e9d09add6..dc7b2f09fc3e 100755 --- a/test/functional/rpc_packages.py +++ b/test/functional/rpc_packages.py @@ -19,6 +19,7 @@ from test_framework.test_framework import BitcoinTestFramework from test_framework.util import ( assert_equal, + assert_equal_without_usage, assert_fee_amount, assert_raises_rpc_error, ) @@ -48,7 +49,7 @@ def assert_testres_equal(self, package_hex, testres_expected): random.shuffle(shuffled_indeces) shuffled_package = [package_hex[i] for i in shuffled_indeces] shuffled_testres = [testres_expected[i] for i in shuffled_indeces] - assert_equal(shuffled_testres, self.nodes[0].testmempoolaccept(shuffled_package)) + assert_equal_without_usage(self.nodes[0].testmempoolaccept(shuffled_package), shuffled_testres) def run_test(self): node = self.nodes[0] @@ -119,7 +120,7 @@ def test_independent(self, coin): # transactions here but empty results in other cases. tx_bad_sig_txid = tx_bad_sig.rehash() tx_bad_sig_wtxid = tx_bad_sig.getwtxid() - assert_equal(testres_bad_sig, self.independent_txns_testres + [{ + assert_equal_without_usage(testres_bad_sig, self.independent_txns_testres + [{ "txid": tx_bad_sig_txid, "wtxid": tx_bad_sig_wtxid, "allowed": False, "reject-reason": "mempool-script-verify-flag-failed (Operation not valid with the current stack size)", @@ -130,12 +131,12 @@ def test_independent(self, coin): self.log.info("Check testmempoolaccept reports txns in packages that exceed max feerate") tx_high_fee = self.wallet.create_self_transfer(fee=Decimal("0.999")) testres_high_fee = node.testmempoolaccept([tx_high_fee["hex"]]) - assert_equal(testres_high_fee, [ + assert_equal_without_usage(testres_high_fee, [ {"txid": tx_high_fee["txid"], "wtxid": tx_high_fee["wtxid"], "allowed": False, "reject-reason": "max-fee-exceeded"} ]) package_high_fee = [tx_high_fee["hex"]] + self.independent_txns_hex testres_package_high_fee = node.testmempoolaccept(package_high_fee) - assert_equal(testres_package_high_fee, testres_high_fee + self.independent_txns_testres_blank) + assert_equal_without_usage(testres_package_high_fee, testres_high_fee + self.independent_txns_testres_blank) def test_chain(self): node = self.nodes[0] @@ -145,7 +146,7 @@ def test_chain(self): chain_txns = [t["tx"] for t in chain] self.log.info("Check that testmempoolaccept requires packages to be sorted by dependency") - assert_equal(node.testmempoolaccept(rawtxs=chain_hex[::-1]), + assert_equal_without_usage(node.testmempoolaccept(rawtxs=chain_hex[::-1]), [{"txid": tx.rehash(), "wtxid": tx.getwtxid(), "package-error": "package-not-sorted"} for tx in chain_txns[::-1]]) self.log.info("Testmempoolaccept a chain of 25 transactions") @@ -158,7 +159,7 @@ def test_chain(self): testres_single.append(testres[0]) # Submit the transaction now so its child should have no problem validating node.sendrawtransaction(rawtx) - assert_equal(testres_single, testres_multiple) + assert_equal_without_usage(testres_single, testres_multiple) # Clean up by clearing the mempool self.generate(node, 1) @@ -235,14 +236,14 @@ def test_conflicting(self): self.log.info("Test duplicate transactions in the same package") testres = node.testmempoolaccept([tx1["hex"], tx1["hex"]]) - assert_equal(testres, [ + assert_equal_without_usage(testres, [ {"txid": tx1["txid"], "wtxid": tx1["wtxid"], "package-error": "package-contains-duplicates"}, {"txid": tx1["txid"], "wtxid": tx1["wtxid"], "package-error": "package-contains-duplicates"} ]) self.log.info("Test conflicting transactions in the same package") testres = node.testmempoolaccept([tx1["hex"], tx2["hex"]]) - assert_equal(testres, [ + assert_equal_without_usage(testres, [ {"txid": tx1["txid"], "wtxid": tx1["wtxid"], "package-error": "conflict-in-package"}, {"txid": tx2["txid"], "wtxid": tx2["wtxid"], "package-error": "conflict-in-package"} ]) @@ -255,7 +256,7 @@ def test_conflicting(self): testres = node.testmempoolaccept([tx1["hex"], tx2["hex"], tx_child["hex"]]) - assert_equal(testres, [ + assert_equal_without_usage(testres, [ {"txid": tx1["txid"], "wtxid": tx1["wtxid"], "package-error": "conflict-in-package"}, {"txid": tx2["txid"], "wtxid": tx2["wtxid"], "package-error": "conflict-in-package"}, {"txid": tx_child["txid"], "wtxid": tx_child["wtxid"], "package-error": "conflict-in-package"} @@ -296,7 +297,7 @@ def test_rbf(self): # Replacement transaction is identical except has double the fee replacement_tx = self.wallet.create_self_transfer(utxo_to_spend=coin, sequence=MAX_BIP125_RBF_SEQUENCE, fee = 2 * fee) testres_rbf_conflicting = node.testmempoolaccept([replaceable_tx["hex"], replacement_tx["hex"]]) - assert_equal(testres_rbf_conflicting, [ + assert_equal_without_usage(testres_rbf_conflicting, [ {"txid": replaceable_tx["txid"], "wtxid": replaceable_tx["wtxid"], "package-error": "conflict-in-package"}, {"txid": replacement_tx["txid"], "wtxid": replacement_tx["wtxid"], "package-error": "conflict-in-package"} ]) diff --git a/test/functional/test_framework/mempool_util.py b/test/functional/test_framework/mempool_util.py index 56a9b4d262e7..869988e24c0d 100644 --- a/test/functional/test_framework/mempool_util.py +++ b/test/functional/test_framework/mempool_util.py @@ -69,6 +69,15 @@ def fill_mempool(test_framework, node, *, tx_sync_fun=None): confirmed_utxos = [ephemeral_miniwallet.get_utxo(confirmed_only=True) for _ in range(num_of_batches * tx_batch_size + 1)] assert_equal(len(confirmed_utxos), num_of_batches * tx_batch_size + 1) + # Calibrate dummy tx memory usage, since we rely on filling maxmempool + target_tx_usage = 68064 + tx = ephemeral_miniwallet.create_self_transfer(utxo_to_spend=confirmed_utxos[0])["tx"] + tx.vout.extend(txouts) + res = node.testmempoolaccept([tx.serialize().hex()])[0] + if res['usage'] > target_tx_usage: + excess_outputs = len(txouts) - (target_tx_usage * len(txouts) // res['usage']) + txouts = txouts[excess_outputs:] + test_framework.log.debug("Create a mempool tx that will be evicted") tx_to_be_evicted_id = ephemeral_miniwallet.send_self_transfer( from_node=node, utxo_to_spend=confirmed_utxos.pop(0), fee_rate=minrelayfee)["txid"] diff --git a/test/functional/test_framework/util.py b/test/functional/test_framework/util.py index 4a35d9b86939..32143b297cbc 100644 --- a/test/functional/test_framework/util.py +++ b/test/functional/test_framework/util.py @@ -77,6 +77,29 @@ def assert_equal(thing1, thing2, *args): raise AssertionError("not(%s)" % " == ".join(str(arg) for arg in (thing1, thing2) + args)) +def assert_equal_without_usage(actual, expected): + """ + Assert that testmempoolaccept results match expected values, ignoring the 'usage' field. + This helper is for tests that were written before the 'usage' field was added. + """ + if isinstance(actual, list) and isinstance(expected, list): + assert_equal(len(actual), len(expected)) + for act, exp in zip(actual, expected): + assert_equal_without_usage(act, exp) + elif isinstance(actual, dict) and isinstance(expected, dict): + # Check that all expected keys match + for key in expected: + assert key in actual, f"Expected key '{key}' not in actual result" + if key != 'usage': # Skip usage comparison + assert_equal(actual[key], expected[key]) + # Verify usage exists and is positive if transaction was validated + if 'usage' in actual: + assert isinstance(actual['usage'], int), "usage should be an integer" + assert actual['usage'] > 0, "usage should be positive" + else: + assert_equal(actual, expected) + + def assert_greater_than(thing1, thing2): if thing1 <= thing2: raise AssertionError("%s <= %s" % (str(thing1), str(thing2))) @@ -571,7 +594,8 @@ def check_node_connections(*, node, num_in, num_out): def gen_return_txouts(): from .messages import CTxOut from .script import CScript, OP_RETURN - txouts = [CTxOut(nValue=0, scriptPubKey=CScript([OP_RETURN, b'\x01'*67437]))] + txouts = [CTxOut(nValue=0, scriptPubKey=CScript([OP_RETURN, b'\x01'*80]))] * 733 + txouts.append(CTxOut(nValue=0, scriptPubKey=CScript([OP_RETURN, b'\x01'*9]))) assert_equal(sum([len(txout.serialize()) for txout in txouts]), 67456) return txouts diff --git a/test/functional/test_framework/wallet.py b/test/functional/test_framework/wallet.py index dee90f9fd6cf..137622664832 100644 --- a/test/functional/test_framework/wallet.py +++ b/test/functional/test_framework/wallet.py @@ -33,6 +33,7 @@ CTxInWitness, CTxOut, hash256, + MAX_OP_RETURN_RELAY, ser_compact_size, ) from test_framework.script import ( @@ -78,7 +79,7 @@ class MiniWalletMode(Enum): ----------------+-------------------+-----------+----------+------------+---------- ADDRESS_OP_TRUE | anyone-can-spend | bech32m | yes | no | no RAW_OP_TRUE | anyone-can-spend | - (raw) | no | yes | no - RAW_P2PK | pay-to-public-key | - (raw) | yes | yes | yes + RAW_P2PK | p2pkh | base58 | yes | yes | yes """ ADDRESS_OP_TRUE = 1 RAW_OP_TRUE = 2 @@ -101,7 +102,7 @@ def __init__(self, test_node, *, mode=MiniWalletMode.ADDRESS_OP_TRUE, tag_name=N self._priv_key = ECKey() self._priv_key.set((1).to_bytes(32, 'big'), True) pub_key = self._priv_key.get_pubkey() - self._scriptPubKey = key_to_p2pk_script(pub_key.get_bytes()) + self._scriptPubKey = key_to_p2pkh_script(pub_key.get_bytes()) elif mode == MiniWalletMode.ADDRESS_OP_TRUE: internal_key = None if tag_name is None else compute_xonly_pubkey(hash256(tag_name.encode()))[0] self._address, self._taproot_info = create_deterministic_address_bcrt1_p2tr_op_true(internal_key) @@ -124,13 +125,25 @@ def _bulk_tx(self, tx, target_vsize): if target_vsize < tx.get_vsize(): raise RuntimeError(f"target_vsize {target_vsize} is less than transaction virtual size {tx.get_vsize()}") - tx.vout.append(CTxOut(nValue=0, scriptPubKey=CScript([OP_RETURN]))) - # determine number of needed padding bytes dummy_vbytes = target_vsize - tx.get_vsize() - # compensate for the increase of the compact-size encoded script length - # (note that the length encoding of the unpadded output script needs one byte) - dummy_vbytes -= len(ser_compact_size(dummy_vbytes)) - 1 - tx.vout[-1].scriptPubKey = CScript([OP_RETURN] + [OP_1] * dummy_vbytes) + if dummy_vbytes > 0: + # determine number of needed padding bytes + min_output_size = 8 + 1 + 1 + max_output_size = 8 + 1 + MAX_OP_RETURN_RELAY + n_max_outputs = (dummy_vbytes - min_output_size) // max_output_size + last_output_size = dummy_vbytes - (n_max_outputs * max_output_size) + n_outputs_before = len(tx.vout) + + tx.vout.extend([CTxOut(nValue=0, scriptPubKey=CScript([OP_RETURN] + [OP_1] * (MAX_OP_RETURN_RELAY - 1)))] * n_max_outputs) + tx.vout.append(CTxOut(nValue=0, scriptPubKey=CScript([OP_RETURN] + [OP_1] * (last_output_size - 8 - 1 - 1)))) + + # compensate for the increase of the compact-size encoded script length + # (note that the length encoding of the unpadded output script needs one byte) + extra_len_size = len(ser_compact_size(len(tx.vout))) - 1 + if extra_len_size: + assert tx.vout[n_outputs_before].scriptPubKey[-extra_len_size:] == bytes([OP_1] * extra_len_size) + tx.vout[n_outputs_before] = CTxOut(nValue=0, scriptPubKey = CScript(tx.vout[n_outputs_before].scriptPubKey[:-extra_len_size])) + assert_equal(tx.get_vsize(), target_vsize) def get_balance(self): @@ -182,8 +195,9 @@ def sign_tx(self, tx, fixed_length=True): # with the DER header/skeleton data of 6 bytes added, plus 2 bytes scriptSig overhead # (OP_PUSHn and SIGHASH_ALL), this leads to a scriptSig target size of 73 bytes tx.vin[0].scriptSig = b'' - while not len(tx.vin[0].scriptSig) == 73: - tx.vin[0].scriptSig = b'' + while not len(tx.vin[0].scriptSig) == 107: + pub_key = self._priv_key.get_pubkey() + tx.vin[0].scriptSig = CScript([pub_key.get_bytes()]) sign_input_legacy(tx, 0, self._scriptPubKey, self._priv_key) if not fixed_length: break @@ -375,7 +389,7 @@ def create_self_transfer( if self._mode in (MiniWalletMode.RAW_OP_TRUE, MiniWalletMode.ADDRESS_OP_TRUE): vsize = Decimal(104) # anyone-can-spend elif self._mode == MiniWalletMode.RAW_P2PK: - vsize = Decimal(168) # P2PK (73 bytes scriptSig + 35 bytes scriptPubKey + 60 bytes other) + vsize = Decimal(192) # P2PK (73+34 bytes scriptSig + 25 bytes scriptPubKey + 60 bytes other) else: assert False if target_vsize and not fee: # respect fee_rate if target vsize is passed @@ -397,6 +411,8 @@ def create_self_transfer( return tx def sendrawtransaction(self, *, from_node, tx_hex, maxfeerate=0, **kwargs): + if self._mode == MiniWalletMode.RAW_OP_TRUE and 'ignore_rejects' not in kwargs: + kwargs['ignore_rejects'] = ('scriptsig-not-pushonly', 'scriptpubkey', 'bad-txns-input-script-unknown') txid = from_node.sendrawtransaction(hexstring=tx_hex, maxfeerate=maxfeerate, **kwargs) self.scan_tx(from_node.decoderawtransaction(tx_hex)) return txid diff --git a/test/functional/tool_utxo_to_sqlite.py b/test/functional/tool_utxo_to_sqlite.py index 7399e7b57456..abca9d70fa95 100755 --- a/test/functional/tool_utxo_to_sqlite.py +++ b/test/functional/tool_utxo_to_sqlite.py @@ -67,29 +67,43 @@ def run_test(self): wallet = MiniWallet(node) key = ECKey() - self.log.info('Create UTXOs with various output script types') + self.log.info('Test that oversized output scripts are rejected') + key.generate(compressed=False) + uncompressed_pubkey = key.get_pubkey().get_bytes() + key.generate(compressed=True) + pubkey = key.get_pubkey().get_bytes() + + # Test that scripts exceeding MAX_OUTPUT_SCRIPT_SIZE=34 are rejected + invalid_scripts = [ + (key_to_p2pk_script(pubkey), "P2PK compressed (35 bytes)"), + (key_to_p2pk_script(uncompressed_pubkey), "P2PK uncompressed (67 bytes)"), + (keys_to_multisig_script([pubkey]), "Bare multisig 1-of-1 (37 bytes)"), + (keys_to_multisig_script([uncompressed_pubkey]*2), "Bare multisig 2-of-2 uncompressed"), + (CScript([CScriptOp.encode_op_n(1)]*1000), "Large script (1000 bytes)"), + ] + + for script, description in invalid_scripts: + try: + wallet.send_to(from_node=node, scriptPubKey=script, amount=1, fee=20000) + raise AssertionError(f"{description} should have been rejected") + except Exception as e: + assert 'bad-txns-vout-script-toolarge' in str(e), \ + f"{description} rejected with wrong error: {e}" + self.log.info(f" ✓ {description} correctly rejected") + + self.log.info('Create UTXOs with valid output script types (≤34 bytes)') for i in range(1, 10+1): - key.generate(compressed=False) - uncompressed_pubkey = key.get_pubkey().get_bytes() key.generate(compressed=True) pubkey = key.get_pubkey().get_bytes() - # add output scripts for compressed script type 0 (P2PKH), type 1 (P2SH), - # types 2-3 (P2PK compressed), types 4-5 (P2PK uncompressed) and - # for uncompressed scripts (bare multisig, segwit, etc.) + # Only include output scripts that comply with MAX_OUTPUT_SCRIPT_SIZE=34 output_scripts = ( - key_to_p2pkh_script(pubkey), - script_to_p2sh_script(key_to_p2pkh_script(pubkey)), - key_to_p2pk_script(pubkey), - key_to_p2pk_script(uncompressed_pubkey), - - keys_to_multisig_script([pubkey]*i), - keys_to_multisig_script([uncompressed_pubkey]*i), - key_to_p2wpkh_script(pubkey), - script_to_p2wsh_script(key_to_p2pkh_script(pubkey)), - output_key_to_p2tr_script(pubkey[1:]), - PAY_TO_ANCHOR, - CScript([CScriptOp.encode_op_n(i)]*(1000*i)), # large script (up to 10000 bytes) + key_to_p2pkh_script(pubkey), # 25 bytes + script_to_p2sh_script(key_to_p2pkh_script(pubkey)), # 23 bytes + key_to_p2wpkh_script(pubkey), # 22 bytes + script_to_p2wsh_script(key_to_p2pkh_script(pubkey)),# 34 bytes + output_key_to_p2tr_script(pubkey[1:]), # 34 bytes + PAY_TO_ANCHOR, # 4 bytes ) # create outputs and mine them in a block From 224c1c32ce572f372406ac5ad0b0d74d93707f13 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Wed, 14 Jan 2026 12:48:15 -0600 Subject: [PATCH 23/34] consensus: apply output size limit to generation transactions --- src/consensus/tx_verify.cpp | 20 +++++++++++++------- src/consensus/tx_verify.h | 7 +++++++ src/validation.cpp | 12 +++++++++++- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/consensus/tx_verify.cpp b/src/consensus/tx_verify.cpp index 91215f5a1dc8..84558503c708 100644 --- a/src/consensus/tx_verify.cpp +++ b/src/consensus/tx_verify.cpp @@ -161,6 +161,17 @@ int64_t GetTransactionSigOpCost(const CTransaction& tx, const CCoinsViewCache& i return nSigOps; } +bool Consensus::CheckOutputSizes(const CTransaction& tx, TxValidationState& state) +{ + for (const auto& txout : tx.vout) { + if (txout.scriptPubKey.empty()) continue; + if (txout.scriptPubKey.size() > ((txout.scriptPubKey[0] == OP_RETURN) ? MAX_OUTPUT_DATA_SIZE : MAX_OUTPUT_SCRIPT_SIZE)) { + return state.Invalid(TxValidationResult::TX_PREMATURE_SPEND, "bad-txns-vout-script-toolarge"); + } + } + return true; +} + bool Consensus::CheckTxInputs(const CTransaction& tx, TxValidationState& state, const CCoinsViewCache& inputs, int nSpendHeight, CAmount& txfee, const CheckTxInputsRules rules) { // are the actual inputs available? @@ -170,13 +181,8 @@ bool Consensus::CheckTxInputs(const CTransaction& tx, TxValidationState& state, } // NOTE: CheckTransaction is arguably the more logical place to do this, but it's context-independent, so this is probably the next best place for now - if (rules.test(CheckTxInputsRules::OutputSizeLimit)) { - for (const auto& txout : tx.vout) { - if (txout.scriptPubKey.empty()) continue; - if (txout.scriptPubKey.size() > ((txout.scriptPubKey[0] == OP_RETURN) ? MAX_OUTPUT_DATA_SIZE : MAX_OUTPUT_SCRIPT_SIZE)) { - return state.Invalid(TxValidationResult::TX_PREMATURE_SPEND, "bad-txns-vout-script-toolarge"); - } - } + if (rules.test(CheckTxInputsRules::OutputSizeLimit) && !CheckOutputSizes(tx, state)) { + return false; } CAmount nValueIn = 0; diff --git a/src/consensus/tx_verify.h b/src/consensus/tx_verify.h index 65e705abd496..8111a5b77a4f 100644 --- a/src/consensus/tx_verify.h +++ b/src/consensus/tx_verify.h @@ -42,6 +42,13 @@ class CheckTxInputsRules { }; namespace Consensus { +/** + * Check whether all outputs of this transaction satisfy size limits. + * Regular outputs must be <= MAX_OUTPUT_SCRIPT_SIZE (34 bytes). + * OP_RETURN outputs must be <= MAX_OUTPUT_DATA_SIZE (83 bytes). + */ +bool CheckOutputSizes(const CTransaction& tx, TxValidationState& state); + /** * Check whether all inputs of this transaction are valid (no double spends and amounts) * This does not modify the UTXO set. This does not check scripts and sigs. diff --git a/src/validation.cpp b/src/validation.cpp index 747214c2f608..490f7d15f3a6 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -2899,7 +2899,17 @@ bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state, CCheckQueueControl control(fScriptChecks && parallel_script_checks ? &m_chainman.GetCheckQueue() : nullptr); std::vector txsdata(block.vtx.size()); - const auto chk_input_rules{DeploymentActiveAt(*pindex, m_chainman, Consensus::DEPLOYMENT_REDUCED_DATA) ? CheckTxInputsRules::OutputSizeLimit : CheckTxInputsRules::None}; + const CheckTxInputsRules chk_input_rules{DeploymentActiveAt(*pindex, m_chainman, Consensus::DEPLOYMENT_REDUCED_DATA) ? CheckTxInputsRules::OutputSizeLimit : CheckTxInputsRules::None}; + + // Check generation tx output sizes if REDUCED_DATA is active + if (chk_input_rules.test(CheckTxInputsRules::OutputSizeLimit)) { + TxValidationState tx_state; + if (!Consensus::CheckOutputSizes(*block.vtx[0], tx_state)) { + return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, + tx_state.GetRejectReason(), + tx_state.GetDebugMessage() + " in generation tx " + block.vtx[0]->GetHash().ToString()); + } + } std::vector prevheights; CAmount nFees = 0; From 881d4ab135f9452e99b6c3c5a2f0ea7d9d1b3151 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Sat, 4 Oct 2025 14:39:50 +0000 Subject: [PATCH 24/34] consensus: Enforce SCRIPT_VERIFY_DISCOURAGE_{UPGRADABLE_WITNESS_PROGRAM,UPGRADABLE_TAPROOT_VERSION,OP_SUCCESS} on blocks when DEPLOYMENT_REDUCED_DATA is active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Léo Haf Co-Authored-By: Dathon Ohm --- src/test/fuzz/miniscript.cpp | 9 +++++++++ src/test/miniscript_tests.cpp | 9 +++++++++ src/validation.cpp | 5 ++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/test/fuzz/miniscript.cpp b/src/test/fuzz/miniscript.cpp index 7488245e699d..132f9f9d4d28 100644 --- a/src/test/fuzz/miniscript.cpp +++ b/src/test/fuzz/miniscript.cpp @@ -1136,6 +1136,9 @@ void TestNode(const MsCtx script_ctx, const NodeRef& node, FuzzedDataProvider& p if (node->ValidSatisfactions()) { assert(res || serror == ScriptError::SCRIPT_ERR_PUSH_SIZE || + serror == ScriptError::SCRIPT_ERR_DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM || + serror == ScriptError::SCRIPT_ERR_DISCOURAGE_UPGRADABLE_TAPROOT_VERSION || + serror == ScriptError::SCRIPT_ERR_DISCOURAGE_OP_SUCCESS || serror == ScriptError::SCRIPT_ERR_TAPSCRIPT_MINIMALIF); } // More detailed: non-malleable satisfactions must be valid, or could fail with ops count error (if CheckOpsLimit failed), @@ -1144,6 +1147,9 @@ void TestNode(const MsCtx script_ctx, const NodeRef& node, FuzzedDataProvider& p (!node->CheckOpsLimit() && serror == ScriptError::SCRIPT_ERR_OP_COUNT) || (!node->CheckStackSize() && serror == ScriptError::SCRIPT_ERR_STACK_SIZE) || serror == ScriptError::SCRIPT_ERR_PUSH_SIZE || + serror == ScriptError::SCRIPT_ERR_DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM || + serror == ScriptError::SCRIPT_ERR_DISCOURAGE_UPGRADABLE_TAPROOT_VERSION || + serror == ScriptError::SCRIPT_ERR_DISCOURAGE_OP_SUCCESS || serror == ScriptError::SCRIPT_ERR_TAPSCRIPT_MINIMALIF); } @@ -1159,6 +1165,9 @@ void TestNode(const MsCtx script_ctx, const NodeRef& node, FuzzedDataProvider& p serror == ScriptError::SCRIPT_ERR_OP_COUNT || serror == ScriptError::SCRIPT_ERR_STACK_SIZE || serror == ScriptError::SCRIPT_ERR_PUSH_SIZE || + serror == ScriptError::SCRIPT_ERR_DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM || + serror == ScriptError::SCRIPT_ERR_DISCOURAGE_UPGRADABLE_TAPROOT_VERSION || + serror == ScriptError::SCRIPT_ERR_DISCOURAGE_OP_SUCCESS || serror == ScriptError::SCRIPT_ERR_TAPSCRIPT_MINIMALIF); } diff --git a/src/test/miniscript_tests.cpp b/src/test/miniscript_tests.cpp index 28bb6c8744f9..e7e32ea2b4a2 100644 --- a/src/test/miniscript_tests.cpp +++ b/src/test/miniscript_tests.cpp @@ -397,6 +397,9 @@ void TestSatisfy(const KeyConverter& converter, const std::string& testcase, con if (node->ValidSatisfactions()) { BOOST_CHECK(res || serror == ScriptError::SCRIPT_ERR_PUSH_SIZE || + serror == ScriptError::SCRIPT_ERR_DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM || + serror == ScriptError::SCRIPT_ERR_DISCOURAGE_UPGRADABLE_TAPROOT_VERSION || + serror == ScriptError::SCRIPT_ERR_DISCOURAGE_OP_SUCCESS || serror == ScriptError::SCRIPT_ERR_TAPSCRIPT_MINIMALIF); } // More detailed: non-malleable satisfactions must be valid, or could fail with ops count error (if CheckOpsLimit failed), @@ -405,6 +408,9 @@ void TestSatisfy(const KeyConverter& converter, const std::string& testcase, con (!node->CheckOpsLimit() && serror == ScriptError::SCRIPT_ERR_OP_COUNT) || (!node->CheckStackSize() && serror == ScriptError::SCRIPT_ERR_STACK_SIZE) || (serror == ScriptError::SCRIPT_ERR_PUSH_SIZE) || + (serror == ScriptError::SCRIPT_ERR_DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM) || + (serror == ScriptError::SCRIPT_ERR_DISCOURAGE_UPGRADABLE_TAPROOT_VERSION) || + (serror == ScriptError::SCRIPT_ERR_DISCOURAGE_OP_SUCCESS) || (serror == ScriptError::SCRIPT_ERR_TAPSCRIPT_MINIMALIF)); } @@ -418,6 +424,9 @@ void TestSatisfy(const KeyConverter& converter, const std::string& testcase, con serror == ScriptError::SCRIPT_ERR_OP_COUNT || serror == ScriptError::SCRIPT_ERR_STACK_SIZE || serror == ScriptError::SCRIPT_ERR_PUSH_SIZE || + serror == ScriptError::SCRIPT_ERR_DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM || + serror == ScriptError::SCRIPT_ERR_DISCOURAGE_UPGRADABLE_TAPROOT_VERSION || + serror == ScriptError::SCRIPT_ERR_DISCOURAGE_OP_SUCCESS || serror == ScriptError::SCRIPT_ERR_TAPSCRIPT_MINIMALIF); } diff --git a/src/validation.cpp b/src/validation.cpp index 490f7d15f3a6..45ef23dd5119 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -2682,7 +2682,10 @@ static unsigned int GetBlockScriptFlags(const CBlockIndex& block_index, const Ch } if (DeploymentActiveAt(block_index, chainman, Consensus::DEPLOYMENT_REDUCED_DATA)) { - flags |= SCRIPT_VERIFY_REDUCED_DATA; + flags |= SCRIPT_VERIFY_REDUCED_DATA | + SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM | + SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_TAPROOT_VERSION | + SCRIPT_VERIFY_DISCOURAGE_OP_SUCCESS; } return flags; From 286a923ecad29ffa651b3211c9ba26f7cc2c239a Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Mon, 3 Nov 2025 18:33:52 +0000 Subject: [PATCH 25/34] Refactor: Include all reduced_data verify flags in REDUCED_DATA_MANDATORY_VERIFY_FLAGS --- src/policy/policy.h | 2 +- src/script/interpreter.h | 7 +++++++ src/validation.cpp | 5 +---- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/policy/policy.h b/src/policy/policy.h index d19433285e05..908c2346e0fc 100644 --- a/src/policy/policy.h +++ b/src/policy/policy.h @@ -171,7 +171,7 @@ static constexpr unsigned int STANDARD_SCRIPT_VERIFY_FLAGS{MANDATORY_SCRIPT_VERI SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_TAPROOT_VERSION | SCRIPT_VERIFY_DISCOURAGE_OP_SUCCESS | SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_PUBKEYTYPE | - SCRIPT_VERIFY_REDUCED_DATA}; + REDUCED_DATA_MANDATORY_VERIFY_FLAGS}; /** For convenience, standard but not mandatory verify flags. */ static constexpr unsigned int STANDARD_NOT_MANDATORY_VERIFY_FLAGS{STANDARD_SCRIPT_VERIFY_FLAGS & ~MANDATORY_SCRIPT_VERIFY_FLAGS}; diff --git a/src/script/interpreter.h b/src/script/interpreter.h index 0f641acc8736..392914b71f5d 100644 --- a/src/script/interpreter.h +++ b/src/script/interpreter.h @@ -155,6 +155,13 @@ enum : uint32_t { SCRIPT_VERIFY_END_MARKER }; +static constexpr unsigned int REDUCED_DATA_MANDATORY_VERIFY_FLAGS{0 + | SCRIPT_VERIFY_REDUCED_DATA + | SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM + | SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_TAPROOT_VERSION + | SCRIPT_VERIFY_DISCOURAGE_OP_SUCCESS +}; + bool CheckSignatureEncoding(const std::vector &vchSig, unsigned int flags, ScriptError* serror); struct PrecomputedTransactionData diff --git a/src/validation.cpp b/src/validation.cpp index 45ef23dd5119..f0f803f29648 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -2682,10 +2682,7 @@ static unsigned int GetBlockScriptFlags(const CBlockIndex& block_index, const Ch } if (DeploymentActiveAt(block_index, chainman, Consensus::DEPLOYMENT_REDUCED_DATA)) { - flags |= SCRIPT_VERIFY_REDUCED_DATA | - SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM | - SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_TAPROOT_VERSION | - SCRIPT_VERIFY_DISCOURAGE_OP_SUCCESS; + flags |= REDUCED_DATA_MANDATORY_VERIFY_FLAGS; } return flags; From 48118a72548fcc537767a76a1f4d021e7353441d Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Mon, 3 Nov 2025 18:41:11 +0000 Subject: [PATCH 26/34] validation: Extend CheckInputScripts to allow overriding script validation flags on a per-input basis --- src/test/txvalidationcache_tests.cpp | 4 +++- src/validation.cpp | 12 ++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/test/txvalidationcache_tests.cpp b/src/test/txvalidationcache_tests.cpp index d22b815fcd68..11e48b9f530b 100644 --- a/src/test/txvalidationcache_tests.cpp +++ b/src/test/txvalidationcache_tests.cpp @@ -24,7 +24,9 @@ bool CheckInputScripts(const CTransaction& tx, TxValidationState& state, const CCoinsViewCache& inputs, unsigned int flags, bool cacheSigStore, bool cacheFullScriptStore, PrecomputedTransactionData& txdata, ValidationCache& validation_cache, - std::vector* pvChecks) EXCLUSIVE_LOCKS_REQUIRED(cs_main); + std::vector* pvChecks, + const std::vector& flags_per_input = {} +) EXCLUSIVE_LOCKS_REQUIRED(cs_main); BOOST_AUTO_TEST_SUITE(txvalidationcache_tests) diff --git a/src/validation.cpp b/src/validation.cpp index f0f803f29648..19ea27e99b43 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -143,7 +143,8 @@ bool CheckInputScripts(const CTransaction& tx, TxValidationState& state, const CCoinsViewCache& inputs, unsigned int flags, bool cacheSigStore, bool cacheFullScriptStore, PrecomputedTransactionData& txdata, ValidationCache& validation_cache, - std::vector* pvChecks = nullptr) + std::vector* pvChecks = nullptr, + const std::vector& flags_per_input = {}) EXCLUSIVE_LOCKS_REQUIRED(cs_main); bool CheckFinalTxAtTip(const CBlockIndex& active_chain_tip, const CTransaction& tx) @@ -2415,6 +2416,10 @@ ValidationCache::ValidationCache(const size_t script_execution_cache_bytes, cons * This involves ECDSA signature checks so can be computationally intensive. This function should * only be called after the cheap sanity checks in CheckTxInputs passed. * + * WARNING: flags_per_input deviations from flags must be handled with care. Under no + * circumstances should they allow a script to pass that might not pass with the same + * `flags` parameter (which is used for the cache). + * * If pvChecks is not nullptr, script checks are pushed onto it instead of being performed inline. Any * script checks which are not necessary (eg due to script execution cache hits) are, obviously, * not pushed onto pvChecks/run. @@ -2432,7 +2437,8 @@ bool CheckInputScripts(const CTransaction& tx, TxValidationState& state, const CCoinsViewCache& inputs, unsigned int flags, bool cacheSigStore, bool cacheFullScriptStore, PrecomputedTransactionData& txdata, ValidationCache& validation_cache, - std::vector* pvChecks) + std::vector* pvChecks, + const std::vector& flags_per_input) { if (tx.IsCoinBase()) return true; @@ -2466,8 +2472,10 @@ bool CheckInputScripts(const CTransaction& tx, TxValidationState& state, txdata.Init(tx, std::move(spent_outputs)); } assert(txdata.m_spent_outputs.size() == tx.vin.size()); + assert(flags_per_input.empty() || flags_per_input.size() == tx.vin.size()); for (unsigned int i = 0; i < tx.vin.size(); i++) { + if (!flags_per_input.empty()) flags = flags_per_input[i]; // We very carefully only pass in things to CScriptCheck which // are clearly committed to by tx' witness hash. This provides From 71722a2123b2bc4b5a15f2f8fa2bad1933431562 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Mon, 3 Nov 2025 18:43:43 +0000 Subject: [PATCH 27/34] validation: Exempt inputs spending UTXOs prior to reduced_data_start_height from reduced_data script validation rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugfix: validation: Do not cache the result of CheckInputScripts if flags_per_input is used (and avoid using it when unnecessary) Co-Authored-By: Dathon Ohm Co-Authored-By: Lőrinc --- src/test/txvalidationcache_tests.cpp | 74 ++++++++++++++++++++++++++++ src/validation.cpp | 21 ++++++-- 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/src/test/txvalidationcache_tests.cpp b/src/test/txvalidationcache_tests.cpp index 11e48b9f530b..cf93d4702cb1 100644 --- a/src/test/txvalidationcache_tests.cpp +++ b/src/test/txvalidationcache_tests.cpp @@ -2,9 +2,11 @@ // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. +#include #include #include #include +#include