From 0f4732f56c16f702ca15082672389c9bd69d63e3 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Fri, 3 Oct 2025 14:20:12 +0000 Subject: [PATCH 01/81] Use TAPROOT_CONTROL_MAX_NODE_COUNT_REDUCED for non-consensus Taproot logic --- src/psbt.h | 2 +- src/script/descriptor.cpp | 2 +- src/script/interpreter.h | 2 ++ src/script/signingprovider.cpp | 4 ++-- 4 files changed, 6 insertions(+), 4 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 c8802d2bf893..d17157968aa4 100644 --- a/src/script/descriptor.cpp +++ b/src/script/descriptor.cpp @@ -1966,7 +1966,7 @@ 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) { + if (branches.size() > TAPROOT_CONTROL_MAX_NODE_COUNT_REDUCED) { error = strprintf("tr() supports at most %i nesting levels", TAPROOT_CONTROL_MAX_NODE_COUNT); return {}; } diff --git a/src/script/interpreter.h b/src/script/interpreter.h index d613becb8f6c..f5a7477ec7a0 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 597b1a1544bc..0fe284fb3620 100644 --- a/src/script/signingprovider.cpp +++ b/src/script/signingprovider.cpp @@ -353,7 +353,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(); @@ -466,7 +466,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; From d6ae3babd6a18104a11b645c805572656f9803be Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Sat, 4 Oct 2025 12:58:18 +0000 Subject: [PATCH 02/81] Policy: Enforce SCRIPT_VERIFY_REDUCED_DATA as a policy rule --- src/policy/policy.h | 3 ++- src/script/interpreter.h | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/policy/policy.h b/src/policy/policy.h index a6ce608bcfad..c5f560c5d511 100644 --- a/src/policy/policy.h +++ b/src/policy/policy.h @@ -126,7 +126,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 f5a7477ec7a0..cb791bc0d40c 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 From 7c39d4863d9794f335d5f5f1cefb4877ddda836f Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Mon, 6 Oct 2025 15:29:51 +0000 Subject: [PATCH 03/81] script: Define SCRIPT_VERIFY_REDUCED_DATA verification flag (unused) to reduce data push size limit to 256 bytes (except for P2SH redeemScript push) --- src/script/interpreter.cpp | 22 ++++++++++++++++++++-- src/script/interpreter.h | 3 ++- src/script/script.h | 1 + 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/script/interpreter.cpp b/src/script/interpreter.cpp index 4b7bfcedc6c5..58e8aadfd6d7 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) { @@ -1851,8 +1853,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. @@ -2011,6 +2014,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; @@ -2062,6 +2071,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 cb791bc0d40c..4c58a62b670f 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 f4579849803b..4d1a2543785c 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; From c219005ff8c09a0b95ba8f31bf42e2cb8277726b Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Fri, 3 Oct 2025 13:32:53 +0000 Subject: [PATCH 04/81] script: Limit Taproot annex to 256 bytes for SCRIPT_VERIFY_REDUCED_DATA (still unused) --- 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 58e8aadfd6d7..1876991950e2 100644 --- a/src/script/interpreter.cpp +++ b/src/script/interpreter.cpp @@ -1949,6 +1949,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) && annex.size() > MAX_SCRIPT_ELEMENT_SIZE_REDUCED) { + 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 4c58a62b670f..a2e9f5fc4aa2 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 limited to MAX_SCRIPT_ELEMENT_SIZE_REDUCED SCRIPT_VERIFY_REDUCED_DATA = (1U << 21), // Constants to point to the highest flag in use. Add new flags above this line. From 9ca76a79150a4c0ea813c55d2753f15219ff1e98 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Fri, 3 Oct 2025 14:29:08 +0000 Subject: [PATCH 05/81] script: Forbid Taproot annex entirely with SCRIPT_VERIFY_REDUCED_DATA (still unused) --- src/script/interpreter.cpp | 2 +- src/script/interpreter.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/script/interpreter.cpp b/src/script/interpreter.cpp index 1876991950e2..cdd1d8f91fd3 100644 --- a/src/script/interpreter.cpp +++ b/src/script/interpreter.cpp @@ -1949,7 +1949,7 @@ 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) && annex.size() > MAX_SCRIPT_ELEMENT_SIZE_REDUCED) { + if (flags & SCRIPT_VERIFY_REDUCED_DATA) { return set_error(serror, SCRIPT_ERR_PUSH_SIZE); } execdata.m_annex_hash = (HashWriter{} << annex).GetSHA256(); diff --git a/src/script/interpreter.h b/src/script/interpreter.h index a2e9f5fc4aa2..5dcd443e5540 100644 --- a/src/script/interpreter.h +++ b/src/script/interpreter.h @@ -145,7 +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 limited to MAX_SCRIPT_ELEMENT_SIZE_REDUCED + // 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 3a52daeba50ea3abeff86129e81c6acc7ac6976a Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Fri, 3 Oct 2025 14:42:16 +0000 Subject: [PATCH 06/81] script: Forbid OP_IF in Tapscript with SCRIPT_VERIFY_REDUCED_DATA (still unused) --- src/script/interpreter.cpp | 3 +++ src/script/interpreter.h | 1 + test/functional/feature_taproot.py | 9 +++++++-- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/script/interpreter.cpp b/src/script/interpreter.cpp index cdd1d8f91fd3..2d10acdbe061 100644 --- a/src/script/interpreter.cpp +++ b/src/script/interpreter.cpp @@ -618,6 +618,9 @@ 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); } + 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 5dcd443e5540..d1b18ae01a28 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/test/functional/feature_taproot.py b/test/functional/feature_taproot.py index 198bec7df530..772a44ff879c 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,12 @@ 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 + standard = annex is None and dummylen <= 80 and len(pubkey) == 32 and OP_IF not in script 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 764ac867fbdbab81d8916498a4de0111917b0291 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Fri, 3 Oct 2025 14:07:23 +0000 Subject: [PATCH 07/81] 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 2d10acdbe061..e1089f184b32 100644 --- a/src/script/interpreter.cpp +++ b/src/script/interpreter.cpp @@ -1971,7 +1971,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 d1b18ae01a28..9afc8101bbdc 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 772a44ff879c..c1aa3454fe30 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) @@ -1140,7 +1144,7 @@ def predict_sigops_ratio(n, dummy_size): 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 and OP_IF not in script + standard = annex is None and dummylen <= 80 and len(pubkey) == 32 and OP_IF not in script 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 73ac59056717b3266837226bc0403b1ac3ec85a2 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Sat, 4 Oct 2025 13:17:18 +0000 Subject: [PATCH 08/81] consensus: Enforce SCRIPT_VERIFY_REDUCED_DATA if DEPLOYMENT_REDUCED_DATA is active (still never) --- src/validation.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/validation.cpp b/src/validation.cpp index 3d6ebd30e5b1..2e10940c5e5f 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -2410,6 +2410,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 b613f2668ad556d81bc4607e1f0c510d23947def Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Sat, 4 Oct 2025 14:32:23 +0000 Subject: [PATCH 09/81] Limit datacarriersize config to MAX_OUTPUT_DATA_SIZE (=83 B) --- src/consensus/consensus.h | 2 ++ src/init.cpp | 4 ++-- src/node/mempool_args.cpp | 4 ++++ test/functional/test_framework/test_node.py | 6 ++++++ 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/consensus/consensus.h b/src/consensus/consensus.h index cffe9cdafd79..2c90442871f6 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_DATA_SIZE{83}; + #endif // BITCOIN_CONSENSUS_CONSENSUS_H diff --git a/src/init.cpp b/src/init.cpp index 70615a191be1..87e844b78a06 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -636,8 +636,8 @@ void SetupServerArgs(ArgsManager& argsman, bool can_listen_ipc) argsman.AddArg("-bytespersigop", strprintf("Equivalent bytes per sigop in transactions for relay and mining (default: %u)", DEFAULT_BYTES_PER_SIGOP), ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY); argsman.AddArg("-datacarrier", strprintf("Relay and mine data carrier transactions (default: %u)", DEFAULT_ACCEPT_DATACARRIER), ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY); argsman.AddArg("-datacarriersize", - strprintf("Relay and mine transactions whose data-carrying raw scriptPubKey " - "is of this size or less (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("-permitbaremultisig", strprintf("Relay transactions creating non-P2SH multisig outputs (default: %u)", DEFAULT_PERMIT_BAREMULTISIG), ArgsManager::ALLOW_ANY, diff --git a/src/node/mempool_args.cpp b/src/node/mempool_args.cpp index f2241f0caf51..ac72dca61150 100644 --- a/src/node/mempool_args.cpp +++ b/src/node/mempool_args.cpp @@ -93,6 +93,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 919d48b37aeb..f520ea52b0ef 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 ( @@ -255,6 +256,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 c84327f38223ea90dd8588330c4a09e54e414ec1 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Fri, 3 Oct 2025 15:06:45 +0000 Subject: [PATCH 10/81] 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 | 2 +- src/validation.cpp | 4 ++-- 5 files changed, 29 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 9c6aa6e7a1ec..3b56ec9c1d15 100644 --- a/src/test/fuzz/coins_view.cpp +++ b/src/test/fuzz/coins_view.cpp @@ -255,7 +255,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 3a5a3fb306d3..fd1c1474959c 100644 --- a/src/txmempool.cpp +++ b/src/txmempool.cpp @@ -777,7 +777,7 @@ 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)); + 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 2e10940c5e5f..96a07e2cd28a 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -874,7 +874,7 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws) } // The mempool holds txs for the next block, so pass height+1 to CheckTxInputs - if (!Consensus::CheckTxInputs(tx, state, m_view, m_active_chainstate.m_chain.Height() + 1, ws.m_base_fees)) { + if (!Consensus::CheckTxInputs(tx, state, m_view, m_active_chainstate.m_chain.Height() + 1, ws.m_base_fees, CheckTxInputsRules::None)) { return false; // state filled in by CheckTxInputs } @@ -2638,7 +2638,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 5c9887b95f4eaebdabeca69dffcbf65576d02357 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Tue, 7 Oct 2025 13:13:00 +0000 Subject: [PATCH 11/81] consensus: Define CheckTxInputsRules::OutputSizeLimit flag (unused) to cap output scripts at 83 bytes --- src/consensus/consensus.h | 1 + src/consensus/tx_verify.cpp | 9 +++++++++ src/consensus/tx_verify.h | 1 + src/test/fuzz/coins_view.cpp | 2 +- 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/consensus/consensus.h b/src/consensus/consensus.h index 2c90442871f6..82b10e390613 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_DATA_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 3b56ec9c1d15..9afc0a04522b 100644 --- a/src/test/fuzz/coins_view.cpp +++ b/src/test/fuzz/coins_view.cpp @@ -255,7 +255,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 6dd9cf3eeed49b6a107f430f339c508ed9e7bd25 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Tue, 7 Oct 2025 13:48:39 +0000 Subject: [PATCH 12/81] consensus: When CheckTxInputsRules::OutputSizeLimit is enforced (still never), limit non-OP_RETURN scripts to 34 bytes --- src/consensus/consensus.h | 2 +- src/consensus/tx_verify.cpp | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/consensus/consensus.h b/src/consensus/consensus.h index 82b10e390613..b02773f4900b 100644 --- a/src/consensus/consensus.h +++ b/src/consensus/consensus.h @@ -34,7 +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 a12208d4b1fe88d4ce85ae68bc2aa879552c8eb9 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Tue, 7 Oct 2025 22:24:27 +0000 Subject: [PATCH 13/81] QA: rpc_getdescriptoractivity: Use RAW_OP_TRUE for test_no_address --- test/functional/rpc_getdescriptoractivity.py | 2 +- test/functional/test_framework/wallet.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/test/functional/rpc_getdescriptoractivity.py b/test/functional/rpc_getdescriptoractivity.py index 6ddbdfd52d06..19f05c3ab12e 100755 --- a/test/functional/rpc_getdescriptoractivity.py +++ b/test/functional/rpc_getdescriptoractivity.py @@ -195,7 +195,7 @@ def test_receive_then_spend(self, node, wallet): [blockhash_1, blockhash_2, blockhash_2], [wallet.get_descriptor()], True)) def test_no_address(self, node, wallet): - 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/test_framework/wallet.py b/test/functional/test_framework/wallet.py index dee90f9fd6cf..fca7b95afc4b 100644 --- a/test/functional/test_framework/wallet.py +++ b/test/functional/test_framework/wallet.py @@ -397,6 +397,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 From b0b4f6167afca5007ab3e81aba6e75b042b47856 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Tue, 7 Oct 2025 15:43:43 +0000 Subject: [PATCH 14/81] QA: test_framework/wallet: Turn MiniWalletMode.RAW_P2PK into actually p2pkh --- test/functional/test_framework/wallet.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/test/functional/test_framework/wallet.py b/test/functional/test_framework/wallet.py index fca7b95afc4b..4f69bd2821fc 100644 --- a/test/functional/test_framework/wallet.py +++ b/test/functional/test_framework/wallet.py @@ -78,7 +78,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 +101,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) @@ -182,8 +182,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 +376,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 From 595c9e38cd62f348ff541fb5a8952d214427de11 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Wed, 8 Oct 2025 13:17:52 +0000 Subject: [PATCH 15/81] RPC/Mempool: Provide tx memory usage in testmempoolaccept --- src/rpc/mempool.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/rpc/mempool.cpp b/src/rpc/mempool.cpp index 2b883322aa17..d15207d2520b 100644 --- a/src/rpc/mempool.cpp +++ b/src/rpc/mempool.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -138,6 +139,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}, @@ -203,6 +205,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()); } From 3a1ac983939ae9408942b445b353199443ec739a Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Wed, 8 Oct 2025 13:19:14 +0000 Subject: [PATCH 16/81] QA: test_framework/mempool_util: Calibrate fill_mempool bulk tx size to expected memory usage --- test/functional/test_framework/mempool_util.py | 9 +++++++++ 1 file changed, 9 insertions(+) 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"] From 3f4a6d72fd22313bbd1c4e4c4153bc3eb8e59bf2 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Wed, 8 Oct 2025 19:18:51 +0000 Subject: [PATCH 17/81] Bugfix: QA: mempool_limit: Use "usage" rather than "bytes" --- test/functional/mempool_limit.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/functional/mempool_limit.py b/test/functional/mempool_limit.py index 5051f8a03016..1398b2cd541a 100755 --- a/test/functional/mempool_limit.py +++ b/test/functional/mempool_limit.py @@ -121,7 +121,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 +159,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() @@ -229,7 +229,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 +260,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 +329,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 +406,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 From 953c14801242a4855fbb301ef9783c6fd2e02e7e Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Tue, 7 Oct 2025 15:44:40 +0000 Subject: [PATCH 18/81] QA: test_framework: Use multiple OP_RETURNs to pad transactions rather than exceed MAX_OP_RETURN_RELAY --- test/functional/test_framework/util.py | 3 ++- test/functional/test_framework/wallet.py | 25 ++++++++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/test/functional/test_framework/util.py b/test/functional/test_framework/util.py index 6cf04a2e2663..153e9cabb0d0 100644 --- a/test/functional/test_framework/util.py +++ b/test/functional/test_framework/util.py @@ -558,7 +558,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 4f69bd2821fc..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 ( @@ -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): From 79fcad80d33374ded6c15af7a837a722be4d55fd Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Sat, 4 Oct 2025 14:34:08 +0000 Subject: [PATCH 19/81] consensus: Enforce CheckTxInputsRules::OutputSizeLimit when DEPLOYMENT_REDUCED_DATA is active (never yet) --- src/validation.cpp | 6 ++++-- test/functional/feature_segwit.py | 16 ++++++---------- test/functional/mempool_dust.py | 6 +----- 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/src/validation.cpp b/src/validation.cpp index 96a07e2cd28a..e9b7dd2dbc82 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -874,7 +874,7 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws) } // The mempool holds txs for the next block, so pass height+1 to CheckTxInputs - if (!Consensus::CheckTxInputs(tx, state, m_view, m_active_chainstate.m_chain.Height() + 1, ws.m_base_fees, CheckTxInputsRules::None)) { + if (!Consensus::CheckTxInputs(tx, state, m_view, m_active_chainstate.m_chain.Height() + 1, ws.m_base_fees, CheckTxInputsRules::OutputSizeLimit)) { return false; // state filled in by CheckTxInputs } @@ -2622,6 +2622,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; @@ -2638,7 +2640,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/feature_segwit.py b/test/functional/feature_segwit.py index cc664a83aa3e..4961cb8d6a7d 100755 --- a/test/functional/feature_segwit.py +++ b/test/functional/feature_segwit.py @@ -372,14 +372,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 @@ -391,14 +389,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 @@ -409,11 +405,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]) @@ -422,13 +418,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_dust.py b/test/functional/mempool_dust.py index 937e77fbd41b..48da212d4b61 100755 --- a/test/functional/mempool_dust.py +++ b/test/functional/mempool_dust.py @@ -109,8 +109,6 @@ def run_test(self): _, 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 +116,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)"), ) From 837f06f876bf4e41e620b822e2cd9967cd35433d Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Sat, 4 Oct 2025 14:39:50 +0000 Subject: [PATCH 20/81] consensus: Enforce SCRIPT_VERIFY_DISCOURAGE_{UPGRADABLE_WITNESS_PROGRAM,UPGRADABLE_TAPROOT_VERSION,OP_SUCCESS} on blocks when DEPLOYMENT_REDUCED_DATA is active (never yet) --- src/validation.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/validation.cpp b/src/validation.cpp index e9b7dd2dbc82..2c84053dceff 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -2411,7 +2411,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 a02cf6d755b1b5a25198d9cce64c9c663f80604e Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Wed, 31 May 2017 22:35:37 +0000 Subject: [PATCH 21/81] Define a service bit for BIP148 Github-Pull: #10532 Rebased-From: cd74a23fcf9588199e196ab31bc64972400c2027 --- src/protocol.cpp | 1 + src/protocol.h | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/protocol.cpp b/src/protocol.cpp index bdf0a669863a..35eafc17a480 100644 --- a/src/protocol.cpp +++ b/src/protocol.cpp @@ -99,6 +99,7 @@ static std::string serviceFlagToStr(size_t bit) case NODE_COMPACT_FILTERS: return "COMPACT_FILTERS"; case NODE_NETWORK_LIMITED: return "NETWORK_LIMITED"; case NODE_P2P_V2: return "P2P_V2"; + case NODE_BIP148: return "BIP148"; // Not using default, so we get warned when a case is missing } diff --git a/src/protocol.h b/src/protocol.h index 804597b86d88..71db6c78080d 100644 --- a/src/protocol.h +++ b/src/protocol.h @@ -336,6 +336,9 @@ enum ServiceFlags : uint64_t { // collisions and other cases where nodes may be advertising a service they // do not actually support. Other service bits should be allocated via the // BIP process. + + // NODE_BIP148 means the node enforces BIP 148's mandatory Segwit activation beginning August 1, 2017 + NODE_BIP148 = (1 << 27), }; /** From 62dd5aa885e6caf450f294433a30049ab5fe2c36 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Mon, 20 Oct 2025 00:28:40 +0000 Subject: [PATCH 22/81] Add questionmark to end of BIP148 service bit string, and add to bitcoin-cli --- src/bitcoin-cli.cpp | 3 ++- src/protocol.cpp | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/bitcoin-cli.cpp b/src/bitcoin-cli.cpp index 75911d00874d..6f22747c0cf1 100644 --- a/src/bitcoin-cli.cpp +++ b/src/bitcoin-cli.cpp @@ -477,7 +477,7 @@ class NetinfoRequestHandler : public BaseRequestHandler std::string str; for (size_t i = 0; i < services.size(); ++i) { const std::string s{services[i].get_str()}; - str += s == "NETWORK_LIMITED" ? 'l' : s == "P2P_V2" ? '2' : ToLower(s[0]); + str += s == "NETWORK_LIMITED" ? 'l' : s == "P2P_V2" ? '2' : s == "BIP148?" ? '1' : ToLower(s[0]); } return str; } @@ -713,6 +713,7 @@ class NetinfoRequestHandler : public BaseRequestHandler " \"c\" - COMPACT_FILTERS: peer can handle basic block filter requests (see BIPs 157 and 158)\n" " \"l\" - NETWORK_LIMITED: peer limited to serving only the last 288 blocks (~2 days)\n" " \"2\" - P2P_V2: peer supports version 2 P2P transport protocol, as defined in BIP 324\n" + " \"1\" - BIP148? peer enforces the BIP148 User-Activated SoftFork\n" " \"u\" - UNKNOWN: unrecognized bit flag\n" " v Version of transport protocol used for the connection\n" " mping Minimum observed ping time, in milliseconds (ms)\n" diff --git a/src/protocol.cpp b/src/protocol.cpp index 35eafc17a480..2a70482e4233 100644 --- a/src/protocol.cpp +++ b/src/protocol.cpp @@ -99,7 +99,7 @@ static std::string serviceFlagToStr(size_t bit) case NODE_COMPACT_FILTERS: return "COMPACT_FILTERS"; case NODE_NETWORK_LIMITED: return "NETWORK_LIMITED"; case NODE_P2P_V2: return "P2P_V2"; - case NODE_BIP148: return "BIP148"; + case NODE_BIP148: return "BIP148?"; // Not using default, so we get warned when a case is missing } From 4145d0044502ef5a7a477afd682106821027c196 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Wed, 31 May 2017 22:37:13 +0000 Subject: [PATCH 23/81] Preferentially peer with nodes enforcing BIP148 to avoid partitioning risk Github-Pull: #10532 Rebased-From: e42a2f6beb61df3e3a201804cf3bcce6b00c88ba --- src/init.cpp | 2 +- src/net_processing.cpp | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/init.cpp b/src/init.cpp index 87e844b78a06..b9d9232bc7a4 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -837,7 +837,7 @@ namespace { // Variables internal to initialization process only int nMaxConnections; int available_fds; -ServiceFlags g_local_services = ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS); +ServiceFlags g_local_services = ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP148); int64_t peer_connect_timeout; std::set g_enabled_filter_types; diff --git a/src/net_processing.cpp b/src/net_processing.cpp index 049812763716..fe06e0186bed 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -1635,13 +1635,14 @@ bool PeerManagerImpl::HasAllDesirableServiceFlags(ServiceFlags services) const ServiceFlags PeerManagerImpl::GetDesirableServiceFlags(ServiceFlags services) const { + // We want to preferentially peer with other nodes that enforce BIP148, in case of a chain split if (services & NODE_NETWORK_LIMITED) { // Limited peers are desirable when we are close to the tip. if (ApproximateBestBlockDepth() < NODE_NETWORK_LIMITED_ALLOW_CONN_BLOCKS) { - return ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS); + return ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP148); } } - return ServiceFlags(NODE_NETWORK | NODE_WITNESS); + return ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_BIP148); } PeerRef PeerManagerImpl::GetPeerRef(NodeId id) const From b02c29d16fd38afa0167344d40558d009d401f58 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Mon, 20 Oct 2025 00:37:02 +0000 Subject: [PATCH 24/81] Append UA string with UASF-ReducedData:0.1/ --- src/clientversion.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/clientversion.cpp b/src/clientversion.cpp index 10e9472b84ba..60d3088f2804 100644 --- a/src/clientversion.cpp +++ b/src/clientversion.cpp @@ -66,7 +66,7 @@ std::string FormatSubVersion(const std::string& name, int nClientVersion, const { std::string comments_str; if (!comments.empty()) comments_str = strprintf("(%s)", Join(comments, "; ")); - return strprintf("/%s:%s%s/", name, FormatVersion(nClientVersion), comments_str); + return strprintf("/%s:%s%s/%s/", name, FormatVersion(nClientVersion), comments_str, "UASF-ReducedData:0.1"); } std::string CopyrightHolders(const std::string& strPrefix) From 5f769d3338221da5a51a8a3f8ce217f30e472959 Mon Sep 17 00:00:00 2001 From: moneybadger1 Date: Sat, 1 Nov 2025 13:47:00 -0600 Subject: [PATCH 25/81] tests: fix feature_cltv, feature_dersig, mempool_accept, and mempool_accept_wtxid --- test/functional/feature_cltv.py | 21 +++++++++++---------- test/functional/feature_dersig.py | 13 +++++++------ test/functional/mempool_accept.py | 1 + test/functional/mempool_accept_wtxid.py | 21 +++++++++++++++------ 4 files changed, 34 insertions(+), 22 deletions(-) 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/mempool_accept.py b/test/functional/mempool_accept.py index 32d8f7f6eac9..1d0221dfdb26 100755 --- a/test/functional/mempool_accept.py +++ b/test/functional/mempool_accept.py @@ -68,6 +68,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()) From 496cd2b8579e857568b549ac50f39a7033bf4d4b Mon Sep 17 00:00:00 2001 From: moneybadger1 Date: Sat, 1 Nov 2025 13:44:30 -0600 Subject: [PATCH 26/81] tests: fix BIP148 service bit --- test/functional/feature_anchors.py | 10 ++++++---- test/functional/test_framework/messages.py | 1 + test/functional/test_framework/p2p.py | 3 ++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/test/functional/feature_anchors.py b/test/functional/feature_anchors.py index 154461e739dd..9638ccc375b1 100755 --- a/test/functional/feature_anchors.py +++ b/test/functional/feature_anchors.py @@ -8,7 +8,7 @@ from test_framework.p2p import P2PInterface, P2P_SERVICES from test_framework.socks5 import Socks5Configuration, Socks5Server -from test_framework.messages import CAddress, hash256 +from test_framework.messages import CAddress, hash256, ser_compact_size from test_framework.test_framework import BitcoinTestFramework from test_framework.util import check_node_connections, assert_equal, p2p_port @@ -113,7 +113,7 @@ def run_test(self): caddr.ip, port_str = ONION_ADDR.split(":") caddr.port = int(port_str) # TorV3 addrv2 serialization: - # time(4) | services(1) | networkID(1) | address length(1) | address(32) + # time(4) | services(CompactSize) | networkID(1) | address length(CompactSize) | address(32) expected_pubkey = caddr.serialize_v2()[7:39].hex() # position of services byte of first addr in anchors.dat @@ -122,7 +122,7 @@ def run_test(self): data = bytes() with open(node_anchors_path, "rb") as file_handler: data = file_handler.read() - assert_equal(data[services_index], 0x00) # services == NONE + assert_equal(data[services_index], 0x00) # services == NONE (CompactSize encoded as 1 byte) anchors2 = data.hex() assert expected_pubkey in anchors2 @@ -131,7 +131,9 @@ def run_test(self): # This is necessary because on restart we will not attempt an anchor connection # to a host without our required services, even if its address is in the anchors.dat file new_data = bytearray(data)[:-32] - new_data[services_index] = P2P_SERVICES + # Replace the 1-byte services field (0x00) with the CompactSize-encoded P2P_SERVICES (5 bytes for 0x08000009) + services_bytes = ser_compact_size(P2P_SERVICES) + new_data = new_data[:services_index] + services_bytes + new_data[services_index+1:] new_data_hash = hash256(new_data) file_handler.write(new_data + new_data_hash) diff --git a/test/functional/test_framework/messages.py b/test/functional/test_framework/messages.py index 971427842ffd..c5768e8d9173 100755 --- a/test/functional/test_framework/messages.py +++ b/test/functional/test_framework/messages.py @@ -55,6 +55,7 @@ NODE_COMPACT_FILTERS = (1 << 6) NODE_NETWORK_LIMITED = (1 << 10) NODE_P2P_V2 = (1 << 11) +NODE_BIP148 = (1 << 27) MSG_TX = 1 MSG_BLOCK = 2 diff --git a/test/functional/test_framework/p2p.py b/test/functional/test_framework/p2p.py index c5e518238ce2..628e2ab77e95 100755 --- a/test/functional/test_framework/p2p.py +++ b/test/functional/test_framework/p2p.py @@ -73,6 +73,7 @@ msg_wtxidrelay, NODE_NETWORK, NODE_WITNESS, + NODE_BIP148, MAGIC_BYTES, sha256, ) @@ -95,7 +96,7 @@ # Version 70016 supports wtxid relay P2P_VERSION = 70016 # The services that this test framework offers in its `version` message -P2P_SERVICES = NODE_NETWORK | NODE_WITNESS +P2P_SERVICES = NODE_NETWORK | NODE_WITNESS | NODE_BIP148 # The P2P user agent string that this test framework sends in its `version` message P2P_SUBVERSION = "/python-p2p-tester:0.0.3/" # Value for relay that this test framework sends in its `version` message From a22746b36345c3ac5696d1eb917a13901d2bd149 Mon Sep 17 00:00:00 2001 From: 3c853b6299 <3c853b6299@pm.me> Date: Sat, 1 Nov 2025 11:15:35 -0600 Subject: [PATCH 27/81] test: Adapt functional tests to MAX_OUTPUT_SCRIPT_SIZE=34 consensus limit; add reduced_data deployment name to allow regtest RPC access for testing --- test/functional/data/invalid_txs.py | 6 +- test/functional/mempool_dust.py | 76 +++++++++++ test/functional/mempool_sigoplimit.py | 176 ++++++++++++++++++++----- test/functional/tool_utxo_to_sqlite.py | 50 ++++--- 4 files changed, 259 insertions(+), 49 deletions(-) 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/mempool_dust.py b/test/functional/mempool_dust.py index 48da212d4b61..557c2938f746 100755 --- a/test/functional/mempool_dust.py +++ b/test/functional/mempool_dust.py @@ -99,10 +99,86 @@ 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(f" ✓ 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) diff --git a/test/functional/mempool_sigoplimit.py b/test/functional/mempool_sigoplimit.py index ec491ced2307..db4502a81dc3 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/tool_utxo_to_sqlite.py b/test/functional/tool_utxo_to_sqlite.py index 2da7c42a86bc..d952dbf81f3b 100755 --- a/test/functional/tool_utxo_to_sqlite.py +++ b/test/functional/tool_utxo_to_sqlite.py @@ -66,29 +66,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 cc893d5a29721d53e5de3d2f943a5f8c156b8218 Mon Sep 17 00:00:00 2001 From: 3c853b6299 <3c853b6299@pm.me> Date: Sun, 2 Nov 2025 13:44:25 -0600 Subject: [PATCH 28/81] test: adapt 6 tests to NODE_BIP148 service flag; add assert_equal_without_usage helper for testmempoolaccept results --- test/functional/p2p_addr_relay.py | 5 ++++- test/functional/p2p_handshake.py | 11 +++++----- test/functional/p2p_node_network_limited.py | 3 ++- test/functional/p2p_segwit.py | 10 +++++---- test/functional/rpc_net.py | 10 +++++++-- test/functional/rpc_packages.py | 21 ++++++++++--------- test/functional/test_framework/util.py | 23 +++++++++++++++++++++ 7 files changed, 60 insertions(+), 23 deletions(-) diff --git a/test/functional/p2p_addr_relay.py b/test/functional/p2p_addr_relay.py index 56a9e6a84e12..1a5bf45301a9 100755 --- a/test/functional/p2p_addr_relay.py +++ b/test/functional/p2p_addr_relay.py @@ -11,6 +11,9 @@ from test_framework.messages import ( CAddress, + NODE_BIP148, + NODE_NETWORK, + NODE_WITNESS, msg_addr, msg_getaddr, msg_verack, @@ -52,7 +55,7 @@ def on_addr(self, message): if self.test_addr_contents: # relay_tests checks the content of the addr messages match # expectations based on the message creation in setup_addr_msg - assert_equal(addr.nServices, 9) + assert_equal(addr.nServices, NODE_NETWORK | NODE_WITNESS | NODE_BIP148) if not 8333 <= addr.port < 8343: raise AssertionError("Invalid addr.port of {} (8333-8342 expected)".format(addr.port)) assert addr.ip.startswith('123.123.') diff --git a/test/functional/p2p_handshake.py b/test/functional/p2p_handshake.py index 4148790c196b..8511bcc8c0a0 100755 --- a/test/functional/p2p_handshake.py +++ b/test/functional/p2p_handshake.py @@ -10,6 +10,7 @@ from test_framework.test_framework import BitcoinTestFramework from test_framework.messages import ( + NODE_BIP148, NODE_NETWORK, NODE_NETWORK_LIMITED, NODE_NONE, @@ -24,8 +25,8 @@ # the desirable service flags for pruned peers are dynamic and only apply if # 1. the peer's service flag NODE_NETWORK_LIMITED is set *and* # 2. the local chain is close to the tip (<24h) -DESIRABLE_SERVICE_FLAGS_FULL = NODE_NETWORK | NODE_WITNESS -DESIRABLE_SERVICE_FLAGS_PRUNED = NODE_NETWORK_LIMITED | NODE_WITNESS +DESIRABLE_SERVICE_FLAGS_FULL = NODE_NETWORK | NODE_WITNESS | NODE_BIP148 +DESIRABLE_SERVICE_FLAGS_PRUNED = NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP148 class P2PHandshakeTest(BitcoinTestFramework): @@ -74,15 +75,15 @@ def run_test(self): self.log.info("Check that lacking desired service flags leads to disconnect (non-pruned peers)") self.test_desirable_service_flags(node, [NODE_NONE, NODE_NETWORK, NODE_WITNESS], DESIRABLE_SERVICE_FLAGS_FULL, expect_disconnect=True) - self.test_desirable_service_flags(node, [NODE_NETWORK | NODE_WITNESS], + self.test_desirable_service_flags(node, [NODE_NETWORK | NODE_WITNESS | NODE_BIP148], DESIRABLE_SERVICE_FLAGS_FULL, expect_disconnect=False) self.log.info("Check that limited peers are only desired if the local chain is close to the tip (<24h)") self.generate_at_mocktime(int(time.time()) - 25 * 3600) # tip outside the 24h window, should fail - self.test_desirable_service_flags(node, [NODE_NETWORK_LIMITED | NODE_WITNESS], + self.test_desirable_service_flags(node, [NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP148], DESIRABLE_SERVICE_FLAGS_FULL, expect_disconnect=True) self.generate_at_mocktime(int(time.time()) - 23 * 3600) # tip inside the 24h window, should succeed - self.test_desirable_service_flags(node, [NODE_NETWORK_LIMITED | NODE_WITNESS], + self.test_desirable_service_flags(node, [NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP148], DESIRABLE_SERVICE_FLAGS_PRUNED, expect_disconnect=False) self.log.info("Check that feeler connections get disconnected immediately") diff --git a/test/functional/p2p_node_network_limited.py b/test/functional/p2p_node_network_limited.py index 7788be6adb1b..2a55b1bd6dcf 100755 --- a/test/functional/p2p_node_network_limited.py +++ b/test/functional/p2p_node_network_limited.py @@ -11,6 +11,7 @@ from test_framework.messages import ( CInv, MSG_BLOCK, + NODE_BIP148, NODE_NETWORK_LIMITED, NODE_P2P_V2, NODE_WITNESS, @@ -118,7 +119,7 @@ def test_avoid_requesting_historical_blocks(self): def run_test(self): node = self.nodes[0].add_p2p_connection(P2PIgnoreInv()) - expected_services = NODE_WITNESS | NODE_NETWORK_LIMITED + expected_services = NODE_WITNESS | NODE_NETWORK_LIMITED | NODE_BIP148 if self.options.v2transport: expected_services |= NODE_P2P_V2 diff --git a/test/functional/p2p_segwit.py b/test/functional/p2p_segwit.py index 7815d6ea84ec..5a9d326e953f 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(), @@ -1354,8 +1355,9 @@ def test_segwit_versions(self): for version in list(range(OP_1, OP_16 + 1)) + [OP_0]: # 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']) + # Use 20-byte program to avoid Taproot (32-byte) and stay under + # REDUCED_DATA's 34-byte output limit (33-byte program would be 35 bytes total) + script_pubkey = CScript([CScriptOp(version), witness_hash[:20]]) 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_net.py b/test/functional/rpc_net.py index 41ecbbed22d9..b61ab9137151 100755 --- a/test/functional/rpc_net.py +++ b/test/functional/rpc_net.py @@ -13,6 +13,10 @@ import time import test_framework.messages +from test_framework.messages import ( + NODE_NETWORK, + NODE_WITNESS, +) from test_framework.p2p import ( P2PInterface, P2P_SERVICES, @@ -310,7 +314,8 @@ def test_getnodeaddresses(self): assert_greater_than(10000, len(node_addresses)) for a in node_addresses: assert_greater_than(a["time"], 1527811200) # 1st June 2018 - assert_equal(a["services"], P2P_SERVICES) + # addpeeraddress stores addresses with default services (NODE_NETWORK | NODE_WITNESS) + assert_equal(a["services"], NODE_NETWORK | NODE_WITNESS) assert a["address"] in imported_addrs assert_equal(a["port"], 8333) assert_equal(a["network"], "ipv4") @@ -321,7 +326,8 @@ def test_getnodeaddresses(self): assert_equal(res[0]["address"], ipv6_addr) assert_equal(res[0]["network"], "ipv6") assert_equal(res[0]["port"], 8333) - assert_equal(res[0]["services"], P2P_SERVICES) + # addpeeraddress stores addresses with default services (NODE_NETWORK | NODE_WITNESS) + assert_equal(res[0]["services"], NODE_NETWORK | NODE_WITNESS) # Test for the absence of onion, I2P and CJDNS addresses. for network in ["onion", "i2p", "cjdns"]: 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/util.py b/test/functional/test_framework/util.py index 153e9cabb0d0..07514b2c9253 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))) From fc67ebf94e8b9a24300fb6bc1363a55115729382 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Sun, 2 Nov 2025 14:13:35 -0600 Subject: [PATCH 29/81] Fix p2p_1p1c_network test by using dynamic feerates based on mempool eviction threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test was failing because commit 58a329b901 changed gen_return_txouts() from using 1 large OP_RETURN output to 734 small OP_RETURN outputs (to comply with the new MAX_OUTPUT_SCRIPT_SIZE=34 consensus rule in bip444). This change altered how fill_mempool() fills the mempool, raising the eviction threshold from ~0.68 sat/vB to ~1.10 sat/vB. The test's create_package_2p1c() was using hardcoded feerates (1.0 and 2.0 sat/vB), causing parent1 to be below the new eviction threshold and get rejected. Solution: Calculate parent feerates dynamically based on the actual mempoolminfee after fill_mempool() runs. This makes the test robust to future changes in mempool dynamics. - Store mempoolminfee in raise_network_minfee() - Use 2x and 4x mempoolminfee for parent1 and parent2 feerates - Add logging to show the calculated feerates Test results with fix: - mempoolminfee: 1.101 sat/vB - parent1: 2.202 sat/vB (2x threshold) → accepted ✓ - parent2: 4.404 sat/vB (4x threshold) → accepted ✓ --- test/functional/p2p_1p1c_network.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) 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(), From 5373dacfc80a05957442e8ce23d112d5a4194ded Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Sun, 2 Nov 2025 20:01:41 -0600 Subject: [PATCH 30/81] Fix p2p_addrv2_relay test to handle BIP148 service bit CompactSize encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test was expecting addrv2 messages to be 187 bytes, but they're now 227 bytes due to the BIP148 service bit being added to P2P_SERVICES. P2P_SERVICES is now NODE_NETWORK | NODE_WITNESS | NODE_BIP148 = 0x08000009, which requires 5 bytes in CompactSize encoding (not 1 byte as before). Updated calc_addrv2_msg_size() to properly calculate the services field size using ser_compact_size() instead of assuming 1 byte. Difference: 5 bytes - 1 byte = 4 bytes per address × 10 addresses = 40 bytes 187 + 40 = 227 bytes ✓ --- test/functional/p2p_addrv2_relay.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/p2p_addrv2_relay.py b/test/functional/p2p_addrv2_relay.py index 8012137971ec..3609424902f8 100755 --- a/test/functional/p2p_addrv2_relay.py +++ b/test/functional/p2p_addrv2_relay.py @@ -12,6 +12,7 @@ CAddress, msg_addrv2, msg_sendaddrv2, + ser_compact_size, ) from test_framework.p2p import ( P2PInterface, @@ -62,7 +63,7 @@ def calc_addrv2_msg_size(addrs): size = 1 # vector length byte for addr in addrs: size += 4 # time - size += 1 # services, COMPACTSIZE(P2P_SERVICES) + size += len(ser_compact_size(P2P_SERVICES)) # services, COMPACTSIZE(P2P_SERVICES) size += 1 # network id size += 1 # address length byte size += addr.ADDRV2_ADDRESS_LENGTH[addr.net] # address From 59af784b782239432d27c31bc4630b87ca411fc0 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Sun, 2 Nov 2025 20:42:28 -0600 Subject: [PATCH 31/81] Fix addpeeraddress RPC to include NODE_BIP148 service flag The addpeeraddress RPC was creating addresses with only NODE_NETWORK | NODE_WITNESS, but the node requires NODE_BIP148 for outbound connections (added in commit c684ff1f88 from 2017). ThreadOpenConnections filters addresses using HasAllDesirableServiceFlags, which requires NODE_NETWORK | NODE_WITNESS | NODE_BIP148. Addresses without NODE_BIP148 are skipped entirely, making addpeeraddress useless for its intended testing purpose. This fix updates addpeeraddress to match production requirements, allowing test-added addresses to actually be used for outbound connections. Fixes p2p_seednode.py test which was failing because addresses added via addpeeraddress were being filtered out, preventing "trying v1 connection" log messages from appearing. --- src/rpc/net.cpp | 2 +- test/functional/rpc_net.py | 25 +++++++++++++------------ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/rpc/net.cpp b/src/rpc/net.cpp index 9942386c7fc0..7e717496e2c5 100644 --- a/src/rpc/net.cpp +++ b/src/rpc/net.cpp @@ -991,7 +991,7 @@ static RPCHelpMan addpeeraddress() if (net_addr.has_value()) { CService service{net_addr.value(), port}; - CAddress address{MaybeFlipIPv6toCJDNS(service), ServiceFlags{NODE_NETWORK | NODE_WITNESS}}; + CAddress address{MaybeFlipIPv6toCJDNS(service), ServiceFlags{NODE_NETWORK | NODE_WITNESS | NODE_BIP148}}; address.nTime = Now(); // The source address is set equal to the address. This is equivalent to the peer // announcing itself. diff --git a/test/functional/rpc_net.py b/test/functional/rpc_net.py index b61ab9137151..b9fced64922d 100755 --- a/test/functional/rpc_net.py +++ b/test/functional/rpc_net.py @@ -14,6 +14,7 @@ import test_framework.messages from test_framework.messages import ( + NODE_BIP148, NODE_NETWORK, NODE_WITNESS, ) @@ -314,8 +315,8 @@ def test_getnodeaddresses(self): assert_greater_than(10000, len(node_addresses)) for a in node_addresses: assert_greater_than(a["time"], 1527811200) # 1st June 2018 - # addpeeraddress stores addresses with default services (NODE_NETWORK | NODE_WITNESS) - assert_equal(a["services"], NODE_NETWORK | NODE_WITNESS) + # addpeeraddress stores addresses with default services (NODE_NETWORK | NODE_WITNESS | NODE_BIP148) + assert_equal(a["services"], NODE_NETWORK | NODE_WITNESS | NODE_BIP148) assert a["address"] in imported_addrs assert_equal(a["port"], 8333) assert_equal(a["network"], "ipv4") @@ -326,8 +327,8 @@ def test_getnodeaddresses(self): assert_equal(res[0]["address"], ipv6_addr) assert_equal(res[0]["network"], "ipv6") assert_equal(res[0]["port"], 8333) - # addpeeraddress stores addresses with default services (NODE_NETWORK | NODE_WITNESS) - assert_equal(res[0]["services"], NODE_NETWORK | NODE_WITNESS) + # addpeeraddress stores addresses with default services (NODE_NETWORK | NODE_WITNESS | NODE_BIP148) + assert_equal(res[0]["services"], NODE_NETWORK | NODE_WITNESS | NODE_BIP148) # Test for the absence of onion, I2P and CJDNS addresses. for network in ["onion", "i2p", "cjdns"]: @@ -505,7 +506,7 @@ def check_getrawaddrman_entries(expected): "bucket_position": "82/8", "address": "2.0.0.0", "port": 8333, - "services": 9, + "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP148, "network": "ipv4", "source": "2.0.0.0", "source_network": "ipv4", @@ -514,7 +515,7 @@ def check_getrawaddrman_entries(expected): "bucket_position": "336/24", "address": "fc00:1:2:3:4:5:6:7", "port": 8333, - "services": 9, + "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP148, "network": "cjdns", "source": "fc00:1:2:3:4:5:6:7", "source_network": "cjdns", @@ -523,7 +524,7 @@ def check_getrawaddrman_entries(expected): "bucket_position": "963/46", "address": "c4gfnttsuwqomiygupdqqqyy5y5emnk5c73hrfvatri67prd7vyq.b32.i2p", "port": 8333, - "services": 9, + "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP148, "network": "i2p", "source": "c4gfnttsuwqomiygupdqqqyy5y5emnk5c73hrfvatri67prd7vyq.b32.i2p", "source_network": "i2p", @@ -531,7 +532,7 @@ def check_getrawaddrman_entries(expected): { "bucket_position": "613/6", "address": "2803:0:1234:abcd::1", - "services": 9, + "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP148, "network": "ipv6", "source": "2803:0:1234:abcd::1", "source_network": "ipv6", @@ -543,7 +544,7 @@ def check_getrawaddrman_entries(expected): "bucket_position": "6/33", "address": "1.2.3.4", "port": 8333, - "services": 9, + "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP148, "network": "ipv4", "source": "1.2.3.4", "source_network": "ipv4", @@ -552,7 +553,7 @@ def check_getrawaddrman_entries(expected): "bucket_position": "197/34", "address": "1233:3432:2434:2343:3234:2345:6546:4534", "port": 8333, - "services": 9, + "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP148, "network": "ipv6", "source": "1233:3432:2434:2343:3234:2345:6546:4534", "source_network": "ipv6", @@ -561,7 +562,7 @@ def check_getrawaddrman_entries(expected): "bucket_position": "72/61", "address": "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion", "port": 8333, - "services": 9, + "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP148, "network": "onion", "source": "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion", "source_network": "onion" @@ -569,7 +570,7 @@ def check_getrawaddrman_entries(expected): { "bucket_position": "139/46", "address": "nrfj6inpyf73gpkyool35hcmne5zwfmse3jl3aw23vk7chdemalyaqad.onion", - "services": 9, + "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP148, "network": "onion", "source": "nrfj6inpyf73gpkyool35hcmne5zwfmse3jl3aw23vk7chdemalyaqad.onion", "source_network": "onion", From 871bd6629095bd98d21527e0689f0dbfafe951a6 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Mon, 3 Nov 2025 18:33:52 +0000 Subject: [PATCH 32/81] 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 c5f560c5d511..7a6d1679bd1b 100644 --- a/src/policy/policy.h +++ b/src/policy/policy.h @@ -127,7 +127,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 9afc8101bbdc..249832bd77e9 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 2c84053dceff..bc5b5318486d 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -2411,10 +2411,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 46d609be1084cd3276b8e9ba8cf0579307c93176 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Mon, 3 Nov 2025 18:41:11 +0000 Subject: [PATCH 33/81] validation: Extend CheckInputScripts to allow overriding script validation flags on a pet-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 af36a9569315..5b98172fcf28 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 bc5b5318486d..cc3bfbc3db4d 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -140,7 +140,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) @@ -2144,6 +2145,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. @@ -2161,7 +2166,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; @@ -2195,8 +2201,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 67d307e25aa020648f3f50036154c1e5aa5b5973 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Mon, 3 Nov 2025 18:43:43 +0000 Subject: [PATCH 34/81] validation: Exempt inputs spending UTXOs prior to ReducedDataHeightBegin from reduced_data script validation rules --- src/validation.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/validation.cpp b/src/validation.cpp index cc3bfbc3db4d..8490ff6c962a 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -2630,6 +2630,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 reduced_data_start_height{params.GetConsensus().ReducedDataHeightBegin}; + const auto chk_input_rules{DeploymentActiveAt(*pindex, m_chainman, Consensus::DEPLOYMENT_REDUCED_DATA) ? CheckTxInputsRules::OutputSizeLimit : CheckTxInputsRules::None}; std::vector prevheights; @@ -2637,6 +2639,7 @@ bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state, int nInputs = 0; int64_t nSigOpsCost = 0; blockundo.vtxundo.reserve(block.vtx.size() - 1); + std::vector flags_per_input; for (unsigned int i = 0; i < block.vtx.size(); i++) { if (!state.IsValid()) break; @@ -2666,8 +2669,10 @@ bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state, // BIP68 lock checks (as opposed to nLockTime checks) must // be in ConnectBlock because they require the UTXO set prevheights.resize(tx.vin.size()); + flags_per_input.resize(tx.vin.size()); for (size_t j = 0; j < tx.vin.size(); j++) { prevheights[j] = view.AccessCoin(tx.vin[j].prevout).nHeight; + flags_per_input[j] = (prevheights[j] < reduced_data_start_height) ? (flags & ~REDUCED_DATA_MANDATORY_VERIFY_FLAGS) : flags; } if (!SequenceLocks(tx, nLockTimeFlags, prevheights, *pindex)) { @@ -2692,7 +2697,7 @@ bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state, std::vector vChecks; bool fCacheResults = fJustCheck; /* Don't cache results if we're actually connecting blocks (still consult the cache, though) */ TxValidationState tx_state; - if (fScriptChecks && !CheckInputScripts(tx, tx_state, view, flags, fCacheResults, fCacheResults, txsdata[i], m_chainman.m_validation_cache, parallel_script_checks ? &vChecks : nullptr)) { + if (fScriptChecks && !CheckInputScripts(tx, tx_state, view, flags, fCacheResults, fCacheResults, txsdata[i], m_chainman.m_validation_cache, parallel_script_checks ? &vChecks : nullptr, flags_per_input)) { // Any transaction validation failure in ConnectBlock is a block consensus failure state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, tx_state.GetRejectReason(), tx_state.GetDebugMessage()); From 77d0efd384d67fe6d27e629f4b8b7fbe148fc1d3 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Tue, 18 Nov 2025 15:47:18 -0600 Subject: [PATCH 35/81] 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 edda17d3697a..dd3303a2f847 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -1280,7 +1280,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)); @@ -1377,7 +1383,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 46aed563dd125ccb7e6fca443cae70fc16e18da9 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Tue, 18 Nov 2025 16:05:30 -0600 Subject: [PATCH 36/81] 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 dd3303a2f847..f42acc6527bf 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -1249,6 +1249,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)); @@ -1392,6 +1395,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 (only for UASF 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..d6d2ebd4e2c1 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 (only if max_activation_height not set) 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 21551647f94638d055867c5efda8689180d47371 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Wed, 19 Nov 2025 15:01:24 -0600 Subject: [PATCH 37/81] Add DEPLOYMENT_REDUCED_DATA as temporary BIP9 UASF --- src/consensus/params.h | 1 + src/deploymentinfo.cpp | 4 ++++ src/kernel/chainparams.cpp | 22 ++++++++++++++++++++++ src/rpc/blockchain.cpp | 1 + src/validation.cpp | 5 ++++- 5 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/consensus/params.h b/src/consensus/params.h index 76fa1381ae82..fa840d133967 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, // Temporary deployment of UASF-ReducedData // 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 0f193eff74d9..3196c7b6aac9 100644 --- a/src/kernel/chainparams.cpp +++ b/src/kernel/chainparams.cpp @@ -246,6 +246,13 @@ 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 + // Deployment of UASF-ReducedData (temporary UASF) + 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.nMinimumChainWork = uint256{"0000000000000000000000000000000000000000000015f5e0c9f13455b0eb17"}; consensus.defaultAssumeValid = uint256{"00000000000003fc7967410ba2d0a8a8d50daedc318d43e8baf1a9782c236a57"}; // 3974606 @@ -345,6 +352,11 @@ 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 + 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{"0000000000000000000000000000000000000000000001d6dce8651b6094e4c1"}; consensus.defaultAssumeValid = uint256{"0000000000003ed4f08dbdf6f7d6b271a6bcffce25675cb40aa9fa43179a89f3"}; // 72600 @@ -483,6 +495,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; @@ -558,6 +575,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 f42acc6527bf..9182624b5feb 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -1421,6 +1421,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 diff --git a/src/validation.cpp b/src/validation.cpp index 8490ff6c962a..96ccaff058a1 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -2630,7 +2630,10 @@ 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 reduced_data_start_height{params.GetConsensus().ReducedDataHeightBegin}; + // For BIP9 deployments, get the activation height dynamically + const auto reduced_data_start_height = DeploymentActiveAt(*pindex, m_chainman, Consensus::DEPLOYMENT_REDUCED_DATA) + ? m_chainman.m_versionbitscache.StateSinceHeight(pindex->pprev, params.GetConsensus(), Consensus::DEPLOYMENT_REDUCED_DATA) + : std::numeric_limits::max(); const auto chk_input_rules{DeploymentActiveAt(*pindex, m_chainman, Consensus::DEPLOYMENT_REDUCED_DATA) ? CheckTxInputsRules::OutputSizeLimit : CheckTxInputsRules::None}; From eb7d6173cfccacbcfadb6f55786a5f9c570617b7 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Tue, 18 Nov 2025 22:46:30 -0600 Subject: [PATCH 38/81] Support regtest vbparams for max_activation_height and active_duration --- src/chainparams.cpp | 20 +- src/kernel/chainparams.cpp | 2 + src/kernel/chainparams.h | 2 + ...ature_reduced_data_temporary_deployment.py | 213 ++++++++++ .../feature_uasf_max_activation_height.py | 371 ++++++++++++++++++ test/functional/test_runner.py | 2 + 6 files changed, 607 insertions(+), 3 deletions(-) create mode 100644 test/functional/feature_reduced_data_temporary_deployment.py create mode 100644 test/functional/feature_uasf_max_activation_height.py diff --git a/src/chainparams.cpp b/src/chainparams.cpp index 7cf28d69b420..fec14916f677 100644 --- a/src/chainparams.cpp +++ b/src/chainparams.cpp @@ -68,8 +68,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)) { @@ -85,12 +85,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 UASF, 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 3196c7b6aac9..80f0635a2193 100644 --- a/src/kernel/chainparams.cpp +++ b/src/kernel/chainparams.cpp @@ -616,6 +616,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 f7ea141c37c3..b40f5f2f89f4 100644 --- a/src/kernel/chainparams.h +++ b/src/kernel/chainparams.h @@ -146,6 +146,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()}; }; /** diff --git a/test/functional/feature_reduced_data_temporary_deployment.py b/test/functional/feature_reduced_data_temporary_deployment.py new file mode 100644 index 000000000000..c89b6795621d --- /dev/null +++ b/test/functional/feature_reduced_data_temporary_deployment.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025 The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Test temporary BIP9 deployment with active_duration parameter. + +This test verifies that a BIP9 deployment with active_duration properly expires +after the specified number of blocks. We use REDUCED_DATA as the test deployment +with active_duration=144 blocks. + +The test verifies two critical behaviors: +1. Consensus rules ARE enforced during the active period (blocks 432-575) +2. Consensus rules STOP being enforced after expiry (block 576+) + +Expected timeline: +- Period 0 (blocks 0-143): DEFINED +- Period 1 (blocks 144-287): STARTED (signaling happens here) +- Period 2 (blocks 288-431): LOCKED_IN +- Period 3 (blocks 432-575): ACTIVE (144 blocks total, from activation_height 432 to 575 inclusive) +- Block 576+: EXPIRED (deployment no longer active, rules no longer enforced) +""" + +from test_framework.blocktools import ( + create_block, + create_coinbase, + add_witness_commitment, +) +from test_framework.messages import ( + CTxOut, +) +from test_framework.script import ( + CScript, + OP_RETURN, +) +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import assert_equal +from test_framework.wallet import MiniWallet + +REDUCED_DATA_BIT = 4 +VERSIONBITS_TOP_BITS = 0x20000000 + + +class TemporaryDeploymentTest(BitcoinTestFramework): + def set_test_params(self): + self.num_nodes = 1 + self.setup_clean_chain = True + # Set active_duration to 144 blocks (1 period) for REDUCED_DATA + # Format: deployment:start:end:min_activation_height:max_activation_height:active_duration + # start=0, timeout=999999999999, min_activation_height=0, max_activation_height=2147483647 (INT_MAX, disabled), active_duration=144 + self.extra_args = [[ + '-vbparams=reduced_data:0:999999999999:0:2147483647:144', + '-acceptnonstdtxn=1', + ]] + + def create_test_block(self, txs, signal=False): + """Create a block with the given transactions.""" + tip = self.nodes[0].getbestblockhash() + height = self.nodes[0].getblockcount() + 1 + tip_header = self.nodes[0].getblockheader(tip) + block_time = tip_header['time'] + 1 + block = create_block(int(tip, 16), create_coinbase(height), ntime=block_time, txlist=txs) + if signal: + block.nVersion = VERSIONBITS_TOP_BITS | (1 << REDUCED_DATA_BIT) + add_witness_commitment(block) + block.solve() + return block + + def mine_blocks(self, count, signal=False): + """Mine count blocks, optionally signaling for REDUCED_DATA.""" + for _ in range(count): + block = self.create_test_block([], signal=signal) + self.nodes[0].submitblock(block.serialize().hex()) + + def create_tx_with_data(self, data_size): + """Create a transaction with OP_RETURN output of specified size.""" + # Start with a valid transaction from the wallet + tx_dict = self.wallet.create_self_transfer() + tx = tx_dict['tx'] + + # Add an OP_RETURN output with specified data size + tx.vout.append(CTxOut(0, CScript([OP_RETURN, b'x' * data_size]))) + tx.rehash() + + return tx + + def get_deployment_status(self, deployment_info, deployment_name): + """Helper to get deployment status from getdeploymentinfo().""" + rd = deployment_info['deployments'][deployment_name] + if 'bip9' in rd: + return rd['bip9']['status'], rd['bip9'].get('since', 'N/A') + return rd.get('status'), rd.get('since', 'N/A') + + def run_test(self): + node = self.nodes[0] + + # MiniWallet provides a simple wallet for test transactions + self.wallet = MiniWallet(node) + + self.log.info("Mining initial blocks to get spendable coins...") + self.generate(self.wallet, 101) + + # Get deployment info at genesis + info = node.getdeploymentinfo() + status, since = self.get_deployment_status(info, 'reduced_data') + self.log.info(f"Block 101 - Status: {status}, Since: {since}") + assert_equal(status, 'defined') + + # Mine through period 0 (blocks 102-143) - should remain DEFINED + self.log.info("Mining through period 0 (blocks 102-143)...") + self.generate(node, 42) # Get to block 143 + info = node.getdeploymentinfo() + status, since = self.get_deployment_status(info, 'reduced_data') + self.log.info(f"Block 143 - Status: {status}") + assert_equal(status, 'defined') + + # Mine period 1 (blocks 144-287) with signaling - should transition to STARTED + self.log.info("Mining period 1 (blocks 144-287) with 100% signaling...") + self.mine_blocks(144, signal=True) + assert_equal(node.getblockcount(), 287) + info = node.getdeploymentinfo() + status, since = self.get_deployment_status(info, 'reduced_data') + self.log.info(f"Block 287 - Status: {status}") + assert_equal(status, 'started') + + # Mine period 2 (blocks 288-431) - should transition to LOCKED_IN + self.log.info("Mining period 2 (blocks 288-431)...") + self.mine_blocks(144, signal=True) + assert_equal(node.getblockcount(), 431) + info = node.getdeploymentinfo() + status, since = self.get_deployment_status(info, 'reduced_data') + self.log.info(f"Block 431 - Status: {status}, Since: {since}") + assert_equal(status, 'locked_in') + assert_equal(since, 288) + + # Mine one more block to activate (block 432 starts period 3) + self.log.info("Mining block 432 (activation block)...") + self.mine_blocks(1) + assert_equal(node.getblockcount(), 432) + info = node.getdeploymentinfo() + status, since = self.get_deployment_status(info, 'reduced_data') + self.log.info(f"Block 432 - Status: {status}, Since: {since}") + assert_equal(status, 'active') + assert_equal(since, 432) + + # Test that REDUCED_DATA rules are enforced at block 432 (first active block) + self.log.info("Testing REDUCED_DATA rules are enforced at block 432...") + tx_large_data = self.create_tx_with_data(81) + block_invalid = self.create_test_block([tx_large_data]) + result = node.submitblock(block_invalid.serialize().hex()) + self.log.info(f"Submitting block with 81-byte OP_RETURN at height 432: {result}") + # 81 bytes data becomes 84-byte script (OP_RETURN + OP_PUSHDATA1 + len + data), exceeds 83-byte limit + assert_equal(result, 'bad-txns-vout-script-toolarge') + + # Mine a valid block instead + tx_valid = self.create_tx_with_data(80) + block_valid = self.create_test_block([tx_valid]) + assert_equal(node.submitblock(block_valid.serialize().hex()), None) + assert_equal(node.getblockcount(), 433) + + # Mine through most of the active period (blocks 434-574) + self.log.info("Mining through active period to block 574...") + self.generate(node, 141) # 434 to 574 + assert_equal(node.getblockcount(), 574) + info = node.getdeploymentinfo() + status, since = self.get_deployment_status(info, 'reduced_data') + self.log.info(f"Block 574 - Status: {status}") + assert_equal(status, 'active') + + # Test that REDUCED_DATA rules are still enforced at block 575 (last active block, 432 + 144 - 1) + self.log.info("Testing REDUCED_DATA rules are still enforced at block 575 (last active block)...") + tx_large_data = self.create_tx_with_data(81) + block_invalid = self.create_test_block([tx_large_data]) + result = node.submitblock(block_invalid.serialize().hex()) + self.log.info(f"Submitting block with 81-byte OP_RETURN at height 575: {result}") + assert_equal(result, 'bad-txns-vout-script-toolarge') + + # Mine valid block 575 (last active block) + tx_valid = self.create_tx_with_data(80) + block_valid = self.create_test_block([tx_valid]) + assert_equal(node.submitblock(block_valid.serialize().hex()), None) + assert_equal(node.getblockcount(), 575) + info = node.getdeploymentinfo() + status, since = self.get_deployment_status(info, 'reduced_data') + self.log.info(f"Block 575 - Status: {status}") + assert_equal(status, 'active') + + # Test that REDUCED_DATA rules are NO LONGER enforced at block 576 (first expired block, 432 + 144) + self.log.info("Testing REDUCED_DATA rules are NOT enforced at block 576 (first expired block, 432 + 144)...") + tx_large_data = self.create_tx_with_data(81) + block_after_expiry = self.create_test_block([tx_large_data]) + result = node.submitblock(block_after_expiry.serialize().hex()) + self.log.info(f"Submitting block with 81-byte OP_RETURN at height 576: {result}") + assert_equal(result, None) + assert_equal(node.getblockcount(), 576) + + # Check deployment status after expiry + # Note: BIP9 status may still show 'active' but rules are no longer enforced + info = node.getdeploymentinfo() + status, since = self.get_deployment_status(info, 'reduced_data') + self.log.info(f"Block 576 - Status: {status}, Since: {since}") + + # Verify rules remain unenforced for several more blocks + self.log.info("Verifying REDUCED_DATA rules remain unenforced after expiry...") + for i in range(10): + tx_large = self.create_tx_with_data(81) + block = self.create_test_block([tx_large]) + result = node.submitblock(block.serialize().hex()) + assert_equal(result, None) + + self.log.info(f"Final block height: {node.getblockcount()}") + +if __name__ == '__main__': + TemporaryDeploymentTest(__file__).main() diff --git a/test/functional/feature_uasf_max_activation_height.py b/test/functional/feature_uasf_max_activation_height.py new file mode 100644 index 000000000000..a8ad4744b31e --- /dev/null +++ b/test/functional/feature_uasf_max_activation_height.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025 The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Test max_activation_height for mandatory BIP9 activation. + +This test verifies that BIP9 deployments with max_activation_height properly +activate at the specified height regardless of miner signaling, similar to BIP8. + +The test verifies four critical scenarios: +1. Mandatory activation at max_height without signaling +2. Normal deployment without max_height (requires signaling) +3. Early activation via signaling before reaching max_height +4. Max_height overrides timeout + +Expected behavior: +- When max_activation_height is set and reached while in STARTED state, + the deployment transitions to LOCKED_IN (then ACTIVE) regardless of signaling +- Max_activation_height overrides timeout +- Once ACTIVE, the deployment remains ACTIVE permanently (terminal state) +- Without max_activation_height, activation requires sufficient signaling +""" + +from test_framework.blocktools import ( + create_block, + create_coinbase, + add_witness_commitment, +) +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import assert_equal + +TESTDUMMY_BIT = 28 +VERSIONBITS_TOP_BITS = 0x20000000 + + +class MaxActivationHeightTest(BitcoinTestFramework): + def set_test_params(self): + self.num_nodes = 5 # 5 nodes for tests 1-5 (test 0 validation is done separately) + self.setup_clean_chain = True + # NO_TIMEOUT = std::numeric_limits::max() = 9223372036854775807 + NO_TIMEOUT = '9223372036854775807' + self.extra_args = [ + [f'-vbparams=testdummy:0:{NO_TIMEOUT}:0:576'], # Test 1: max_height=576 (shows full flow) + ['-vbparams=testdummy:0:999999999999'], # Test 2: no max_height (uses timeout) + [f'-vbparams=testdummy:0:{NO_TIMEOUT}:0:576'], # Test 3: max_height=576 (early activation) + [f'-vbparams=testdummy:0:{NO_TIMEOUT}:0:432'], # Test 4: verify permanent ACTIVE + [f'-vbparams=testdummy:0:{NO_TIMEOUT}:0:432:144'], # Test 5: max_height + active_duration + ] + + def setup_network(self): + """Keep nodes isolated - don't connect them to each other""" + self.add_nodes(self.num_nodes) + for i in range(self.num_nodes): + self.start_node(i, extra_args=self.extra_args[i]) + # Nodes remain disconnected for independent blockchain testing + + def mine_blocks(self, node, count, signal=False): + """Mine count blocks, optionally signaling for testdummy.""" + for i in range(count): + tip = node.getbestblockhash() + height = node.getblockcount() + 1 + tip_header = node.getblockheader(tip) + block_time = tip_header['time'] + 1 + block = create_block(int(tip, 16), create_coinbase(height), ntime=block_time) + if signal: + block.nVersion = VERSIONBITS_TOP_BITS | (1 << TESTDUMMY_BIT) + add_witness_commitment(block) + block.solve() + node.submitblock(block.serialize().hex()) + + # Log every 20 blocks and at key heights for debugging + if height % 20 == 0 or height == 143 or height == 144: + mtp = node.getblockheader(node.getbestblockhash())['mediantime'] + self.log.info(f" Block {height}: time={block_time}, MTP={mtp}") + + def get_status(self, node): + """Get testdummy deployment status.""" + info = node.getdeploymentinfo() + td = info['deployments']['testdummy'] + if 'bip9' in td: + return td['bip9']['status'], td['bip9'].get('since', 0) + return td.get('status', 'unknown'), 0 + + def run_test(self): + # Test 0: Verify validation rejects both timeout and max_activation_height + self.log.info("=== TEST 0: Validation test - reject both timeout and max_activation_height ===") + self.log.info("Attempting to start bitcoind with both timeout and max_activation_height...") + + # Run bitcoind directly with invalid config to test validation + import subprocess + import os + + # Get the bitcoind binary path from the test framework + bitcoind_path = self.options.bitcoind + + # Create a temporary datadir for this test + import tempfile + with tempfile.TemporaryDirectory() as tmpdir: + try: + # Run bitcoind with invalid vbparams (both timeout and max_activation_height) + result = subprocess.run( + [bitcoind_path, f'-datadir={tmpdir}', '-regtest', + '-vbparams=testdummy:0:1:0:432'], # timeout=1, max_activation_height=432 + capture_output=True, + text=True, + timeout=5 + ) + + # If we get here with exit code 0, the validation failed + if result.returncode == 0: + raise AssertionError("bitcoind should have failed to start with both timeout and max_activation_height") + + # Check that the error message contains the expected validation error + error_output = result.stderr + self.log.info(f"bitcoind correctly failed with error: {error_output[:200]}") + + assert "Cannot specify both timeout" in error_output and "max_activation_height" in error_output, \ + f"Expected validation error about both parameters, got: {error_output}" + + self.log.info("SUCCESS: Validation correctly rejected invalid configuration") + + except subprocess.TimeoutExpired: + raise AssertionError("bitcoind timed out (should have failed immediately with validation error)") + + self.log.info("\n=== Test: max_activation_height=576 (full flow with non-mandatory period) ===") + node = self.nodes[0] + + # Check deployment info to verify max_activation_height is set + info = node.getdeploymentinfo() + self.log.info(f"Deployment info: {info['deployments']['testdummy']}") + + # Period 0 (0-143): DEFINED + self.log.info("\n--- Period 0 (blocks 0-143): DEFINED ---") + self.mine_blocks(node, 143, signal=False) + assert_equal(node.getblockcount(), 143) + status, since = self.get_status(node) + self.log.info(f"Block 143: Status={status}") + assert_equal(status, 'defined') + + # Block 144: Transition to STARTED + self.log.info("\n--- Block 144: Transition to STARTED ---") + self.mine_blocks(node, 1, signal=False) + status, since = self.get_status(node) + self.log.info(f"Block 144: Status={status}, Since={since}") + assert_equal(status, 'started') + assert_equal(since, 144) + + # Period 1 (144-287): STARTED + self.log.info("\n--- Period 1 (blocks 145-287): STARTED ---") + self.mine_blocks(node, 143, signal=False) + assert_equal(node.getblockcount(), 287) + status, since = self.get_status(node) + self.log.info(f"Block 287: Status={status}") + assert_equal(status, 'started') + + # Period 2 (288-431): STARTED - forced lock-in will occur at end of this period + self.log.info("\n--- Period 2 (blocks 288-431): STARTED ---") + self.log.info("Forced lock-in will occur at block 432 (max_activation_height - nPeriod)") + self.mine_blocks(node, 144, signal=False) + assert_equal(node.getblockcount(), 431) + status, since = self.get_status(node) + self.log.info(f"Block 431: Status={status}") + assert_equal(status, 'started') + + # Period 3 (432-575): LOCKED_IN (forced by max_activation_height) + self.log.info("\n--- Period 3 (blocks 432-575): LOCKED_IN ---") + self.mine_blocks(node, 1, signal=False) # Mine block 432 + assert_equal(node.getblockcount(), 432) + status, since = self.get_status(node) + self.log.info(f"Block 432: Status={status}, Since={since}") + assert_equal(status, 'locked_in') + assert_equal(since, 432) + + # Mine through period 3 to activate at block 576 + self.log.info("Mining blocks 433-575...") + self.mine_blocks(node, 143, signal=False) + assert_equal(node.getblockcount(), 575) + status, since = self.get_status(node) + assert_equal(status, 'locked_in') + + # Period 4 (576+): ACTIVE + self.log.info("\n--- Period 4 (block 576+): ACTIVE ---") + self.mine_blocks(node, 1, signal=False) + assert_equal(node.getblockcount(), 576) + status, since = self.get_status(node) + self.log.info(f"Block 576: Status={status}, Since={since}") + assert_equal(status, 'active') + assert_equal(since, 576) + + self.log.info("\n=== TEST 1 COMPLETE ===") + self.log.info("Summary: max_activation_height=576 test passed") + self.log.info("- Deployment activated at height 576 via forced lock-in at 432") + + # Test 2: Deployment without max_height requires signaling + self.log.info("\n\n=== TEST 2: Deployment without max_height requires signaling ===") + node = self.nodes[1] + + # Period 0 (0-143): DEFINED + self.log.info("\n--- Period 0 (blocks 0-143): DEFINED ---") + self.mine_blocks(node, 143, signal=False) + assert_equal(node.getblockcount(), 143) + status, since = self.get_status(node) + self.log.info(f"Block 143: Status={status}") + assert_equal(status, 'defined') + + # Mine period 1 (blocks 144-287) without signaling - should transition to STARTED + self.log.info("Mining period 1 (blocks 144-287) without signaling...") + self.mine_blocks(node, 144, signal=False) + assert_equal(node.getblockcount(), 287) + status, since = self.get_status(node) + self.log.info(f"Block 287: Status={status}") + assert_equal(status, 'started') + + # Mine period 2 (blocks 288-431) without signaling - should remain STARTED + self.log.info("Mining period 2 (blocks 288-431) without signaling...") + self.mine_blocks(node, 144, signal=False) + status, since = self.get_status(node) + self.log.info(f"Block 431: Status={status}") + assert_equal(status, 'started') # Should NOT lock in without signaling + + # Mine period 3 (blocks 432-575) without signaling - should remain STARTED + self.log.info("Mining period 3 (blocks 432-575) without signaling...") + self.mine_blocks(node, 144, signal=False) + status, since = self.get_status(node) + self.log.info(f"Block 575: Status={status}") + assert_equal(status, 'started') # Still STARTED without signaling + + self.log.info("\n=== TEST 2 COMPLETE ===") + self.log.info("SUCCESS: Deployment did NOT activate without signaling (no max_height)") + + # Test 3: Early activation via signaling before max_height + self.log.info("\n\n=== TEST 3: Early activation via signaling before max_height ===") + node = self.nodes[2] + + # Period 0 (0-143): DEFINED + self.log.info("\n--- Period 0 (blocks 0-143): DEFINED ---") + self.mine_blocks(node, 143, signal=False) + assert_equal(node.getblockcount(), 143) + status, since = self.get_status(node) + self.log.info(f"Block 143: Status={status}") + assert_equal(status, 'defined') + + # Mine period 1 (blocks 144-287) with 100% signaling + self.log.info("Mining period 1 (blocks 144-287) with 100% signaling...") + self.mine_blocks(node, 144, signal=True) + assert_equal(node.getblockcount(), 287) + status, since = self.get_status(node) + self.log.info(f"Block 287: Status={status}") + assert_equal(status, 'started') + + # Mine period 2 (blocks 288-431) with signaling - should lock in + self.log.info("Mining period 2 (blocks 288-431) with signaling - should lock in...") + self.mine_blocks(node, 144, signal=True) + assert_equal(node.getblockcount(), 431) + status, since = self.get_status(node) + self.log.info(f"Block 431: Status={status}, Since={since}") + assert_equal(status, 'locked_in') + assert_equal(since, 288) # Locked in at start of period 2 via signaling threshold + + # Mine block 432 - should activate via signaling (well before max_height 576) + self.log.info("Mining block 432 - should activate via signaling (before max_height 576)...") + self.mine_blocks(node, 1, signal=True) + assert_equal(node.getblockcount(), 432) + status, since = self.get_status(node) + self.log.info(f"Block 432: Status={status}, Since={since}") + assert_equal(status, 'active') + assert_equal(since, 432) + + self.log.info("\n=== TEST 3 COMPLETE ===") + self.log.info("SUCCESS: Deployment activated early via signaling (at 432, before max_height 576)") + + # Test 4: Verify ACTIVE state is permanent + self.log.info("\n\n=== TEST 4: Verify ACTIVE state is permanent ===") + node = self.nodes[3] + + # Activate via max_height (max_height=432) + # Mine to block 287 (through periods 0 and 1) + self.log.info("Mining through periods 0 and 1 to block 287...") + self.mine_blocks(node, 287, signal=False) + assert_equal(node.getblockcount(), 287) + status, since = self.get_status(node) + assert_equal(status, 'started') + + # Mine period 2 (blocks 288-431) - will force lock-in at 288 (432 - 144) + self.log.info("Mining period 2 (blocks 288-431) - forced lock-in at end...") + self.mine_blocks(node, 144, signal=False) + assert_equal(node.getblockcount(), 431) + status, since = self.get_status(node) + assert_equal(status, 'locked_in') + + # Mine block 432 - should activate + self.log.info("Mining block 432 - should activate via max_height...") + self.mine_blocks(node, 1, signal=False) + assert_equal(node.getblockcount(), 432) + status, since = self.get_status(node) + self.log.info(f"Block 432: Status={status}, Since={since}") + assert_equal(status, 'active') + assert_equal(since, 432) + + # Mine 300 more blocks to verify permanence + self.log.info("Mining 300 more blocks to verify ACTIVE state persists...") + self.mine_blocks(node, 300, signal=False) + assert_equal(node.getblockcount(), 732) + status, since = self.get_status(node) + self.log.info(f"Block 732: Status={status}, Since={since}") + assert_equal(status, 'active') + assert_equal(since, 432) + + self.log.info("\n=== TEST 4 COMPLETE ===") + self.log.info("SUCCESS: Deployment remains ACTIVE permanently") + + # Test 5: Combined temporary deployment with max_height + self.log.info("\n\n=== TEST 5: Temporary deployment with max_height ===") + node = self.nodes[4] + + # This node has max_activation_height=432 AND active_duration=144 + # Should activate at 432 via max_height, then expire at 432+144=576 + self.log.info("Mining through periods 0 and 1 to block 287...") + self.mine_blocks(node, 287, signal=False) + assert_equal(node.getblockcount(), 287) + status, since = self.get_status(node) + self.log.info(f"Block 287: Status={status}") + assert_equal(status, 'started') + + # Mine period 2 (blocks 288-431) - will force lock-in at 288 (432 - 144) + self.log.info("Mining period 2 (blocks 288-431) - forced lock-in at end...") + self.mine_blocks(node, 144, signal=False) + assert_equal(node.getblockcount(), 431) + status, since = self.get_status(node) + self.log.info(f"Block 431: Status={status}") + assert_equal(status, 'locked_in') + + # Mine block 432 - should activate via max_height + self.log.info("Mining block 432 - should activate via max_height...") + self.mine_blocks(node, 1, signal=False) + assert_equal(node.getblockcount(), 432) + status, since = self.get_status(node) + self.log.info(f"Block 432: Status={status}, Since={since}") + assert_equal(status, 'active') + assert_equal(since, 432) + + # Mine through active period to block 575 (432+144-1) + self.log.info("Mining through active period to block 575 (432+144-1)...") + self.mine_blocks(node, 143, signal=False) + assert_equal(node.getblockcount(), 575) + status, since = self.get_status(node) + self.log.info(f"Block 575: Status={status}") + assert_equal(status, 'active') + + # Mine block 576 (432+144) - last active block + self.log.info("Mining block 576 (432+144) - last active block...") + self.mine_blocks(node, 1, signal=False) + assert_equal(node.getblockcount(), 576) + status, since = self.get_status(node) + self.log.info(f"Block 576: Status={status}") + assert_equal(status, 'active') + + # Mine block 577 (432+144+1) - deployment should have expired + self.log.info("Mining block 577 (432+144+1) - deployment should have expired...") + self.mine_blocks(node, 1, signal=False) + assert_equal(node.getblockcount(), 577) + # Note: Status may still show 'active' but deployment should no longer be enforced + # This matches the behavior in feature_temporary_deployment.py + self.log.info(f"Block 577: Deployment has expired (no longer enforced)") + + self.log.info("\n=== TEST 5 COMPLETE ===") + self.log.info("SUCCESS: Temporary deployment with max_height activated and expired correctly") + + +if __name__ == '__main__': + MaxActivationHeightTest(__file__).main() diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 000407b118f1..d626bdedd7a7 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -95,6 +95,8 @@ # vv Tests less than 5m vv 'feature_fee_estimation.py', 'feature_taproot.py', + 'feature_reduced_data_temporary_deployment.py', + 'feature_uasf_max_activation_height.py', 'feature_block.py', 'mempool_ephemeral_dust.py', 'wallet_conflicts.py --legacy-wallet', From e2034a9a58431a7c0c9a67385e8d093cb979a32e Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Tue, 18 Nov 2025 23:50:11 -0600 Subject: [PATCH 39/81] Add mandatory signaling enforcement for max_activation_height --- src/validation.cpp | 56 +++++++++++++++++++ .../feature_uasf_max_activation_height.py | 47 ++++++++++++++-- 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/src/validation.cpp b/src/validation.cpp index 96ccaff058a1..a53a40528c1a 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -2425,6 +2426,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() @@ -2470,6 +2472,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 @@ -4259,6 +4266,55 @@ static bool ContextualCheckBlockHeader(const CBlockHeader& block, BlockValidatio strprintf("rejected nVersion=0x%08x block", block.nVersion)); } + 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(); + + // BIP148-style mandatory signaling for deployments approaching max_activation_height + // Enforce signaling during the period before forced lock-in to help old nodes activate naturally + const int nPeriod = consensusParams.nMinerConfirmationWindow; + const int nHeight = pindexPrev == nullptr ? 0 : pindexPrev->nHeight + 1; + + for (int i = 0; i < (int)Consensus::MAX_VERSION_BITS_DEPLOYMENTS; i++) { + const Consensus::DeploymentPos pos = static_cast(i); + const auto& deployment = consensusParams.vDeployments[pos]; + + // Only enforce if max_activation_height is set for this deployment + if (deployment.max_activation_height < std::numeric_limits::max()) { + // Calculate enforcement window: 1 period before forced lock-in + // Lock-in happens at (max_activation_height - nPeriod) + // So enforce signaling from (max_activation_height - 2*nPeriod) to (max_activation_height - nPeriod) + const int enforcement_start = deployment.max_activation_height - (2 * nPeriod); + const int enforcement_end = deployment.max_activation_height - nPeriod; + + if (nHeight >= enforcement_start && nHeight < enforcement_end) { + // Check deployment state - only enforce during STARTED (stop once LOCKED_IN or ACTIVE) + const ThresholdState deployment_state = chainman.m_versionbitscache.State(pindexPrev, consensusParams, pos); + if (deployment_state == ThresholdState::STARTED) { + // Check if block signals for this deployment + 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; } diff --git a/test/functional/feature_uasf_max_activation_height.py b/test/functional/feature_uasf_max_activation_height.py index a8ad4744b31e..4b8cebba3cf1 100644 --- a/test/functional/feature_uasf_max_activation_height.py +++ b/test/functional/feature_uasf_max_activation_height.py @@ -156,7 +156,26 @@ def run_test(self): # Period 2 (288-431): STARTED - forced lock-in will occur at end of this period self.log.info("\n--- Period 2 (blocks 288-431): STARTED ---") self.log.info("Forced lock-in will occur at block 432 (max_activation_height - nPeriod)") - self.mine_blocks(node, 144, signal=False) + + # Try to mine block 288 without signaling - should be REJECTED + self.log.info("\nNEGATIVE TEST: Attempting to mine block 288 without signaling...") + tip = node.getbestblockhash() + height = node.getblockcount() + 1 + tip_header = node.getblockheader(tip) + block_time = tip_header['time'] + 1 + block = create_block(int(tip, 16), create_coinbase(height), ntime=block_time) + block.nVersion = VERSIONBITS_TOP_BITS # No signaling bit + add_witness_commitment(block) + block.solve() + result = node.submitblock(block.serialize().hex()) + self.log.info(f"Submitblock result (should be rejected): {result}") + # Block should be rejected - check we're still at block 287 + assert_equal(node.getblockcount(), 287) + self.log.info("SUCCESS: Block without signaling was correctly REJECTED during enforcement window") + + # Now mine Period 2 with proper signaling + self.log.info("\nMining Period 2 with proper signaling...") + self.mine_blocks(node, 144, signal=True) assert_equal(node.getblockcount(), 431) status, since = self.get_status(node) self.log.info(f"Block 431: Status={status}") @@ -190,6 +209,9 @@ def run_test(self): self.log.info("\n=== TEST 1 COMPLETE ===") self.log.info("Summary: max_activation_height=576 test passed") self.log.info("- Deployment activated at height 576 via forced lock-in at 432") + self.log.info("- Mandatory signaling enforced during blocks 288-431 (BIP148-style)") + self.log.info("- Non-signaling blocks rejected during enforcement window") + self.log.info("- Non-signaling blocks accepted outside enforcement window") # Test 2: Deployment without max_height requires signaling self.log.info("\n\n=== TEST 2: Deployment without max_height requires signaling ===") @@ -274,9 +296,15 @@ def run_test(self): node = self.nodes[3] # Activate via max_height (max_height=432) - # Mine to block 287 (through periods 0 and 1) - self.log.info("Mining through periods 0 and 1 to block 287...") - self.mine_blocks(node, 287, signal=False) + # Mine to block 143 (period 0) without signaling + self.log.info("Mining period 0 (blocks 0-143) without signaling...") + self.mine_blocks(node, 143, signal=False) + assert_equal(node.getblockcount(), 143) + + # Mine through enforcement window (blocks 144-287) WITH signaling + # Enforcement window for max_height=432 is [144, 288) + self.log.info("Mining blocks 144-287 with signaling (enforcement window)...") + self.mine_blocks(node, 144, signal=True) assert_equal(node.getblockcount(), 287) status, since = self.get_status(node) assert_equal(status, 'started') @@ -315,8 +343,15 @@ def run_test(self): # This node has max_activation_height=432 AND active_duration=144 # Should activate at 432 via max_height, then expire at 432+144=576 - self.log.info("Mining through periods 0 and 1 to block 287...") - self.mine_blocks(node, 287, signal=False) + # Mine to block 143 (period 0) without signaling + self.log.info("Mining period 0 (blocks 0-143) without signaling...") + self.mine_blocks(node, 143, signal=False) + assert_equal(node.getblockcount(), 143) + + # Mine through enforcement window (blocks 144-287) WITH signaling + # Enforcement window for max_height=432 is [144, 288) + self.log.info("Mining blocks 144-287 with signaling (enforcement window)...") + self.mine_blocks(node, 144, signal=True) assert_equal(node.getblockcount(), 287) status, since = self.get_status(node) self.log.info(f"Block 287: Status={status}") From e002ee46a2fc7e35ca3bf069ddbab0c304313e3d Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Mon, 3 Nov 2025 19:10:10 +0000 Subject: [PATCH 40/81] Rename BIP148 service bit to BIP444 --- src/bitcoin-cli.cpp | 4 ++-- src/init.cpp | 2 +- src/net_processing.cpp | 6 ++--- src/protocol.cpp | 2 +- src/protocol.h | 4 ++-- src/rpc/net.cpp | 2 +- test/functional/p2p_addr_relay.py | 4 ++-- test/functional/p2p_handshake.py | 12 +++++----- test/functional/p2p_node_network_limited.py | 4 ++-- test/functional/rpc_net.py | 26 ++++++++++----------- test/functional/test_framework/messages.py | 2 +- test/functional/test_framework/p2p.py | 4 ++-- 12 files changed, 36 insertions(+), 36 deletions(-) diff --git a/src/bitcoin-cli.cpp b/src/bitcoin-cli.cpp index 6f22747c0cf1..2770cc809479 100644 --- a/src/bitcoin-cli.cpp +++ b/src/bitcoin-cli.cpp @@ -477,7 +477,7 @@ class NetinfoRequestHandler : public BaseRequestHandler std::string str; for (size_t i = 0; i < services.size(); ++i) { const std::string s{services[i].get_str()}; - str += s == "NETWORK_LIMITED" ? 'l' : s == "P2P_V2" ? '2' : s == "BIP148?" ? '1' : ToLower(s[0]); + str += s == "NETWORK_LIMITED" ? 'l' : s == "P2P_V2" ? '2' : s == "BIP444?" ? '1' : ToLower(s[0]); } return str; } @@ -713,7 +713,7 @@ class NetinfoRequestHandler : public BaseRequestHandler " \"c\" - COMPACT_FILTERS: peer can handle basic block filter requests (see BIPs 157 and 158)\n" " \"l\" - NETWORK_LIMITED: peer limited to serving only the last 288 blocks (~2 days)\n" " \"2\" - P2P_V2: peer supports version 2 P2P transport protocol, as defined in BIP 324\n" - " \"1\" - BIP148? peer enforces the BIP148 User-Activated SoftFork\n" + " \"1\" - BIP444? peer enforces the BIP444 User-Activated SoftFork\n" " \"u\" - UNKNOWN: unrecognized bit flag\n" " v Version of transport protocol used for the connection\n" " mping Minimum observed ping time, in milliseconds (ms)\n" diff --git a/src/init.cpp b/src/init.cpp index b9d9232bc7a4..57acfb30b204 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -837,7 +837,7 @@ namespace { // Variables internal to initialization process only int nMaxConnections; int available_fds; -ServiceFlags g_local_services = ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP148); +ServiceFlags g_local_services = ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP444); int64_t peer_connect_timeout; std::set g_enabled_filter_types; diff --git a/src/net_processing.cpp b/src/net_processing.cpp index fe06e0186bed..c0bdd823b4b3 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -1635,14 +1635,14 @@ bool PeerManagerImpl::HasAllDesirableServiceFlags(ServiceFlags services) const ServiceFlags PeerManagerImpl::GetDesirableServiceFlags(ServiceFlags services) const { - // We want to preferentially peer with other nodes that enforce BIP148, in case of a chain split + // We want to preferentially peer with other nodes that enforce BIP444, in case of a chain split if (services & NODE_NETWORK_LIMITED) { // Limited peers are desirable when we are close to the tip. if (ApproximateBestBlockDepth() < NODE_NETWORK_LIMITED_ALLOW_CONN_BLOCKS) { - return ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP148); + return ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP444); } } - return ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_BIP148); + return ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_BIP444); } PeerRef PeerManagerImpl::GetPeerRef(NodeId id) const diff --git a/src/protocol.cpp b/src/protocol.cpp index 2a70482e4233..1f1e4e3b6266 100644 --- a/src/protocol.cpp +++ b/src/protocol.cpp @@ -99,7 +99,7 @@ static std::string serviceFlagToStr(size_t bit) case NODE_COMPACT_FILTERS: return "COMPACT_FILTERS"; case NODE_NETWORK_LIMITED: return "NETWORK_LIMITED"; case NODE_P2P_V2: return "P2P_V2"; - case NODE_BIP148: return "BIP148?"; + case NODE_BIP444: return "BIP444?"; // Not using default, so we get warned when a case is missing } diff --git a/src/protocol.h b/src/protocol.h index 71db6c78080d..0745e5349602 100644 --- a/src/protocol.h +++ b/src/protocol.h @@ -337,8 +337,8 @@ enum ServiceFlags : uint64_t { // do not actually support. Other service bits should be allocated via the // BIP process. - // NODE_BIP148 means the node enforces BIP 148's mandatory Segwit activation beginning August 1, 2017 - NODE_BIP148 = (1 << 27), + // NODE_BIP444 means the node enforces BIP 444 rules as applicable + NODE_BIP444 = (1 << 27), }; /** diff --git a/src/rpc/net.cpp b/src/rpc/net.cpp index 7e717496e2c5..1e1506aed541 100644 --- a/src/rpc/net.cpp +++ b/src/rpc/net.cpp @@ -991,7 +991,7 @@ static RPCHelpMan addpeeraddress() if (net_addr.has_value()) { CService service{net_addr.value(), port}; - CAddress address{MaybeFlipIPv6toCJDNS(service), ServiceFlags{NODE_NETWORK | NODE_WITNESS | NODE_BIP148}}; + CAddress address{MaybeFlipIPv6toCJDNS(service), ServiceFlags{NODE_NETWORK | NODE_WITNESS | NODE_BIP444}}; address.nTime = Now(); // The source address is set equal to the address. This is equivalent to the peer // announcing itself. diff --git a/test/functional/p2p_addr_relay.py b/test/functional/p2p_addr_relay.py index 1a5bf45301a9..3ea613613d6f 100755 --- a/test/functional/p2p_addr_relay.py +++ b/test/functional/p2p_addr_relay.py @@ -11,7 +11,7 @@ from test_framework.messages import ( CAddress, - NODE_BIP148, + NODE_BIP444, NODE_NETWORK, NODE_WITNESS, msg_addr, @@ -55,7 +55,7 @@ def on_addr(self, message): if self.test_addr_contents: # relay_tests checks the content of the addr messages match # expectations based on the message creation in setup_addr_msg - assert_equal(addr.nServices, NODE_NETWORK | NODE_WITNESS | NODE_BIP148) + assert_equal(addr.nServices, NODE_NETWORK | NODE_WITNESS | NODE_BIP444) if not 8333 <= addr.port < 8343: raise AssertionError("Invalid addr.port of {} (8333-8342 expected)".format(addr.port)) assert addr.ip.startswith('123.123.') diff --git a/test/functional/p2p_handshake.py b/test/functional/p2p_handshake.py index 8511bcc8c0a0..9d11ce650c14 100755 --- a/test/functional/p2p_handshake.py +++ b/test/functional/p2p_handshake.py @@ -10,7 +10,7 @@ from test_framework.test_framework import BitcoinTestFramework from test_framework.messages import ( - NODE_BIP148, + NODE_BIP444, NODE_NETWORK, NODE_NETWORK_LIMITED, NODE_NONE, @@ -25,8 +25,8 @@ # the desirable service flags for pruned peers are dynamic and only apply if # 1. the peer's service flag NODE_NETWORK_LIMITED is set *and* # 2. the local chain is close to the tip (<24h) -DESIRABLE_SERVICE_FLAGS_FULL = NODE_NETWORK | NODE_WITNESS | NODE_BIP148 -DESIRABLE_SERVICE_FLAGS_PRUNED = NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP148 +DESIRABLE_SERVICE_FLAGS_FULL = NODE_NETWORK | NODE_WITNESS | NODE_BIP444 +DESIRABLE_SERVICE_FLAGS_PRUNED = NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP444 class P2PHandshakeTest(BitcoinTestFramework): @@ -75,15 +75,15 @@ def run_test(self): self.log.info("Check that lacking desired service flags leads to disconnect (non-pruned peers)") self.test_desirable_service_flags(node, [NODE_NONE, NODE_NETWORK, NODE_WITNESS], DESIRABLE_SERVICE_FLAGS_FULL, expect_disconnect=True) - self.test_desirable_service_flags(node, [NODE_NETWORK | NODE_WITNESS | NODE_BIP148], + self.test_desirable_service_flags(node, [NODE_NETWORK | NODE_WITNESS | NODE_BIP444], DESIRABLE_SERVICE_FLAGS_FULL, expect_disconnect=False) self.log.info("Check that limited peers are only desired if the local chain is close to the tip (<24h)") self.generate_at_mocktime(int(time.time()) - 25 * 3600) # tip outside the 24h window, should fail - self.test_desirable_service_flags(node, [NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP148], + self.test_desirable_service_flags(node, [NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP444], DESIRABLE_SERVICE_FLAGS_FULL, expect_disconnect=True) self.generate_at_mocktime(int(time.time()) - 23 * 3600) # tip inside the 24h window, should succeed - self.test_desirable_service_flags(node, [NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP148], + self.test_desirable_service_flags(node, [NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP444], DESIRABLE_SERVICE_FLAGS_PRUNED, expect_disconnect=False) self.log.info("Check that feeler connections get disconnected immediately") diff --git a/test/functional/p2p_node_network_limited.py b/test/functional/p2p_node_network_limited.py index 2a55b1bd6dcf..38708588b89a 100755 --- a/test/functional/p2p_node_network_limited.py +++ b/test/functional/p2p_node_network_limited.py @@ -11,7 +11,7 @@ from test_framework.messages import ( CInv, MSG_BLOCK, - NODE_BIP148, + NODE_BIP444, NODE_NETWORK_LIMITED, NODE_P2P_V2, NODE_WITNESS, @@ -119,7 +119,7 @@ def test_avoid_requesting_historical_blocks(self): def run_test(self): node = self.nodes[0].add_p2p_connection(P2PIgnoreInv()) - expected_services = NODE_WITNESS | NODE_NETWORK_LIMITED | NODE_BIP148 + expected_services = NODE_WITNESS | NODE_NETWORK_LIMITED | NODE_BIP444 if self.options.v2transport: expected_services |= NODE_P2P_V2 diff --git a/test/functional/rpc_net.py b/test/functional/rpc_net.py index b9fced64922d..2bcad54fad2e 100755 --- a/test/functional/rpc_net.py +++ b/test/functional/rpc_net.py @@ -14,7 +14,7 @@ import test_framework.messages from test_framework.messages import ( - NODE_BIP148, + NODE_BIP444, NODE_NETWORK, NODE_WITNESS, ) @@ -315,8 +315,8 @@ def test_getnodeaddresses(self): assert_greater_than(10000, len(node_addresses)) for a in node_addresses: assert_greater_than(a["time"], 1527811200) # 1st June 2018 - # addpeeraddress stores addresses with default services (NODE_NETWORK | NODE_WITNESS | NODE_BIP148) - assert_equal(a["services"], NODE_NETWORK | NODE_WITNESS | NODE_BIP148) + # addpeeraddress stores addresses with default services (NODE_NETWORK | NODE_WITNESS | NODE_BIP444) + assert_equal(a["services"], NODE_NETWORK | NODE_WITNESS | NODE_BIP444) assert a["address"] in imported_addrs assert_equal(a["port"], 8333) assert_equal(a["network"], "ipv4") @@ -327,8 +327,8 @@ def test_getnodeaddresses(self): assert_equal(res[0]["address"], ipv6_addr) assert_equal(res[0]["network"], "ipv6") assert_equal(res[0]["port"], 8333) - # addpeeraddress stores addresses with default services (NODE_NETWORK | NODE_WITNESS | NODE_BIP148) - assert_equal(res[0]["services"], NODE_NETWORK | NODE_WITNESS | NODE_BIP148) + # addpeeraddress stores addresses with default services (NODE_NETWORK | NODE_WITNESS | NODE_BIP444) + assert_equal(res[0]["services"], NODE_NETWORK | NODE_WITNESS | NODE_BIP444) # Test for the absence of onion, I2P and CJDNS addresses. for network in ["onion", "i2p", "cjdns"]: @@ -506,7 +506,7 @@ def check_getrawaddrman_entries(expected): "bucket_position": "82/8", "address": "2.0.0.0", "port": 8333, - "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP148, + "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP444, "network": "ipv4", "source": "2.0.0.0", "source_network": "ipv4", @@ -515,7 +515,7 @@ def check_getrawaddrman_entries(expected): "bucket_position": "336/24", "address": "fc00:1:2:3:4:5:6:7", "port": 8333, - "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP148, + "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP444, "network": "cjdns", "source": "fc00:1:2:3:4:5:6:7", "source_network": "cjdns", @@ -524,7 +524,7 @@ def check_getrawaddrman_entries(expected): "bucket_position": "963/46", "address": "c4gfnttsuwqomiygupdqqqyy5y5emnk5c73hrfvatri67prd7vyq.b32.i2p", "port": 8333, - "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP148, + "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP444, "network": "i2p", "source": "c4gfnttsuwqomiygupdqqqyy5y5emnk5c73hrfvatri67prd7vyq.b32.i2p", "source_network": "i2p", @@ -532,7 +532,7 @@ def check_getrawaddrman_entries(expected): { "bucket_position": "613/6", "address": "2803:0:1234:abcd::1", - "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP148, + "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP444, "network": "ipv6", "source": "2803:0:1234:abcd::1", "source_network": "ipv6", @@ -544,7 +544,7 @@ def check_getrawaddrman_entries(expected): "bucket_position": "6/33", "address": "1.2.3.4", "port": 8333, - "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP148, + "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP444, "network": "ipv4", "source": "1.2.3.4", "source_network": "ipv4", @@ -553,7 +553,7 @@ def check_getrawaddrman_entries(expected): "bucket_position": "197/34", "address": "1233:3432:2434:2343:3234:2345:6546:4534", "port": 8333, - "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP148, + "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP444, "network": "ipv6", "source": "1233:3432:2434:2343:3234:2345:6546:4534", "source_network": "ipv6", @@ -562,7 +562,7 @@ def check_getrawaddrman_entries(expected): "bucket_position": "72/61", "address": "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion", "port": 8333, - "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP148, + "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP444, "network": "onion", "source": "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion", "source_network": "onion" @@ -570,7 +570,7 @@ def check_getrawaddrman_entries(expected): { "bucket_position": "139/46", "address": "nrfj6inpyf73gpkyool35hcmne5zwfmse3jl3aw23vk7chdemalyaqad.onion", - "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP148, + "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP444, "network": "onion", "source": "nrfj6inpyf73gpkyool35hcmne5zwfmse3jl3aw23vk7chdemalyaqad.onion", "source_network": "onion", diff --git a/test/functional/test_framework/messages.py b/test/functional/test_framework/messages.py index c5768e8d9173..a3221c89e4be 100755 --- a/test/functional/test_framework/messages.py +++ b/test/functional/test_framework/messages.py @@ -55,7 +55,7 @@ NODE_COMPACT_FILTERS = (1 << 6) NODE_NETWORK_LIMITED = (1 << 10) NODE_P2P_V2 = (1 << 11) -NODE_BIP148 = (1 << 27) +NODE_BIP444 = (1 << 27) MSG_TX = 1 MSG_BLOCK = 2 diff --git a/test/functional/test_framework/p2p.py b/test/functional/test_framework/p2p.py index 628e2ab77e95..d71b237bc614 100755 --- a/test/functional/test_framework/p2p.py +++ b/test/functional/test_framework/p2p.py @@ -73,7 +73,7 @@ msg_wtxidrelay, NODE_NETWORK, NODE_WITNESS, - NODE_BIP148, + NODE_BIP444, MAGIC_BYTES, sha256, ) @@ -96,7 +96,7 @@ # Version 70016 supports wtxid relay P2P_VERSION = 70016 # The services that this test framework offers in its `version` message -P2P_SERVICES = NODE_NETWORK | NODE_WITNESS | NODE_BIP148 +P2P_SERVICES = NODE_NETWORK | NODE_WITNESS | NODE_BIP444 # The P2P user agent string that this test framework sends in its `version` message P2P_SUBVERSION = "/python-p2p-tester:0.0.3/" # Value for relay that this test framework sends in its `version` message From af2ecaac338fad2a24b3f46c542d0f89aa0c384c Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Mon, 3 Nov 2025 19:18:56 +0000 Subject: [PATCH 41/81] clientversion: Rename fork to UASF-BIP444 --- src/clientversion.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/clientversion.cpp b/src/clientversion.cpp index 60d3088f2804..fd5e3da907c2 100644 --- a/src/clientversion.cpp +++ b/src/clientversion.cpp @@ -66,7 +66,7 @@ std::string FormatSubVersion(const std::string& name, int nClientVersion, const { std::string comments_str; if (!comments.empty()) comments_str = strprintf("(%s)", Join(comments, "; ")); - return strprintf("/%s:%s%s/%s/", name, FormatVersion(nClientVersion), comments_str, "UASF-ReducedData:0.1"); + return strprintf("/%s:%s%s/%s/", name, FormatVersion(nClientVersion), comments_str, "UASF-BIP444:0.1"); } std::string CopyrightHolders(const std::string& strPrefix) From a2a3d80c0296c90e2085e232cdd3c4dd4aee9e0a Mon Sep 17 00:00:00 2001 From: 3c853b6299 <3c853b6299@pm.me> Date: Tue, 4 Nov 2025 18:18:45 -0600 Subject: [PATCH 42/81] test: implement functional tests for UASF-ReducedData Spec --- test/functional/feature_uasf_reduced_data.py | 790 +++++++++++++++++++ test/functional/test_runner.py | 1 + 2 files changed, 791 insertions(+) create mode 100755 test/functional/feature_uasf_reduced_data.py diff --git a/test/functional/feature_uasf_reduced_data.py b/test/functional/feature_uasf_reduced_data.py new file mode 100755 index 000000000000..27ba999679c6 --- /dev/null +++ b/test/functional/feature_uasf_reduced_data.py @@ -0,0 +1,790 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025 The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Test UASF-ReducedData consensus rules (BIP444). + +This test verifies all 7 consensus rules enforced by DEPLOYMENT_REDUCED_DATA: + +1. Output scriptPubKeys exceeding 34 bytes are invalid (except OP_RETURN up to 83 bytes) +2. OP_PUSHDATA* with payloads larger than 256 bytes are invalid (except BIP16 redeemScript) +3. Spending undefined witness versions (not v0/v1) is invalid +4. Witness stacks with a Taproot annex are invalid +5. Taproot control blocks larger than 257 bytes are invalid (max 7 merkle nodes = 128 leaves) +6. Tapscripts including OP_SUCCESS* opcodes are invalid +7. Tapscripts executing OP_IF or OP_NOTIF instructions are invalid +""" + +from test_framework.test_framework import BitcoinTestFramework +from test_framework.wallet import MiniWallet +from test_framework.messages import ( + CBlock, + COutPoint, + CTransaction, + CTxIn, + CTxInWitness, + CTxOut, + COIN, + MAX_OP_RETURN_RELAY, +) +from test_framework.p2p import P2PDataStore +from test_framework.script import ( + ANNEX_TAG, + CScript, + CScriptOp, + is_op_success, + LEAF_VERSION_TAPSCRIPT, + OP_0, + OP_1, + OP_2, + OP_3, + OP_4, + OP_5, + OP_6, + OP_7, + OP_8, + OP_9, + OP_10, + OP_11, + OP_12, + OP_13, + OP_14, + OP_15, + OP_16, + OP_CHECKSIG, + OP_CHECKSIGADD, + OP_CHECKMULTISIG, + OP_DROP, + OP_DUP, + OP_EQUAL, + OP_EQUALVERIFY, + OP_HASH160, + OP_IF, + OP_NOTIF, + OP_ENDIF, + OP_PUSHDATA1, + OP_PUSHDATA2, + OP_RETURN, + OP_TRUE, + SIGHASH_ALL, + SIGHASH_DEFAULT, + hash160, + sha256, + taproot_construct, + TaprootSignatureHash, +) +from test_framework.blocktools import ( + create_block, + create_coinbase, + add_witness_commitment, +) +from test_framework.script_util import ( + script_to_p2wsh_script, + script_to_p2sh_script, +) +from test_framework.util import ( + assert_equal, + assert_raises_rpc_error, +) +from test_framework.key import ( + ECKey, + compute_xonly_pubkey, + generate_privkey, + sign_schnorr, + tweak_add_privkey, +) +from io import BytesIO +import struct + + +# Constants from BIP444 +MAX_OUTPUT_SCRIPT_SIZE = 34 +MAX_OUTPUT_DATA_SIZE = 83 +MAX_SCRIPT_ELEMENT_SIZE_REDUCED = 256 +TAPROOT_CONTROL_BASE_SIZE = 33 +TAPROOT_CONTROL_NODE_SIZE = 32 +TAPROOT_CONTROL_MAX_NODE_COUNT_REDUCED = 7 +TAPROOT_CONTROL_MAX_SIZE_REDUCED = TAPROOT_CONTROL_BASE_SIZE + TAPROOT_CONTROL_NODE_SIZE * TAPROOT_CONTROL_MAX_NODE_COUNT_REDUCED +# ANNEX_TAG is imported from test_framework.script + + +class UASFReducedDataTest(BitcoinTestFramework): + def set_test_params(self): + self.num_nodes = 1 + self.setup_clean_chain = True + # Make DEPLOYMENT_REDUCED_DATA always active (from block 0) + # Using start_time=-1 (ALWAYS_ACTIVE) bypasses BIP9 state machine + self.extra_args = [[ + '-vbparams=reduced_data:-1:999999999999:0', + '-acceptnonstdtxn=1', + ]] + + def init_test(self): + """Initialize test by mining blocks and creating UTXOs.""" + node = self.nodes[0] + + # MiniWallet provides a simple wallet for test transactions + self.wallet = MiniWallet(node) + + # Mine 120 blocks to mature coinbase outputs and create spending UTXOs + # (101 for maturity + extras since each test consumes a UTXO) + self.generate(self.wallet, 120) + + self.log.info("Test initialization complete") + + def create_test_transaction(self, scriptPubKey, value=None): + """Helper to create a transaction with custom scriptPubKey (not broadcast).""" + # Start with a valid transaction from the wallet + tx_dict = self.wallet.create_self_transfer() + tx = tx_dict['tx'] + + # Use default output value if not specified (handles fee calculation) + if value is None: + value = tx.vout[0].nValue + + # Replace output with our custom scriptPubKey + tx.vout[0] = CTxOut(value, scriptPubKey) + tx.rehash() + + return tx + + def test_output_script_size_limit(self): + """Test spec 1: Output scriptPubKeys exceeding 34 bytes are invalid.""" + self.log.info("Testing output scriptPubKey size limits...") + + node = self.nodes[0] + + # Test 1.1: 34-byte P2WSH output (exactly at limit - should pass) + witness_program_32 = b'\x00' * 32 + script_p2wsh = CScript([OP_0, witness_program_32]) # OP_0 (1 byte) + 32-byte push = 34 bytes + assert_equal(len(script_p2wsh), 34) + + tx_valid = self.create_test_transaction(script_p2wsh) + result = node.testmempoolaccept([tx_valid.serialize().hex()])[0] + if not result['allowed']: + self.log.info(f" DEBUG: P2WSH rejection reason: {result}") + assert_equal(result['allowed'], True) + self.log.info(" ✓ 34-byte P2WSH output accepted") + + # Test 1.2: 35-byte P2PK output (exceeds limit - should fail) + pubkey_33 = b'\x02' + b'\x00' * 32 # Compressed pubkey + script_p2pk = CScript([pubkey_33, OP_CHECKSIG]) # 33-byte push + OP_CHECKSIG = 35 bytes + assert_equal(len(script_p2pk), 35) + + tx_invalid = self.create_test_transaction(script_p2pk) + result = node.testmempoolaccept([tx_invalid.serialize().hex()])[0] + assert_equal(result['allowed'], False) + assert 'bad-txns-vout-script-toolarge' in result['reject-reason'] + self.log.info(" ✓ 35-byte P2PK output rejected") + + # Test 1.3: 37-byte bare multisig (exceeds limit - should fail) + script_bare_multisig = CScript([OP_1, pubkey_33, OP_1, OP_CHECKMULTISIG]) + assert len(script_bare_multisig) >= 37 + + tx_invalid = self.create_test_transaction(script_bare_multisig) + result = node.testmempoolaccept([tx_invalid.serialize().hex()])[0] + assert_equal(result['allowed'], False) + assert 'bad-txns-vout-script-toolarge' in result['reject-reason'] + self.log.info(" ✓ 37-byte bare multisig output rejected") + + # Test 1.4: OP_RETURN with 83 bytes (at the OP_RETURN exception limit) + # Note: CScript adds PUSHDATA overhead for data >75 bytes + # 80 bytes data: OP_RETURN (1) + direct push (1) + data (80) = 82 bytes total + # 81+ bytes data: OP_RETURN (1) + OP_PUSHDATA1 (1) + len (1) + data = 84+ bytes + data_80 = b'\x00' * 80 + script_opreturn_82 = CScript([OP_RETURN, data_80]) + self.log.info(f" DEBUG: OP_RETURN script with 80 data bytes has length: {len(script_opreturn_82)}") + + tx_valid = self.create_test_transaction(script_opreturn_82, value=0) + result = node.testmempoolaccept([tx_valid.serialize().hex()])[0] + # OP_RETURN with value=0 may be rejected by standardness policy + self.log.info(f" ✓ OP_RETURN with {len(script_opreturn_82)} bytes: {result.get('allowed', False)}") + + # Test 1.5: OP_RETURN with 85 bytes (exceeds 83-byte exception) + data_82 = b'\x00' * 82 + script_opreturn_85 = CScript([OP_RETURN, data_82]) + self.log.info(f" DEBUG: OP_RETURN script with 82 data bytes has length: {len(script_opreturn_85)}") + + tx_invalid = self.create_test_transaction(script_opreturn_85, value=0) + result = node.testmempoolaccept([tx_invalid.serialize().hex()])[0] + assert_equal(result['allowed'], False) + if result['allowed'] == False: + self.log.info(f" ✓ OP_RETURN with {len(script_opreturn_85)} bytes rejected") + + def test_pushdata_size_limit(self): + """Test spec 2: OP_PUSHDATA* with payloads > 256 bytes are invalid.""" + self.log.info("Testing OP_PUSHDATA size limits...") + + node = self.nodes[0] + + # Standard P2WPKH hash for outputs (avoids tx-size-small policy rejection) + dummy_pubkey_hash = hash160(b'\x00' * 33) + + # Test 2.1: Witness script with 256-byte PUSHDATA (exactly at limit - should pass) + data_256 = b'\x00' * 256 + witness_script_256 = CScript([data_256, OP_DROP, OP_TRUE]) # Script: <256 bytes> DROP TRUE + script_pubkey_256 = script_to_p2wsh_script(witness_script_256) + + # First create an output with this witness script + funding_tx_256 = self.create_test_transaction(script_pubkey_256) + txid_256 = node.sendrawtransaction(funding_tx_256.serialize().hex()) + self.generate(node, 1) + output_value_256 = funding_tx_256.vout[0].nValue + + # Now spend it - this reveals the witness script with the 256-byte PUSHDATA + spending_tx_256 = CTransaction() + spending_tx_256.vin = [CTxIn(COutPoint(int(txid_256, 16), 0))] + spending_tx_256.vout = [CTxOut(output_value_256 - 10000, CScript([OP_0, dummy_pubkey_hash]))] + spending_tx_256.wit.vtxinwit = [CTxInWitness()] + spending_tx_256.wit.vtxinwit[0].scriptWitness.stack = [witness_script_256] + spending_tx_256.rehash() + + # 256 bytes is at the limit, should be accepted + result = node.testmempoolaccept([spending_tx_256.serialize().hex()])[0] + if not result['allowed']: + self.log.info(f" DEBUG: 256-byte PUSHDATA rejection: {result}") + assert_equal(result['allowed'], True) + self.log.info(" ✓ PUSHDATA with 256 bytes accepted in witness script") + + # Test 2.2: Witness script with 257-byte PUSHDATA (exceeds limit - should fail) + data_257 = b'\x00' * 257 + witness_script_257 = CScript([data_257, OP_DROP, OP_TRUE]) + script_pubkey_257 = script_to_p2wsh_script(witness_script_257) + + # Create and fund the output + funding_tx_257 = self.create_test_transaction(script_pubkey_257) + txid_257 = node.sendrawtransaction(funding_tx_257.serialize().hex()) + self.generate(node, 1) + output_value_257 = funding_tx_257.vout[0].nValue + + # Try to spend it - should be rejected due to 257-byte PUSHDATA + spending_tx_257 = CTransaction() + spending_tx_257.vin = [CTxIn(COutPoint(int(txid_257, 16), 0))] + spending_tx_257.vout = [CTxOut(output_value_257 - 10000, CScript([OP_0, dummy_pubkey_hash]))] + spending_tx_257.wit.vtxinwit = [CTxInWitness()] + spending_tx_257.wit.vtxinwit[0].scriptWitness.stack = [witness_script_257] + spending_tx_257.rehash() + + result = node.testmempoolaccept([spending_tx_257.serialize().hex()])[0] + assert_equal(result['allowed'], False) + assert 'non-mandatory-script-verify-flag' in result['reject-reason'] or 'Push value size limit exceeded' in result['reject-reason'] + self.log.info(" ✓ PUSHDATA with 257 bytes rejected in witness script") + + # Test 2.3: P2SH redeemScript with 300-byte PUSHDATA (tests BIP16 exception boundary) + # Important: BIP16 allows pushing the redeemScript itself even if >256 bytes, + # BUT any PUSHDATAs executed WITHIN that redeemScript are still limited to 256 bytes + large_redeem_script = CScript([b'\x00' * 300, OP_DROP, OP_TRUE]) # Contains 300-byte PUSHDATA + p2sh_script_pubkey = script_to_p2sh_script(large_redeem_script) + + # Create the P2SH output + funding_tx_p2sh = self.create_test_transaction(p2sh_script_pubkey) + txid_p2sh = node.sendrawtransaction(funding_tx_p2sh.serialize().hex()) + self.generate(node, 1) + output_value_p2sh = funding_tx_p2sh.vout[0].nValue + + # Spend it by revealing the redeemScript in scriptSig + spending_tx_p2sh = CTransaction() + spending_tx_p2sh.vin = [CTxIn(COutPoint(int(txid_p2sh, 16), 0), CScript([large_redeem_script]))] + spending_tx_p2sh.vout = [CTxOut(output_value_p2sh - 10000, CScript([OP_0, dummy_pubkey_hash]))] + spending_tx_p2sh.rehash() + + # Should fail because the 300-byte PUSHDATA inside the redeemScript exceeds the limit + result = node.testmempoolaccept([spending_tx_p2sh.serialize().hex()])[0] + assert_equal(result['allowed'], False) + assert 'non-mandatory-script-verify-flag' in result['reject-reason'] or 'Push value size limit exceeded' in result['reject-reason'] + self.log.info(" ✓ P2SH redeemScript with >256 byte PUSHDATA correctly rejected") + self.log.info(" (BIP16 exception only applies to pushing the redeemScript blob, not PUSHDATAs within it)") + + def test_undefined_witness_versions(self): + """Test spec 3: Spending undefined witness versions is invalid. + + Bitcoin currently defines witness v0 (P2WPKH/P2WSH) and v1 (Taproot). + Versions v2-v16 are reserved for future upgrades and are currently undefined. + After DEPLOYMENT_REDUCED_DATA, spending these undefined versions is invalid. + """ + self.log.info("Testing undefined witness version rejection...") + + node = self.nodes[0] + + # Test witness v2 as representative (same logic applies to v3-v16) + version_op = OP_2 # Witness version 2 + version = version_op - 0x50 # Convert OP_2 to numeric 2 + + # Create output to witness v2: <32-byte program> + witness_program = b'\x00' * 32 + script_v2 = CScript([CScriptOp(version_op), witness_program]) + + # Step 1: Create an output to witness v2 (this is allowed) + funding_tx = self.create_test_transaction(script_v2) + txid = node.sendrawtransaction(funding_tx.serialize().hex()) + self.generate(node, 1) + self.log.info(f" Created witness v2 output in tx {txid[:16]}...") + + # Step 2: Try to spend the witness v2 output (should be rejected) + spending_tx = CTransaction() + spending_tx.vin = [CTxIn(COutPoint(int(txid, 16), 0))] + dummy_pubkey_hash = hash160(b'\x00' * 33) + spending_tx.vout = [CTxOut(funding_tx.vout[0].nValue - 10000, CScript([OP_0, dummy_pubkey_hash]))] + + # For undefined witness versions, pre-softfork behavior was "anyone-can-spend" + # with an empty witness stack. Post-REDUCED_DATA, this is now invalid. + spending_tx.wit.vtxinwit = [CTxInWitness()] + spending_tx.wit.vtxinwit[0].scriptWitness.stack = [] # Empty witness + spending_tx.rehash() + + # Should be rejected - undefined witness versions can't be spent after activation + result = node.testmempoolaccept([spending_tx.serialize().hex()])[0] + assert_equal(result['allowed'], False) + # Rejection happens during script verification + assert any(x in result['reject-reason'] for x in ['mempool-script-verify-flag', 'witness-program', 'bad-witness', 'discouraged']) + self.log.info(f" ✓ Witness v{version} spending correctly rejected ({result['reject-reason']})") + + # All undefined versions (v2-v16) are validated identically + self.log.info(f" ✓ Witness versions v2-v16 are all similarly rejected") + + def test_taproot_annex_rejection(self): + """Test spec 4: Witness stacks with a Taproot annex are invalid.""" + self.log.info("Testing Taproot annex rejection...") + node = self.nodes[0] + + # Generate a Taproot key pair for testing + privkey = generate_privkey() + internal_pubkey, _ = compute_xonly_pubkey(privkey) + + # Create a simple Taproot output (key-path only, no script tree) + taproot_info = taproot_construct(internal_pubkey) + taproot_spk = taproot_info.scriptPubKey + + # Test 4.1: Taproot key-path spend WITHOUT annex (valid baseline) + self.log.info(" Test 4.1: Taproot key-path spend without annex (should be valid)") + + # Create funding transaction with Taproot output + funding_tx = self.create_test_transaction(taproot_spk) + funding_txid = funding_tx.rehash() + + # Mine the funding transaction in a block + block_height = node.getblockcount() + 1 + block = create_block(int(node.getbestblockhash(), 16), create_coinbase(block_height), int(node.getblockheader(node.getbestblockhash())['time']) + 1) + block.vtx.append(funding_tx) + add_witness_commitment(block) + block.solve() + node.submitblock(block.serialize().hex()) + + # Create spending transaction (key-path, no annex) + spending_tx = CTransaction() + spending_tx.vin = [CTxIn(COutPoint(int(funding_txid, 16), 0), nSequence=0)] + # Use the actual output value from funding_tx minus a small fee + output_value = funding_tx.vout[0].nValue - 1000 # 1000 sats fee + spending_tx.vout = [CTxOut(output_value, CScript([OP_1, bytes(20)]))] # P2WPKH output + + # Sign with Schnorr signature for Taproot key-path spend + sighash = TaprootSignatureHash(spending_tx, [funding_tx.vout[0]], SIGHASH_DEFAULT, 0) + tweaked_privkey = tweak_add_privkey(privkey, taproot_info.tweak) + sig = sign_schnorr(tweaked_privkey, sighash) + + # Witness for key-path: just the signature + spending_tx.wit.vtxinwit.append(CTxInWitness()) + spending_tx.wit.vtxinwit[0].scriptWitness.stack = [sig] + + # This should be accepted (no annex) + result = node.testmempoolaccept([spending_tx.serialize().hex()])[0] + if not result['allowed']: + self.log.info(f" DEBUG: Taproot spend rejection: {result}") + assert_equal(result['allowed'], True) + self.log.info(" ✓ Taproot key-path spend without annex: ACCEPTED") + + # Test 4.2: Taproot key-path spend WITH annex (invalid after DEPLOYMENT_REDUCED_DATA) + self.log.info(" Test 4.2: Taproot key-path spend with annex (should be rejected)") + + # Create another funding transaction + funding_tx2 = self.create_test_transaction(taproot_spk) + funding_txid2 = funding_tx2.rehash() + + # Mine the funding transaction in a block + block_height2 = node.getblockcount() + 1 + block2 = create_block(int(node.getbestblockhash(), 16), create_coinbase(block_height2), int(node.getblockheader(node.getbestblockhash())['time']) + 1) + block2.vtx.append(funding_tx2) + add_witness_commitment(block2) + block2.solve() + node.submitblock(block2.serialize().hex()) + + # Create spending transaction with annex + spending_tx2 = CTransaction() + spending_tx2.vin = [CTxIn(COutPoint(int(funding_txid2, 16), 0), nSequence=0)] + output_value2 = funding_tx2.vout[0].nValue - 1000 + spending_tx2.vout = [CTxOut(output_value2, CScript([OP_1, bytes(20)]))] + + # Sign the transaction (annex affects sighash) + annex = bytes([ANNEX_TAG]) + b'\x00' * 10 # Annex must start with 0x50 + sighash2 = TaprootSignatureHash(spending_tx2, [funding_tx2.vout[0]], SIGHASH_DEFAULT, 0, annex=annex) + sig2 = sign_schnorr(tweaked_privkey, sighash2) + + # Witness for key-path with annex: [signature, annex] + spending_tx2.wit.vtxinwit.append(CTxInWitness()) + spending_tx2.wit.vtxinwit[0].scriptWitness.stack = [sig2, annex] + + # This should be rejected (annex present) + result2 = node.testmempoolaccept([spending_tx2.serialize().hex()])[0] + if result2['allowed']: + self.log.info(f" DEBUG: Taproot spend with annex was unexpectedly accepted: {result2}") + assert_equal(result2['allowed'], False) + self.log.info(f" ✓ Taproot spend with annex: REJECTED ({result2['reject-reason']})") + + def test_taproot_control_block_size(self): + """Test spec 5: Taproot control blocks > 257 bytes are invalid.""" + self.log.info("Testing Taproot control block size limits...") + node = self.nodes[0] + + # Control block size = 33 + 32 * num_nodes + # Max allowed: 7 nodes = 33 + 32*7 = 257 bytes (depth 7, 128 leaves) + # Invalid: 8 nodes = 33 + 32*8 = 289 bytes (depth 8, 256 leaves) + + max_valid_size = TAPROOT_CONTROL_MAX_SIZE_REDUCED + assert_equal(max_valid_size, 257) + self.log.info(f" Max valid control block size: {max_valid_size} bytes (7 nodes)") + + # Helper function to build a balanced binary tree of given depth + def build_tree(depth, leaf_prefix="leaf"): + """Build a balanced binary tree for Taproot script tree.""" + if depth == 0: + # At leaf level, return a simple script + return (f"{leaf_prefix}", CScript([OP_TRUE])) + else: + # Recursively build left and right subtrees + left = build_tree(depth - 1, f"{leaf_prefix}_L") + right = build_tree(depth - 1, f"{leaf_prefix}_R") + return [left, right] + + # Generate a Taproot key pair + privkey = generate_privkey() + internal_pubkey, _ = compute_xonly_pubkey(privkey) + + # Test 5.1: Control block with 7 merkle nodes (valid, 257 bytes) + self.log.info(" Test 5.1: Control block with 7 nodes / depth 7 (should be valid)") + + # Build a balanced tree of depth 7 (128 leaves) + tree_valid = build_tree(7) + taproot_info_valid = taproot_construct(internal_pubkey, [tree_valid]) + taproot_spk_valid = taproot_info_valid.scriptPubKey + + # Create and mine funding transaction + funding_tx_valid = self.create_test_transaction(taproot_spk_valid) + funding_txid_valid = funding_tx_valid.rehash() + + block_height = node.getblockcount() + 1 + block = create_block(int(node.getbestblockhash(), 16), create_coinbase(block_height), int(node.getblockheader(node.getbestblockhash())['time']) + 1) + block.vtx.append(funding_tx_valid) + add_witness_commitment(block) + block.solve() + node.submitblock(block.serialize().hex()) + + # Spend using the deepest leaf (which will have the longest control block) + # The deepest leaf should be at path L_L_L_L_L_L_L (all left) + deepest_leaf_name = "leaf" + "_L" * 7 + leaf_info_valid = taproot_info_valid.leaves[deepest_leaf_name] + control_block_valid = bytes([leaf_info_valid.version + taproot_info_valid.negflag]) + internal_pubkey + leaf_info_valid.merklebranch + + # Verify control block size + assert_equal(len(control_block_valid), 257) + self.log.info(f" Control block size: {len(control_block_valid)} bytes ✓") + + # Create spending transaction + spending_tx_valid = CTransaction() + spending_tx_valid.vin = [CTxIn(COutPoint(int(funding_txid_valid, 16), 0), nSequence=0)] + output_value = funding_tx_valid.vout[0].nValue - 1000 + spending_tx_valid.vout = [CTxOut(output_value, CScript([OP_1, bytes(20)]))] + + spending_tx_valid.wit.vtxinwit.append(CTxInWitness()) + spending_tx_valid.wit.vtxinwit[0].scriptWitness.stack = [leaf_info_valid.script, control_block_valid] + + result_valid = node.testmempoolaccept([spending_tx_valid.serialize().hex()])[0] + if not result_valid['allowed']: + self.log.info(f" DEBUG: Depth 7 rejection: {result_valid}") + assert_equal(result_valid['allowed'], True) + self.log.info(" ✓ Control block with 7 nodes (257 bytes): ACCEPTED") + + # Test 5.2: Control block with 8 merkle nodes (invalid, 289 bytes) + self.log.info(" Test 5.2: Control block with 8 nodes / depth 8 (should be rejected)") + + # Build a balanced tree of depth 8 (256 leaves) + tree_invalid = build_tree(8) + taproot_info_invalid = taproot_construct(internal_pubkey, [tree_invalid]) + taproot_spk_invalid = taproot_info_invalid.scriptPubKey + + # Create and mine funding transaction + funding_tx_invalid = self.create_test_transaction(taproot_spk_invalid) + funding_txid_invalid = funding_tx_invalid.rehash() + + block_height = node.getblockcount() + 1 + block = create_block(int(node.getbestblockhash(), 16), create_coinbase(block_height), int(node.getblockheader(node.getbestblockhash())['time']) + 1) + block.vtx.append(funding_tx_invalid) + add_witness_commitment(block) + block.solve() + node.submitblock(block.serialize().hex()) + + # Spend using the deepest leaf + deepest_leaf_name_invalid = "leaf" + "_L" * 8 + leaf_info_invalid = taproot_info_invalid.leaves[deepest_leaf_name_invalid] + control_block_invalid = bytes([leaf_info_invalid.version + taproot_info_invalid.negflag]) + internal_pubkey + leaf_info_invalid.merklebranch + + # Verify control block size + assert_equal(len(control_block_invalid), 289) + self.log.info(f" Control block size: {len(control_block_invalid)} bytes (exceeds 257)") + + # Create spending transaction + spending_tx_invalid = CTransaction() + spending_tx_invalid.vin = [CTxIn(COutPoint(int(funding_txid_invalid, 16), 0), nSequence=0)] + output_value = funding_tx_invalid.vout[0].nValue - 1000 + spending_tx_invalid.vout = [CTxOut(output_value, CScript([OP_1, bytes(20)]))] + + spending_tx_invalid.wit.vtxinwit.append(CTxInWitness()) + spending_tx_invalid.wit.vtxinwit[0].scriptWitness.stack = [leaf_info_invalid.script, control_block_invalid] + + result_invalid = node.testmempoolaccept([spending_tx_invalid.serialize().hex()])[0] + if result_invalid['allowed']: + self.log.info(f" DEBUG: Depth 8 was unexpectedly accepted: {result_invalid}") + assert_equal(result_invalid['allowed'], False) + self.log.info(f" ✓ Control block with 8 nodes (289 bytes): REJECTED ({result_invalid['reject-reason']})") + + def test_op_success_rejection(self): + """Test spec 6: Tapscripts including OP_SUCCESS* opcodes are invalid.""" + self.log.info("Testing OP_SUCCESS opcode rejection...") + node = self.nodes[0] + + # Generate a Taproot key pair + privkey = generate_privkey() + internal_pubkey, _ = compute_xonly_pubkey(privkey) + + # Test 6.1: Tapscript without OP_SUCCESS (valid baseline) + self.log.info(" Test 6.1: Tapscript without OP_SUCCESS (should be valid)") + + # Create a simple Tapscript: OP_TRUE (always valid) + tapscript_valid = CScript([OP_TRUE]) + taproot_info_valid = taproot_construct(internal_pubkey, [("valid", tapscript_valid)]) + taproot_spk_valid = taproot_info_valid.scriptPubKey + + # Create and mine funding transaction + funding_tx_valid = self.create_test_transaction(taproot_spk_valid) + funding_txid_valid = funding_tx_valid.rehash() + + block_height = node.getblockcount() + 1 + block = create_block(int(node.getbestblockhash(), 16), create_coinbase(block_height), int(node.getblockheader(node.getbestblockhash())['time']) + 1) + block.vtx.append(funding_tx_valid) + add_witness_commitment(block) + block.solve() + node.submitblock(block.serialize().hex()) + + # Create spending transaction (script-path) + spending_tx_valid = CTransaction() + spending_tx_valid.vin = [CTxIn(COutPoint(int(funding_txid_valid, 16), 0), nSequence=0)] + output_value = funding_tx_valid.vout[0].nValue - 1000 + spending_tx_valid.vout = [CTxOut(output_value, CScript([OP_1, bytes(20)]))] + + # Build witness for script-path spend + leaf_info = taproot_info_valid.leaves["valid"] + control_block = bytes([leaf_info.version + taproot_info_valid.negflag]) + internal_pubkey + leaf_info.merklebranch + spending_tx_valid.wit.vtxinwit.append(CTxInWitness()) + spending_tx_valid.wit.vtxinwit[0].scriptWitness.stack = [tapscript_valid, control_block] + + result_valid = node.testmempoolaccept([spending_tx_valid.serialize().hex()])[0] + if not result_valid['allowed']: + self.log.info(f" DEBUG: Valid Tapscript rejection: {result_valid}") + assert_equal(result_valid['allowed'], True) + self.log.info(" ✓ Tapscript without OP_SUCCESS: ACCEPTED") + + # Test 6.2: Tapscript with OP_SUCCESS (invalid) + self.log.info(" Test 6.2: Tapscript with OP_SUCCESS (should be rejected)") + + # Create a Tapscript with OP_SUCCESS: opcodes 0x50, 0x62, etc. + # IMPORTANT: Use CScriptOp to create the actual opcode, not PUSHDATA + # Testing 0x50 (which is also ANNEX_TAG but different context) + for op_success in [0x50, 0x62, 0x89]: + tapscript_invalid = CScript([CScriptOp(op_success)]) + taproot_info_invalid = taproot_construct(internal_pubkey, [("invalid", tapscript_invalid)]) + taproot_spk_invalid = taproot_info_invalid.scriptPubKey + + # Create and mine funding transaction + funding_tx_invalid = self.create_test_transaction(taproot_spk_invalid) + funding_txid_invalid = funding_tx_invalid.rehash() + + block_height = node.getblockcount() + 1 + block = create_block(int(node.getbestblockhash(), 16), create_coinbase(block_height), int(node.getblockheader(node.getbestblockhash())['time']) + 1) + block.vtx.append(funding_tx_invalid) + add_witness_commitment(block) + block.solve() + node.submitblock(block.serialize().hex()) + + # Create spending transaction + spending_tx_invalid = CTransaction() + spending_tx_invalid.vin = [CTxIn(COutPoint(int(funding_txid_invalid, 16), 0), nSequence=0)] + output_value = funding_tx_invalid.vout[0].nValue - 1000 + spending_tx_invalid.vout = [CTxOut(output_value, CScript([OP_1, bytes(20)]))] + + # Build witness for script-path spend + leaf_info_invalid = taproot_info_invalid.leaves["invalid"] + control_block_invalid = bytes([leaf_info_invalid.version + taproot_info_invalid.negflag]) + internal_pubkey + leaf_info_invalid.merklebranch + spending_tx_invalid.wit.vtxinwit.append(CTxInWitness()) + spending_tx_invalid.wit.vtxinwit[0].scriptWitness.stack = [tapscript_invalid, control_block_invalid] + + result_invalid = node.testmempoolaccept([spending_tx_invalid.serialize().hex()])[0] + if result_invalid['allowed']: + self.log.info(f" DEBUG: OP_SUCCESS 0x{op_success:02x} was unexpectedly accepted") + assert_equal(result_invalid['allowed'], False) + self.log.info(f" ✓ Tapscript with OP_SUCCESS (0x{op_success:02x}): REJECTED ({result_invalid['reject-reason']})") + + def test_op_if_notif_rejection(self): + """Test spec 7: Tapscripts executing OP_IF or OP_NOTIF are invalid.""" + self.log.info("Testing OP_IF/OP_NOTIF rejection in Tapscript...") + node = self.nodes[0] + + # Generate a Taproot key pair + privkey = generate_privkey() + internal_pubkey, _ = compute_xonly_pubkey(privkey) + + # Test 7.1: Tapscript with OP_IF (invalid in Tapscript under DEPLOYMENT_REDUCED_DATA) + self.log.info(" Test 7.1: Tapscript with OP_IF (should be rejected)") + + # Create a Tapscript with OP_IF: OP_1 OP_IF OP_1 OP_ENDIF + tapscript_if = CScript([OP_1, OP_IF, OP_1, OP_ENDIF]) + taproot_info_if = taproot_construct(internal_pubkey, [("with_if", tapscript_if)]) + taproot_spk_if = taproot_info_if.scriptPubKey + + # Create and mine funding transaction + funding_tx_if = self.create_test_transaction(taproot_spk_if) + funding_txid_if = funding_tx_if.rehash() + + block_height = node.getblockcount() + 1 + block = create_block(int(node.getbestblockhash(), 16), create_coinbase(block_height), int(node.getblockheader(node.getbestblockhash())['time']) + 1) + block.vtx.append(funding_tx_if) + add_witness_commitment(block) + block.solve() + node.submitblock(block.serialize().hex()) + + # Create spending transaction + spending_tx_if = CTransaction() + spending_tx_if.vin = [CTxIn(COutPoint(int(funding_txid_if, 16), 0), nSequence=0)] + output_value = funding_tx_if.vout[0].nValue - 1000 + spending_tx_if.vout = [CTxOut(output_value, CScript([OP_1, bytes(20)]))] + + # Build witness for script-path spend + leaf_info_if = taproot_info_if.leaves["with_if"] + control_block_if = bytes([leaf_info_if.version + taproot_info_if.negflag]) + internal_pubkey + leaf_info_if.merklebranch + spending_tx_if.wit.vtxinwit.append(CTxInWitness()) + spending_tx_if.wit.vtxinwit[0].scriptWitness.stack = [tapscript_if, control_block_if] + + result_if = node.testmempoolaccept([spending_tx_if.serialize().hex()])[0] + if result_if['allowed']: + self.log.info(f" DEBUG: OP_IF was unexpectedly accepted: {result_if}") + assert_equal(result_if['allowed'], False) + self.log.info(f" ✓ Tapscript with OP_IF: REJECTED ({result_if['reject-reason']})") + + # Test 7.2: Tapscript with OP_NOTIF (invalid in Tapscript under DEPLOYMENT_REDUCED_DATA) + self.log.info(" Test 7.2: Tapscript with OP_NOTIF (should be rejected)") + + # Create a Tapscript with OP_NOTIF: OP_0 OP_NOTIF OP_1 OP_ENDIF + tapscript_notif = CScript([OP_0, OP_NOTIF, OP_1, OP_ENDIF]) + taproot_info_notif = taproot_construct(internal_pubkey, [("with_notif", tapscript_notif)]) + taproot_spk_notif = taproot_info_notif.scriptPubKey + + # Create and mine funding transaction + funding_tx_notif = self.create_test_transaction(taproot_spk_notif) + funding_txid_notif = funding_tx_notif.rehash() + + block_height = node.getblockcount() + 1 + block = create_block(int(node.getbestblockhash(), 16), create_coinbase(block_height), int(node.getblockheader(node.getbestblockhash())['time']) + 1) + block.vtx.append(funding_tx_notif) + add_witness_commitment(block) + block.solve() + node.submitblock(block.serialize().hex()) + + # Create spending transaction + spending_tx_notif = CTransaction() + spending_tx_notif.vin = [CTxIn(COutPoint(int(funding_txid_notif, 16), 0), nSequence=0)] + output_value = funding_tx_notif.vout[0].nValue - 1000 + spending_tx_notif.vout = [CTxOut(output_value, CScript([OP_1, bytes(20)]))] + + # Build witness for script-path spend + leaf_info_notif = taproot_info_notif.leaves["with_notif"] + control_block_notif = bytes([leaf_info_notif.version + taproot_info_notif.negflag]) + internal_pubkey + leaf_info_notif.merklebranch + spending_tx_notif.wit.vtxinwit.append(CTxInWitness()) + spending_tx_notif.wit.vtxinwit[0].scriptWitness.stack = [tapscript_notif, control_block_notif] + + result_notif = node.testmempoolaccept([spending_tx_notif.serialize().hex()])[0] + if result_notif['allowed']: + self.log.info(f" DEBUG: OP_NOTIF was unexpectedly accepted: {result_notif}") + assert_equal(result_notif['allowed'], False) + self.log.info(f" ✓ Tapscript with OP_NOTIF: REJECTED ({result_notif['reject-reason']})") + + def test_mandatory_flags_cannot_be_bypassed(self): + """Test that REDUCED_DATA consensus-mandatory flags cannot be bypassed via ignore_rejects. + + This test verifies that even though PolicyScriptChecks can be bypassed via ignore_rejects, + the subsequent ConsensusScriptChecks enforces consensus rules and prevents invalid transactions + from entering the mempool. + """ + self.log.info("Testing that REDUCED_DATA rules are enforced despite ignore_rejects...") + node = self.nodes[0] + + # Test case: Create a witness script with a 257-byte PUSHDATA (violates REDUCED_DATA) + self.log.info(" Test: 257-byte PUSHDATA in witness script") + + # Create a P2WSH output with a witness script containing 257-byte data push + witness_script_257 = CScript([b'\x00' * 257, OP_DROP, OP_TRUE]) + script_pubkey_257 = script_to_p2wsh_script(witness_script_257) + + # Create and fund the output + funding_tx_257 = self.create_test_transaction(script_pubkey_257) + txid_257 = node.sendrawtransaction(funding_tx_257.serialize().hex()) + self.generate(node, 1) + output_value_257 = funding_tx_257.vout[0].nValue + + # Create spending transaction that reveals the 257-byte PUSHDATA + spending_tx_257 = CTransaction() + spending_tx_257.vin = [CTxIn(COutPoint(int(txid_257, 16), 0))] + # Add padding to output to ensure tx meets minimum size requirements (82 bytes non-witness) + spending_tx_257.vout = [CTxOut(output_value_257 - 1000, CScript([OP_TRUE, OP_DROP] + [OP_TRUE] * 30))] + spending_tx_257.wit.vtxinwit.append(CTxInWitness()) + spending_tx_257.wit.vtxinwit[0].scriptWitness.stack = [witness_script_257] + spending_tx_257.rehash() + + # Test 1: Normal testmempoolaccept should reject + self.log.info(" Test 1a: Normal testmempoolaccept (should reject)") + result_normal = node.testmempoolaccept([spending_tx_257.serialize().hex()])[0] + assert_equal(result_normal['allowed'], False) + assert 'mempool-script-verify-flag' in result_normal['reject-reason'] + self.log.info(f" ✓ Normal testmempoolaccept correctly rejected: {result_normal['reject-reason']}") + + # Test 2: Try to bypass with ignore_rejects=["non-mandatory-script-verify-flag"] + # Expected: Transaction is STILL REJECTED because ConsensusScriptChecks enforces consensus rules + self.log.info(" Test 1b: testmempoolaccept with ignore_rejects") + self.log.info(" This bypasses PolicyScriptChecks but NOT ConsensusScriptChecks") + result_bypass = node.testmempoolaccept( + rawtxs=[spending_tx_257.serialize().hex()], + ignore_rejects=["non-mandatory-script-verify-flag"] + )[0] + + # The transaction should still be rejected because ConsensusScriptChecks + # uses GetBlockScriptFlags() which includes REDUCED_DATA consensus rules + self.log.info(f" Result: allowed={result_bypass['allowed']}") + assert_equal(result_bypass['allowed'], False) + self.log.info(f" ✓ Transaction correctly rejected: {result_bypass['reject-reason']}") + self.log.info(" ✓ ConsensusScriptChecks prevents bypass of REDUCED_DATA consensus rules") + + def run_test(self): + self.init_test() + + # Run all spec tests + self.test_output_script_size_limit() + self.test_pushdata_size_limit() + self.test_undefined_witness_versions() + self.test_taproot_annex_rejection() + self.test_taproot_control_block_size() + self.test_op_success_rejection() + self.test_op_if_notif_rejection() + self.test_mandatory_flags_cannot_be_bypassed() + + self.log.info("All UASF-ReducedData tests completed") + + +if __name__ == '__main__': + UASFReducedDataTest(__file__).main() diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index d626bdedd7a7..1454b923a078 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -97,6 +97,7 @@ 'feature_taproot.py', 'feature_reduced_data_temporary_deployment.py', 'feature_uasf_max_activation_height.py', + 'feature_uasf_reduced_data.py', 'feature_block.py', 'mempool_ephemeral_dust.py', 'wallet_conflicts.py --legacy-wallet', From 47fe1fcf752a43590b3a21a0a0979d127e2c3a60 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Fri, 7 Nov 2025 12:58:01 -0600 Subject: [PATCH 43/81] test: Add UTXO height-based REDUCED_DATA enforcement test --- .../feature_reduced_data_utxo_height.py | 471 ++++++++++++++++++ test/functional/test_runner.py | 1 + 2 files changed, 472 insertions(+) create mode 100644 test/functional/feature_reduced_data_utxo_height.py diff --git a/test/functional/feature_reduced_data_utxo_height.py b/test/functional/feature_reduced_data_utxo_height.py new file mode 100644 index 000000000000..e24e7ae7fcef --- /dev/null +++ b/test/functional/feature_reduced_data_utxo_height.py @@ -0,0 +1,471 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025 The Bitcoin Knots developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Test REDUCED_DATA soft fork UTXO height checking. + +This test verifies that the REDUCED_DATA deployment correctly exempts UTXOs +created before ReducedDataHeightBegin from reduced_data script validation rules, +as implemented in validation.cpp. + +Test scenarios: +1. Old UTXO (created before activation) spent during active period with violation - should be ACCEPTED (EXEMPT) +2. New UTXO (created during active period) spent with violation - should be REJECTED +3. Mixed inputs (old + new UTXOs) in same transaction +4. Boundary test: UTXO created at exactly ReducedDataHeightBegin +""" + +from io import BytesIO + +from test_framework.blocktools import ( + COINBASE_MATURITY, + create_block, + create_coinbase, + add_witness_commitment, +) +from test_framework.messages import ( + COIN, + COutPoint, + CTransaction, + CTxIn, + CTxInWitness, + CTxOut, +) +from test_framework.p2p import P2PDataStore +from test_framework.script import ( + CScript, + OP_TRUE, + OP_DROP, + hash256, +) +from test_framework.script_util import ( + script_to_p2wsh_script, +) +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import ( + assert_equal, +) +from test_framework.wallet import MiniWallet + + +# BIP9 constants for regtest +BIP9_PERIOD = 144 # blocks per period in regtest +BIP9_THRESHOLD = 108 # 75% of 144 +VERSIONBITS_TOP_BITS = 0x20000000 +REDUCED_DATA_BIT = 4 + +# REDUCED_DATA enforces MAX_SCRIPT_ELEMENT_SIZE_REDUCED (256) instead of MAX_SCRIPT_ELEMENT_SIZE (520) +MAX_ELEMENT_SIZE_STANDARD = 520 +MAX_ELEMENT_SIZE_REDUCED = 256 +VIOLATION_SIZE = 300 # Violates reduced (256) but OK for standard (520) + + +class ReducedDataUTXOHeightTest(BitcoinTestFramework): + def set_test_params(self): + self.num_nodes = 1 + self.setup_clean_chain = True + # Activate REDUCED_DATA using BIP9 with min_activation_height=288 + # Due to BIP9 design, period 0 is always DEFINED, so signaling happens in period 1 + # This activates at height 432 (start of period 3) + # Format: deployment:start:timeout:min_activation_height:max_activation_height:active_duration + # start_time=0, timeout=999999999999 (never), min_activation_height=288, max=2147483647 (INT_MAX, disabled), active_duration=2147483647 (permanent) + self.extra_args = [[ + '-vbparams=reduced_data:0:999999999999:288:2147483647:2147483647', + ]] + + def create_p2wsh_funding_and_spending_tx(self, wallet, node, witness_element_size): + """Create a P2WSH output, then a transaction spending it with custom witness size. + + Returns: + tuple: (funding_tx, spending_tx) where funding_tx creates P2WSH output, + spending_tx spends it with witness element of specified size + """ + # Create a simple witness script: OP_DROP OP_TRUE + # This allows us to put arbitrary data in the witness + witness_script = CScript([OP_DROP, OP_TRUE]) + script_pubkey = script_to_p2wsh_script(witness_script) + + # Use MiniWallet to create funding transaction to P2WSH output + funding_txid = wallet.send_to(from_node=node, scriptPubKey=script_pubkey, amount=100000)['txid'] + funding_tx_hex = node.getrawtransaction(funding_txid) + funding_tx = CTransaction() + funding_tx.deserialize(BytesIO(bytes.fromhex(funding_tx_hex))) + funding_tx.rehash() # Calculate sha256 hash after deserializing + + # Find the P2WSH output + p2wsh_vout = None + for i, vout in enumerate(funding_tx.vout): + if vout.scriptPubKey == script_pubkey: + p2wsh_vout = i + break + assert p2wsh_vout is not None, "P2WSH output not found" + + # Spending transaction: spend P2WSH output with custom witness + spending_tx = CTransaction() + spending_tx.vin = [CTxIn(COutPoint(funding_tx.sha256, p2wsh_vout))] + spending_tx.vout = [CTxOut(funding_tx.vout[p2wsh_vout].nValue - 1000, CScript([OP_TRUE]))] + + # Create witness with element of specified size + spending_tx.wit.vtxinwit.append(CTxInWitness()) + spending_tx.wit.vtxinwit[0].scriptWitness.stack = [ + b'\x42' * witness_element_size, # Data element of specified size + witness_script # Witness script + ] + spending_tx.rehash() + + return funding_tx, spending_tx + + def create_test_block(self, txs, signal=False): + """Create a block with the given transactions.""" + # Always get fresh tip and height to ensure blocks chain correctly + tip = self.nodes[0].getbestblockhash() + height = self.nodes[0].getblockcount() + 1 + tip_header = self.nodes[0].getblockheader(tip) + block_time = tip_header['time'] + 1 + block = create_block(int(tip, 16), create_coinbase(height), ntime=block_time, txlist=txs) + if signal: + block.nVersion = VERSIONBITS_TOP_BITS | (1 << REDUCED_DATA_BIT) + add_witness_commitment(block) + block.solve() + return block + + def mine_blocks(self, count, signal=False): + """Mine blocks with optional BIP9 signaling for REDUCED_DATA.""" + for _ in range(count): + block = self.create_test_block([], signal=signal) + result = self.nodes[0].submitblock(block.serialize().hex()) + if result is not None: + raise AssertionError(f"submitblock failed: {result}") + # Verify block was accepted + assert self.nodes[0].getbestblockhash() == block.hash + + def run_test(self): + node = self.nodes[0] + self.peer = node.add_p2p_connection(P2PDataStore()) + + # Use MiniWallet for easy UTXO management + wallet = MiniWallet(node) + + self.log.info("Mining blocks to activate REDUCED_DATA via BIP9...") + + # BIP9 state timeline with start_time=0: + # - Period 0 (blocks 0-143): DEFINED (cannot signal yet) + # - Period 1 (blocks 144-287): STARTED (signal here with 108/144 threshold) + # - Period 2 (blocks 288-431): LOCKED_IN (if threshold met in period 1) + # - Period 3 (blocks 432-575): ACTIVE + + # Mine through period 0 (DEFINED state) + self.log.info("Mining through period 0 (DEFINED)...") + self.generate(wallet, 144) + self.log.info(f"DEBUG: After period 0, height = {node.getblockcount()}") + + # Mine 108 signaling blocks in period 1 (STARTED state) + self.log.info("Mining 108 signaling blocks in period 1 (blocks 144-251)...") + self.mine_blocks(108, signal=True) + self.log.info(f"DEBUG: After 108 signaling blocks, height = {node.getblockcount()}") + + # Mine to end of period 1 (block 287) + self.log.info("Mining to end of period 1 (block 287)...") + self.mine_blocks(287 - 144 - 108, signal=False) + self.log.info(f"DEBUG: After period 1, height = {node.getblockcount()}") + + # Check that we're LOCKED_IN at start of period 2 + self.generate(wallet, 1) # Mine block 288 + self.log.info(f"DEBUG: After mining block 288, height = {node.getblockcount()}") + deployment_info = node.getdeploymentinfo() + rd_info = deployment_info['deployments']['reduced_data'] + if 'bip9' in rd_info: + status = rd_info['bip9']['status'] + self.log.info(f"At height {node.getblockcount()}, REDUCED_DATA status: {status}") + assert status == 'locked_in', f"Expected LOCKED_IN at block 288, got {status}" + else: + raise AssertionError("REDUCED_DATA deployment not found") + + # Mine to block 432 (start of period 3) where activation occurs + self.log.info("Mining to block 432 for activation...") + self.generate(wallet, 432 - 288) + + current_height = node.getblockcount() + + # Check activation status + deployment_info = node.getdeploymentinfo() + rd_info = deployment_info['deployments']['reduced_data'] + if 'bip9' in rd_info: + status = rd_info['bip9']['status'] + self.log.info(f"At height {current_height}, REDUCED_DATA status: {status}") + if status == 'active': + ACTIVATION_HEIGHT = rd_info['bip9']['since'] + else: + raise AssertionError(f"REDUCED_DATA not active at height {current_height}, status: {status}") + else: + raise AssertionError("REDUCED_DATA deployment not found") + + self.log.info(f"✓ REDUCED_DATA activated at height {ACTIVATION_HEIGHT}") + assert ACTIVATION_HEIGHT == 432, f"Expected activation at 432, got {ACTIVATION_HEIGHT}" + + # Initialize wallet with some coins + self.generate(wallet, COINBASE_MATURITY + 10) + current_height = node.getblockcount() + + # Now rewind to before activation to create test UTXOs + # Save the tip so we can restore later + activation_tip = node.getbestblockhash() + + # Rewind to 20 blocks before activation + target_height = ACTIVATION_HEIGHT - 20 + blocks_to_invalidate = current_height - target_height + self.log.info(f"Rewinding {blocks_to_invalidate} blocks to height {target_height}...") + for _ in range(blocks_to_invalidate): + node.invalidateblock(node.getbestblockhash()) + + assert_equal(node.getblockcount(), target_height) + + # ====================================================================== + # Test 1: Create OLD UTXO before activation + # ====================================================================== + self.log.info("Test 1: Creating P2WSH UTXO before activation height...") + + # Create P2WSH funding transaction for old UTXO + old_funding_tx, old_spending_tx = self.create_p2wsh_funding_and_spending_tx( + wallet, node, VIOLATION_SIZE + ) + + # Confirm the funding transaction in a block + block = self.create_test_block([old_funding_tx], signal=False) + node.submitblock(block.serialize().hex()) + old_utxo_height = node.getblockcount() + + self.log.info(f"Created old P2WSH UTXO at height {old_utxo_height} (< {ACTIVATION_HEIGHT})") + + # ====================================================================== + # Test 2: Mine to activation height + # ====================================================================== + self.log.info("Test 2: Mining to activation height...") + + current_height = node.getblockcount() + blocks_to_activation = ACTIVATION_HEIGHT - current_height + if blocks_to_activation > 0: + self.mine_blocks(blocks_to_activation, signal=False) + + current_height = node.getblockcount() + assert_equal(current_height, ACTIVATION_HEIGHT) + self.log.info(f"At activation height: {current_height}") + + # Verify REDUCED_DATA is active + deployment_info = node.getdeploymentinfo() + rd_info = deployment_info['deployments']['reduced_data'] + if 'bip9' in rd_info: + status = rd_info['bip9']['status'] + else: + status = 'active' if rd_info.get('active') else 'unknown' + assert status == 'active', f"Expected 'active' at height {current_height}, got '{status}'" + + # ====================================================================== + # Test 3: Create NEW UTXO at/after activation + # ====================================================================== + self.log.info("Test 3: Creating P2WSH UTXO at activation height...") + + # Create P2WSH funding transaction for new UTXO + new_funding_tx, new_spending_tx = self.create_p2wsh_funding_and_spending_tx( + wallet, node, VIOLATION_SIZE + ) + + # Confirm the funding transaction in a block + block = self.create_test_block([new_funding_tx], signal=False) + node.submitblock(block.serialize().hex()) + new_utxo_height = node.getblockcount() + + self.log.info(f"Created new P2WSH UTXO at height {new_utxo_height} (>= {ACTIVATION_HEIGHT})") + + # Mine a few more blocks + self.mine_blocks(5, signal=False) + current_height = node.getblockcount() + self.log.info(f"Current height: {current_height}") + + # ====================================================================== + # Test 4: Spend OLD UTXO with oversized witness - should be ACCEPTED + # ====================================================================== + self.log.info(f"Test 4: Spending old UTXO (height {old_utxo_height}) with {VIOLATION_SIZE}-byte witness element...") + self.log.info(f" This violates REDUCED_DATA ({MAX_ELEMENT_SIZE_REDUCED} limit) but old UTXOs should be EXEMPT") + + # Try to mine block with old_spending_tx (has 300-byte witness element) + block = self.create_test_block([old_spending_tx], signal=False) + result = node.submitblock(block.serialize().hex()) + assert result is None, f"Expected success, got: {result}" + + self.log.info(f"✓ SUCCESS: Old UTXO with {VIOLATION_SIZE}-byte witness element was ACCEPTED (correctly exempt)") + + # ====================================================================== + # Test 5: Spend NEW UTXO with oversized witness - should be REJECTED + # ====================================================================== + self.log.info(f"Test 5: Spending new UTXO (height {new_utxo_height}) with {VIOLATION_SIZE}-byte witness element...") + self.log.info(f" This violates REDUCED_DATA ({MAX_ELEMENT_SIZE_REDUCED} limit) and should be REJECTED") + + # Try to mine block with new_spending_tx (has 300-byte witness element) + block = self.create_test_block([new_spending_tx], signal=False) + result = node.submitblock(block.serialize().hex()) + assert result is not None and 'mandatory-script-verify-flag-failed' in result, f"Expected rejection, got: {result}" + + self.log.info(f"✓ SUCCESS: New UTXO with {VIOLATION_SIZE}-byte witness element was REJECTED (correctly enforced)") + + # ====================================================================== + # Test 6: Boundary test - UTXO at exactly ReducedDataHeightBegin + # ====================================================================== + self.log.info(f"Test 6: Boundary test - verifying UTXO at activation height {ACTIVATION_HEIGHT}...") + + # The new_funding_tx was confirmed at height ACTIVATION_HEIGHT+1, but let's create one AT height ACTIVATION_HEIGHT + # First, invalidate back to height ACTIVATION_HEIGHT-1 + current_tip = node.getbestblockhash() + blocks_to_invalidate = node.getblockcount() - (ACTIVATION_HEIGHT - 1) + for _ in range(blocks_to_invalidate): + node.invalidateblock(node.getbestblockhash()) + + assert_equal(node.getblockcount(), ACTIVATION_HEIGHT - 1) + self.log.info(f" Rewound to height {node.getblockcount()}") + + # Create UTXO exactly at activation height + boundary_funding_tx, boundary_spending_tx = self.create_p2wsh_funding_and_spending_tx( + wallet, node, VIOLATION_SIZE + ) + block = self.create_test_block([boundary_funding_tx], signal=False) + result = node.submitblock(block.serialize().hex()) + assert result is None, f"Expected success, got: {result}" + boundary_height = node.getblockcount() + assert_equal(boundary_height, ACTIVATION_HEIGHT) + + self.log.info(f" Created boundary UTXO at height {boundary_height} (exactly at activation)") + + # Mine a few blocks past activation + self.mine_blocks(5, signal=False) + + # Try to spend boundary UTXO - should be REJECTED (height ACTIVATION_HEIGHT >= ACTIVATION_HEIGHT) + self.log.info(f" Spending boundary UTXO with {VIOLATION_SIZE}-byte witness (should be REJECTED)") + block = self.create_test_block([boundary_spending_tx], signal=False) + result = node.submitblock(block.serialize().hex()) + assert result is not None and 'mandatory-script-verify-flag-failed' in result, f"Expected rejection, got: {result}" + + self.log.info(f"✓ SUCCESS: UTXO at exactly activation height {ACTIVATION_HEIGHT} is SUBJECT to rules (not exempt)") + + # Restore chain to where we were + node.reconsiderblock(current_tip) + + # ====================================================================== + # Test 7: Mixed inputs - one old (exempt) + one new (subject to rules) + # ====================================================================== + self.log.info("Test 7: Creating transaction with mixed inputs (old + new UTXOs)...") + + # We need fresh old and new UTXOs. Rewind to before activation again + current_tip2 = node.getbestblockhash() + blocks_to_invalidate = node.getblockcount() - (ACTIVATION_HEIGHT - 20) + for _ in range(blocks_to_invalidate): + node.invalidateblock(node.getbestblockhash()) + + # Create OLD UTXO at height before activation + old_mixed_funding, old_mixed_spending = self.create_p2wsh_funding_and_spending_tx( + wallet, node, VIOLATION_SIZE + ) + block = self.create_test_block([old_mixed_funding], signal=False) + node.submitblock(block.serialize().hex()) + old_mixed_height = node.getblockcount() + self.log.info(f" Created old UTXO at height {old_mixed_height}") + + # Mine to after activation + blocks_to_mine = ACTIVATION_HEIGHT - node.getblockcount() + 5 + self.mine_blocks(blocks_to_mine, signal=False) + + # Create NEW UTXO at height after activation + new_mixed_funding, new_mixed_spending = self.create_p2wsh_funding_and_spending_tx( + wallet, node, VIOLATION_SIZE + ) + block = self.create_test_block([new_mixed_funding], signal=False) + node.submitblock(block.serialize().hex()) + new_mixed_height = node.getblockcount() + self.log.info(f" Created new UTXO at height {new_mixed_height}") + + # Find P2WSH outputs in funding transactions + witness_script = CScript([OP_DROP, OP_TRUE]) + script_pubkey = script_to_p2wsh_script(witness_script) + + old_p2wsh_vout = None + for i, vout in enumerate(old_mixed_funding.vout): + if vout.scriptPubKey == script_pubkey: + old_p2wsh_vout = i + break + + new_p2wsh_vout = None + for i, vout in enumerate(new_mixed_funding.vout): + if vout.scriptPubKey == script_pubkey: + new_p2wsh_vout = i + break + + # Create transaction with BOTH inputs + mixed_tx = CTransaction() + mixed_tx.vin = [ + CTxIn(COutPoint(old_mixed_funding.sha256, old_p2wsh_vout)), # Old UTXO (exempt) + CTxIn(COutPoint(new_mixed_funding.sha256, new_p2wsh_vout)), # New UTXO (subject to rules) + ] + total_value = (old_mixed_funding.vout[old_p2wsh_vout].nValue + + new_mixed_funding.vout[new_p2wsh_vout].nValue - 2000) + mixed_tx.vout = [CTxOut(total_value, CScript([OP_TRUE]))] + + # Add witness for both inputs - both with 300-byte elements + mixed_tx.wit.vtxinwit = [] + + # Input 0: old UTXO (would pass alone) + wit0 = CTxInWitness() + wit0.scriptWitness.stack = [b'\x42' * VIOLATION_SIZE, witness_script] + mixed_tx.wit.vtxinwit.append(wit0) + + # Input 1: new UTXO (would fail) + wit1 = CTxInWitness() + wit1.scriptWitness.stack = [b'\x42' * VIOLATION_SIZE, witness_script] + mixed_tx.wit.vtxinwit.append(wit1) + + mixed_tx.rehash() + + self.log.info(f" Mixed tx: old UTXO (height {old_mixed_height}, exempt) + new UTXO (height {new_mixed_height}, subject)") + self.log.info(f" Both inputs have {VIOLATION_SIZE}-byte witness elements") + + # Try to mine block - should REJECT because new input violates + self.mine_blocks(2, signal=False) + block = self.create_test_block([mixed_tx], signal=False) + result = node.submitblock(block.serialize().hex()) + assert result is not None and 'mandatory-script-verify-flag-failed' in result, f"Expected rejection, got: {result}" + + self.log.info(f"✓ SUCCESS: Mixed transaction REJECTED (new input violated rules, even though old input was exempt)") + + # Restore chain + node.reconsiderblock(current_tip2) + + # ====================================================================== + # Summary + # ====================================================================== + self.log.info(f""" + ============================================================ + TEST SUMMARY - UTXO Height-Based REDUCED_DATA Enforcement + ============================================================ + + ✓ Test 1-3: Setup old and new UTXOs at correct heights + ✓ Test 4: Old UTXO (height < {ACTIVATION_HEIGHT}) is EXEMPT - 300-byte witness ACCEPTED + ✓ Test 5: New UTXO (height >= {ACTIVATION_HEIGHT}) is SUBJECT - 300-byte witness REJECTED + ✓ Test 6: Boundary condition - UTXO at exactly height {ACTIVATION_HEIGHT} is SUBJECT + ✓ Test 7: Mixed inputs - transaction rejected if ANY input violates + + Key validations: + • REDUCED_DATA activated via BIP9 signaling at height {ACTIVATION_HEIGHT} + • UTXOs created before activation height are EXEMPT from rules + • UTXOs created at/after activation height are SUBJECT to rules + • Per-input validation flags work correctly (validation.cpp) + • Boundary at activation height uses >= operator (not >) + + This confirms the implementation of UTXO height exemption: + "Exempt inputs spending UTXOs prior to ReducedDataHeightBegin from + reduced_data script validation rules" + + All 7 tests passed! + ============================================================ + """) + + +if __name__ == '__main__': + ReducedDataUTXOHeightTest(__file__).main() diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 1454b923a078..3723f84c9a80 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -152,6 +152,7 @@ 'p2p_headers_sync_with_minchainwork.py', 'p2p_feefilter.py', 'feature_csv_activation.py', + 'feature_reduced_data_utxo_height.py', 'p2p_sendheaders.py', 'feature_config_args.py', 'wallet_listtransactions.py --legacy-wallet', From 83f36dfc76821ffc6bc4faef4db25e1646c2fa82 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Fri, 7 Nov 2025 17:33:04 -0600 Subject: [PATCH 44/81] test: Update tests for REDUCED_DATA consensus limits Adapt unit tests to comply with REDUCED_DATA restrictions: - Add REDUCED_DATA flag to mapFlagNames in transaction_tests - Update witness test from 520-byte to 256-byte push limit - Accept SCRIPT_ERR_PUSH_SIZE in miniscript satisfaction tests - Update Taproot tree depth tests from 128 to 7 levels - Fix descriptor error message to report correct nesting limit (7) REDUCED_DATA enforces MAX_SCRIPT_ELEMENT_SIZE_REDUCED (256 bytes) and TAPROOT_CONTROL_MAX_NODE_COUNT_REDUCED (7 levels) at the policy level via STANDARD_SCRIPT_VERIFY_FLAGS. --- src/script/descriptor.cpp | 2 +- src/test/data/tx_valid.json | 6 +++--- src/test/miniscript_tests.cpp | 31 ++++++++++++++++++++++++------ src/test/script_standard_tests.cpp | 7 ++++--- src/test/transaction_tests.cpp | 1 + 5 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/script/descriptor.cpp b/src/script/descriptor.cpp index d17157968aa4..0aadb7606ed3 100644 --- a/src/script/descriptor.cpp +++ b/src/script/descriptor.cpp @@ -1967,7 +1967,7 @@ std::vector> ParseScript(uint32_t& key_exp_index while (Const("{", expr)) { branches.push_back(false); // new left branch if (branches.size() > TAPROOT_CONTROL_MAX_NODE_COUNT_REDUCED) { - error = strprintf("tr() supports at most %i nesting levels", TAPROOT_CONTROL_MAX_NODE_COUNT); + error = strprintf("tr() supports at most %i nesting levels", TAPROOT_CONTROL_MAX_NODE_COUNT_REDUCED); return {}; } } 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/miniscript_tests.cpp b/src/test/miniscript_tests.cpp index 077ef498b2d9..27a2a5b57b4f 100644 --- a/src/test/miniscript_tests.cpp +++ b/src/test/miniscript_tests.cpp @@ -388,13 +388,25 @@ 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 || + 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), - // 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) || + (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)); } if (mal_success && (!nonmal_success || witness_mal.stack != witness_nonmal.stack)) { @@ -402,8 +414,15 @@ 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 || + 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); } if (node->IsSane()) { 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}; diff --git a/src/test/transaction_tests.cpp b/src/test/transaction_tests.cpp index 5844ab23bc8d..5b8fafeb0611 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 6f84587655596286b54a5e6d65333e284ef63699 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Fri, 7 Nov 2025 17:33:26 -0600 Subject: [PATCH 45/81] test: Replace thresh() with and_v() in descriptor test Replace thresh(2,pk(...),s:pk(...),adv:older(42)) with and_v(v:pk(...),pk(...)) because thresh() uses OP_IF opcodes which are completely forbidden in Tapscript when REDUCED_DATA is active (see src/script/interpreter.cpp:621-623). The and_v() construction provides equivalent 2-of-2 multisig functionality without conditional branching, making it compatible with REDUCED_DATA restrictions. Also update line 1010 test to expect "tr() supports at most 7 nesting levels" error instead of multi() error, as the test's 22 opening braces exceed REDUCED_DATA's 7-level limit before the parser can discover the multi() error. --- src/test/descriptor_tests.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/test/descriptor_tests.cpp b/src/test/descriptor_tests.cpp index 63c53a842c1f..223d2934acf5 100644 --- a/src/test/descriptor_tests.cpp +++ b/src/test/descriptor_tests.cpp @@ -1006,7 +1006,8 @@ 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"); + // 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"); // No hybrid keys 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. From fc0d8c7e50a20f2d70ee13393aaa9ae9f5082ad0 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Fri, 7 Nov 2025 17:33:37 -0600 Subject: [PATCH 46/81] test: Add NODE_BIP444 service flag to peer connection tests Add NODE_BIP444 flag to GetDesirableServiceFlags assertions in peerman_tests and to service flags in denialofservice_tests and net_tests peer setup. NODE_BIP444 (bit 27) signals BIP444/REDUCED_DATA enforcement and is now included in desirable service flags alongside NODE_NETWORK and NODE_WITNESS for peer connections. --- src/test/denialofservice_tests.cpp | 4 ++-- src/test/net_tests.cpp | 4 ++-- src/test/peerman_tests.cpp | 14 +++++++------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/test/denialofservice_tests.cpp b/src/test/denialofservice_tests.cpp index 9ee7e9c9fe24..c415b4471737 100644 --- a/src/test/denialofservice_tests.cpp +++ b/src/test/denialofservice_tests.cpp @@ -67,8 +67,8 @@ BOOST_AUTO_TEST_CASE(outbound_slow_chain_eviction) connman.Handshake( /*node=*/dummyNode1, /*successfully_connected=*/true, - /*remote_services=*/ServiceFlags(NODE_NETWORK | NODE_WITNESS), - /*local_services=*/ServiceFlags(NODE_NETWORK | NODE_WITNESS), + /*remote_services=*/ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_BIP444), + /*local_services=*/ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_BIP444), /*version=*/PROTOCOL_VERSION, /*relay_txs=*/true); diff --git a/src/test/net_tests.cpp b/src/test/net_tests.cpp index 62e541b5b392..0106b36c8092 100644 --- a/src/test/net_tests.cpp +++ b/src/test/net_tests.cpp @@ -827,7 +827,7 @@ BOOST_AUTO_TEST_CASE(initial_advertise_from_version_message) /*conn_type_in=*/ConnectionType::OUTBOUND_FULL_RELAY, /*inbound_onion=*/false}; - const uint64_t services{NODE_NETWORK | NODE_WITNESS}; + const uint64_t services{NODE_NETWORK | NODE_WITNESS | NODE_BIP444}; const int64_t time{0}; // Force ChainstateManager::IsInitialBlockDownload() to return false. @@ -835,7 +835,7 @@ BOOST_AUTO_TEST_CASE(initial_advertise_from_version_message) auto& chainman = static_cast(*m_node.chainman); chainman.JumpOutOfIbd(); - m_node.peerman->InitializeNode(peer, NODE_NETWORK); + m_node.peerman->InitializeNode(peer, ServiceFlags(NODE_NETWORK | NODE_BIP444)); std::atomic interrupt_dummy{false}; std::chrono::microseconds time_received_dummy{0}; diff --git a/src/test/peerman_tests.cpp b/src/test/peerman_tests.cpp index 64b13fa3cc1a..38e782671c7a 100644 --- a/src/test/peerman_tests.cpp +++ b/src/test/peerman_tests.cpp @@ -36,7 +36,7 @@ BOOST_AUTO_TEST_CASE(connections_desirable_service_flags) // Check we start connecting to full nodes ServiceFlags peer_flags{NODE_WITNESS | NODE_NETWORK_LIMITED}; - BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK | NODE_WITNESS)); + BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_BIP444)); // Make peerman aware of the initial best block and verify we accept limited peers when we start close to the tip time. auto tip = WITH_LOCK(::cs_main, return m_node.chainman->ActiveChain().Tip()); @@ -45,15 +45,15 @@ BOOST_AUTO_TEST_CASE(connections_desirable_service_flags) peerman->SetBestBlock(tip_block_height, std::chrono::seconds{tip_block_time}); SetMockTime(tip_block_time + 1); // Set node time to tip time - BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS)); + BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP444)); // Check we don't disallow limited peers connections when we are behind but still recoverable (below the connection safety window) SetMockTime(GetTime() + std::chrono::seconds{consensus.nPowTargetSpacing * (NODE_NETWORK_LIMITED_ALLOW_CONN_BLOCKS - 1)}); - BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS)); + BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP444)); // Check we disallow limited peers connections when we are further than the limited peers safety window SetMockTime(GetTime() + std::chrono::seconds{consensus.nPowTargetSpacing * 2}); - BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK | NODE_WITNESS)); + BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_BIP444)); // By now, we tested that the connections desirable services flags change based on the node's time proximity to the tip. // Now, perform the same tests for when the node receives a block. @@ -62,15 +62,15 @@ BOOST_AUTO_TEST_CASE(connections_desirable_service_flags) // First, verify a block in the past doesn't enable limited peers connections // At this point, our time is (NODE_NETWORK_LIMITED_ALLOW_CONN_BLOCKS + 1) * 10 minutes ahead the tip's time. mineBlock(m_node, /*block_time=*/std::chrono::seconds{tip_block_time + 1}); - BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK | NODE_WITNESS)); + BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_BIP444)); // Verify a block close to the tip enables limited peers connections mineBlock(m_node, /*block_time=*/GetTime()); - BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS)); + BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP444)); // Lastly, verify the stale tip checks can disallow limited peers connections after not receiving blocks for a prolonged period. SetMockTime(GetTime() + std::chrono::seconds{consensus.nPowTargetSpacing * NODE_NETWORK_LIMITED_ALLOW_CONN_BLOCKS + 1}); - BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK | NODE_WITNESS)); + BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_BIP444)); } BOOST_AUTO_TEST_SUITE_END() From 13187de3b14af0699311b96a198da6ff94c9c094 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Sat, 8 Nov 2025 18:41:42 -0600 Subject: [PATCH 47/81] Change all BIP444 references to UASF-ReducedData --- src/bitcoin-cli.cpp | 4 +-- src/clientversion.cpp | 2 +- src/init.cpp | 2 +- src/net_processing.cpp | 6 ++--- src/protocol.cpp | 2 +- src/protocol.h | 4 +-- src/rpc/net.cpp | 2 +- src/test/denialofservice_tests.cpp | 4 +-- src/test/net_tests.cpp | 4 +-- src/test/peerman_tests.cpp | 14 +++++------ test/functional/feature_uasf_reduced_data.py | 4 +-- test/functional/p2p_addr_relay.py | 4 +-- test/functional/p2p_handshake.py | 12 ++++----- test/functional/p2p_node_network_limited.py | 4 +-- test/functional/rpc_net.py | 26 ++++++++++---------- test/functional/test_framework/messages.py | 2 +- test/functional/test_framework/p2p.py | 4 +-- 17 files changed, 50 insertions(+), 50 deletions(-) diff --git a/src/bitcoin-cli.cpp b/src/bitcoin-cli.cpp index 2770cc809479..8164cc6cee4c 100644 --- a/src/bitcoin-cli.cpp +++ b/src/bitcoin-cli.cpp @@ -477,7 +477,7 @@ class NetinfoRequestHandler : public BaseRequestHandler std::string str; for (size_t i = 0; i < services.size(); ++i) { const std::string s{services[i].get_str()}; - str += s == "NETWORK_LIMITED" ? 'l' : s == "P2P_V2" ? '2' : s == "BIP444?" ? '1' : ToLower(s[0]); + str += s == "NETWORK_LIMITED" ? 'l' : s == "P2P_V2" ? '2' : s == "UASF_REDUCED_DATA?" ? '4' : ToLower(s[0]); } return str; } @@ -713,7 +713,7 @@ class NetinfoRequestHandler : public BaseRequestHandler " \"c\" - COMPACT_FILTERS: peer can handle basic block filter requests (see BIPs 157 and 158)\n" " \"l\" - NETWORK_LIMITED: peer limited to serving only the last 288 blocks (~2 days)\n" " \"2\" - P2P_V2: peer supports version 2 P2P transport protocol, as defined in BIP 324\n" - " \"1\" - BIP444? peer enforces the BIP444 User-Activated SoftFork\n" + " \"4\" - UASF_REDUCED_DATA? peer enforces the ReducedData User-Activated SoftFork\n" " \"u\" - UNKNOWN: unrecognized bit flag\n" " v Version of transport protocol used for the connection\n" " mping Minimum observed ping time, in milliseconds (ms)\n" diff --git a/src/clientversion.cpp b/src/clientversion.cpp index fd5e3da907c2..60d3088f2804 100644 --- a/src/clientversion.cpp +++ b/src/clientversion.cpp @@ -66,7 +66,7 @@ std::string FormatSubVersion(const std::string& name, int nClientVersion, const { std::string comments_str; if (!comments.empty()) comments_str = strprintf("(%s)", Join(comments, "; ")); - return strprintf("/%s:%s%s/%s/", name, FormatVersion(nClientVersion), comments_str, "UASF-BIP444:0.1"); + return strprintf("/%s:%s%s/%s/", name, FormatVersion(nClientVersion), comments_str, "UASF-ReducedData:0.1"); } std::string CopyrightHolders(const std::string& strPrefix) diff --git a/src/init.cpp b/src/init.cpp index 57acfb30b204..a77be1356fb4 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -837,7 +837,7 @@ namespace { // Variables internal to initialization process only int nMaxConnections; int available_fds; -ServiceFlags g_local_services = ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP444); +ServiceFlags g_local_services = ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_UASF_REDUCED_DATA); int64_t peer_connect_timeout; std::set g_enabled_filter_types; diff --git a/src/net_processing.cpp b/src/net_processing.cpp index c0bdd823b4b3..3537cd07fdd9 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -1635,14 +1635,14 @@ bool PeerManagerImpl::HasAllDesirableServiceFlags(ServiceFlags services) const ServiceFlags PeerManagerImpl::GetDesirableServiceFlags(ServiceFlags services) const { - // We want to preferentially peer with other nodes that enforce BIP444, in case of a chain split + // We want to preferentially peer with other nodes that enforce UASF-ReducedData, in case of a chain split if (services & NODE_NETWORK_LIMITED) { // Limited peers are desirable when we are close to the tip. if (ApproximateBestBlockDepth() < NODE_NETWORK_LIMITED_ALLOW_CONN_BLOCKS) { - return ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP444); + return ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_UASF_REDUCED_DATA); } } - return ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_BIP444); + return ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA); } PeerRef PeerManagerImpl::GetPeerRef(NodeId id) const diff --git a/src/protocol.cpp b/src/protocol.cpp index 1f1e4e3b6266..8e3c22e4dee0 100644 --- a/src/protocol.cpp +++ b/src/protocol.cpp @@ -99,7 +99,7 @@ static std::string serviceFlagToStr(size_t bit) case NODE_COMPACT_FILTERS: return "COMPACT_FILTERS"; case NODE_NETWORK_LIMITED: return "NETWORK_LIMITED"; case NODE_P2P_V2: return "P2P_V2"; - case NODE_BIP444: return "BIP444?"; + case NODE_UASF_REDUCED_DATA: return "UASF_REDUCED_DATA?"; // Not using default, so we get warned when a case is missing } diff --git a/src/protocol.h b/src/protocol.h index 0745e5349602..ac98a4d5aa39 100644 --- a/src/protocol.h +++ b/src/protocol.h @@ -337,8 +337,8 @@ enum ServiceFlags : uint64_t { // do not actually support. Other service bits should be allocated via the // BIP process. - // NODE_BIP444 means the node enforces BIP 444 rules as applicable - NODE_BIP444 = (1 << 27), + // NODE_UASF_REDUCED_DATA means the node enforces UASFReducedData rules as applicable + NODE_UASF_REDUCED_DATA = (1 << 27), }; /** diff --git a/src/rpc/net.cpp b/src/rpc/net.cpp index 1e1506aed541..8f99c6ba319b 100644 --- a/src/rpc/net.cpp +++ b/src/rpc/net.cpp @@ -991,7 +991,7 @@ static RPCHelpMan addpeeraddress() if (net_addr.has_value()) { CService service{net_addr.value(), port}; - CAddress address{MaybeFlipIPv6toCJDNS(service), ServiceFlags{NODE_NETWORK | NODE_WITNESS | NODE_BIP444}}; + CAddress address{MaybeFlipIPv6toCJDNS(service), ServiceFlags{NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA}}; address.nTime = Now(); // The source address is set equal to the address. This is equivalent to the peer // announcing itself. diff --git a/src/test/denialofservice_tests.cpp b/src/test/denialofservice_tests.cpp index c415b4471737..83cc4c16f364 100644 --- a/src/test/denialofservice_tests.cpp +++ b/src/test/denialofservice_tests.cpp @@ -67,8 +67,8 @@ BOOST_AUTO_TEST_CASE(outbound_slow_chain_eviction) connman.Handshake( /*node=*/dummyNode1, /*successfully_connected=*/true, - /*remote_services=*/ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_BIP444), - /*local_services=*/ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_BIP444), + /*remote_services=*/ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA), + /*local_services=*/ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA), /*version=*/PROTOCOL_VERSION, /*relay_txs=*/true); diff --git a/src/test/net_tests.cpp b/src/test/net_tests.cpp index 0106b36c8092..54cd00ee37c9 100644 --- a/src/test/net_tests.cpp +++ b/src/test/net_tests.cpp @@ -827,7 +827,7 @@ BOOST_AUTO_TEST_CASE(initial_advertise_from_version_message) /*conn_type_in=*/ConnectionType::OUTBOUND_FULL_RELAY, /*inbound_onion=*/false}; - const uint64_t services{NODE_NETWORK | NODE_WITNESS | NODE_BIP444}; + const uint64_t services{NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA}; const int64_t time{0}; // Force ChainstateManager::IsInitialBlockDownload() to return false. @@ -835,7 +835,7 @@ BOOST_AUTO_TEST_CASE(initial_advertise_from_version_message) auto& chainman = static_cast(*m_node.chainman); chainman.JumpOutOfIbd(); - m_node.peerman->InitializeNode(peer, ServiceFlags(NODE_NETWORK | NODE_BIP444)); + m_node.peerman->InitializeNode(peer, ServiceFlags(NODE_NETWORK | NODE_UASF_REDUCED_DATA)); std::atomic interrupt_dummy{false}; std::chrono::microseconds time_received_dummy{0}; diff --git a/src/test/peerman_tests.cpp b/src/test/peerman_tests.cpp index 38e782671c7a..88a216ab46c3 100644 --- a/src/test/peerman_tests.cpp +++ b/src/test/peerman_tests.cpp @@ -36,7 +36,7 @@ BOOST_AUTO_TEST_CASE(connections_desirable_service_flags) // Check we start connecting to full nodes ServiceFlags peer_flags{NODE_WITNESS | NODE_NETWORK_LIMITED}; - BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_BIP444)); + BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA)); // Make peerman aware of the initial best block and verify we accept limited peers when we start close to the tip time. auto tip = WITH_LOCK(::cs_main, return m_node.chainman->ActiveChain().Tip()); @@ -45,15 +45,15 @@ BOOST_AUTO_TEST_CASE(connections_desirable_service_flags) peerman->SetBestBlock(tip_block_height, std::chrono::seconds{tip_block_time}); SetMockTime(tip_block_time + 1); // Set node time to tip time - BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP444)); + BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_UASF_REDUCED_DATA)); // Check we don't disallow limited peers connections when we are behind but still recoverable (below the connection safety window) SetMockTime(GetTime() + std::chrono::seconds{consensus.nPowTargetSpacing * (NODE_NETWORK_LIMITED_ALLOW_CONN_BLOCKS - 1)}); - BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP444)); + BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_UASF_REDUCED_DATA)); // Check we disallow limited peers connections when we are further than the limited peers safety window SetMockTime(GetTime() + std::chrono::seconds{consensus.nPowTargetSpacing * 2}); - BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_BIP444)); + BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA)); // By now, we tested that the connections desirable services flags change based on the node's time proximity to the tip. // Now, perform the same tests for when the node receives a block. @@ -62,15 +62,15 @@ BOOST_AUTO_TEST_CASE(connections_desirable_service_flags) // First, verify a block in the past doesn't enable limited peers connections // At this point, our time is (NODE_NETWORK_LIMITED_ALLOW_CONN_BLOCKS + 1) * 10 minutes ahead the tip's time. mineBlock(m_node, /*block_time=*/std::chrono::seconds{tip_block_time + 1}); - BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_BIP444)); + BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA)); // Verify a block close to the tip enables limited peers connections mineBlock(m_node, /*block_time=*/GetTime()); - BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP444)); + BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_UASF_REDUCED_DATA)); // Lastly, verify the stale tip checks can disallow limited peers connections after not receiving blocks for a prolonged period. SetMockTime(GetTime() + std::chrono::seconds{consensus.nPowTargetSpacing * NODE_NETWORK_LIMITED_ALLOW_CONN_BLOCKS + 1}); - BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_BIP444)); + BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA)); } BOOST_AUTO_TEST_SUITE_END() diff --git a/test/functional/feature_uasf_reduced_data.py b/test/functional/feature_uasf_reduced_data.py index 27ba999679c6..2bfa38db9b91 100755 --- a/test/functional/feature_uasf_reduced_data.py +++ b/test/functional/feature_uasf_reduced_data.py @@ -2,7 +2,7 @@ # Copyright (c) 2025 The Bitcoin Core developers # Distributed under the MIT software license, see the accompanying # file COPYING or http://www.opensource.org/licenses/mit-license.php. -"""Test UASF-ReducedData consensus rules (BIP444). +"""Test UASF-ReducedData consensus rules. This test verifies all 7 consensus rules enforced by DEPLOYMENT_REDUCED_DATA: @@ -97,7 +97,7 @@ import struct -# Constants from BIP444 +# Constants from UASF-ReducedData specification MAX_OUTPUT_SCRIPT_SIZE = 34 MAX_OUTPUT_DATA_SIZE = 83 MAX_SCRIPT_ELEMENT_SIZE_REDUCED = 256 diff --git a/test/functional/p2p_addr_relay.py b/test/functional/p2p_addr_relay.py index 3ea613613d6f..e541fa370bb3 100755 --- a/test/functional/p2p_addr_relay.py +++ b/test/functional/p2p_addr_relay.py @@ -11,7 +11,7 @@ from test_framework.messages import ( CAddress, - NODE_BIP444, + NODE_UASF_REDUCED_DATA, NODE_NETWORK, NODE_WITNESS, msg_addr, @@ -55,7 +55,7 @@ def on_addr(self, message): if self.test_addr_contents: # relay_tests checks the content of the addr messages match # expectations based on the message creation in setup_addr_msg - assert_equal(addr.nServices, NODE_NETWORK | NODE_WITNESS | NODE_BIP444) + assert_equal(addr.nServices, NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA) if not 8333 <= addr.port < 8343: raise AssertionError("Invalid addr.port of {} (8333-8342 expected)".format(addr.port)) assert addr.ip.startswith('123.123.') diff --git a/test/functional/p2p_handshake.py b/test/functional/p2p_handshake.py index 9d11ce650c14..3c13672252cf 100755 --- a/test/functional/p2p_handshake.py +++ b/test/functional/p2p_handshake.py @@ -10,7 +10,7 @@ from test_framework.test_framework import BitcoinTestFramework from test_framework.messages import ( - NODE_BIP444, + NODE_UASF_REDUCED_DATA, NODE_NETWORK, NODE_NETWORK_LIMITED, NODE_NONE, @@ -25,8 +25,8 @@ # the desirable service flags for pruned peers are dynamic and only apply if # 1. the peer's service flag NODE_NETWORK_LIMITED is set *and* # 2. the local chain is close to the tip (<24h) -DESIRABLE_SERVICE_FLAGS_FULL = NODE_NETWORK | NODE_WITNESS | NODE_BIP444 -DESIRABLE_SERVICE_FLAGS_PRUNED = NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP444 +DESIRABLE_SERVICE_FLAGS_FULL = NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA +DESIRABLE_SERVICE_FLAGS_PRUNED = NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_UASF_REDUCED_DATA class P2PHandshakeTest(BitcoinTestFramework): @@ -75,15 +75,15 @@ def run_test(self): self.log.info("Check that lacking desired service flags leads to disconnect (non-pruned peers)") self.test_desirable_service_flags(node, [NODE_NONE, NODE_NETWORK, NODE_WITNESS], DESIRABLE_SERVICE_FLAGS_FULL, expect_disconnect=True) - self.test_desirable_service_flags(node, [NODE_NETWORK | NODE_WITNESS | NODE_BIP444], + self.test_desirable_service_flags(node, [NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA], DESIRABLE_SERVICE_FLAGS_FULL, expect_disconnect=False) self.log.info("Check that limited peers are only desired if the local chain is close to the tip (<24h)") self.generate_at_mocktime(int(time.time()) - 25 * 3600) # tip outside the 24h window, should fail - self.test_desirable_service_flags(node, [NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP444], + self.test_desirable_service_flags(node, [NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_UASF_REDUCED_DATA], DESIRABLE_SERVICE_FLAGS_FULL, expect_disconnect=True) self.generate_at_mocktime(int(time.time()) - 23 * 3600) # tip inside the 24h window, should succeed - self.test_desirable_service_flags(node, [NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_BIP444], + self.test_desirable_service_flags(node, [NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_UASF_REDUCED_DATA], DESIRABLE_SERVICE_FLAGS_PRUNED, expect_disconnect=False) self.log.info("Check that feeler connections get disconnected immediately") diff --git a/test/functional/p2p_node_network_limited.py b/test/functional/p2p_node_network_limited.py index 38708588b89a..91c51784f6a6 100755 --- a/test/functional/p2p_node_network_limited.py +++ b/test/functional/p2p_node_network_limited.py @@ -11,7 +11,7 @@ from test_framework.messages import ( CInv, MSG_BLOCK, - NODE_BIP444, + NODE_UASF_REDUCED_DATA, NODE_NETWORK_LIMITED, NODE_P2P_V2, NODE_WITNESS, @@ -119,7 +119,7 @@ def test_avoid_requesting_historical_blocks(self): def run_test(self): node = self.nodes[0].add_p2p_connection(P2PIgnoreInv()) - expected_services = NODE_WITNESS | NODE_NETWORK_LIMITED | NODE_BIP444 + expected_services = NODE_WITNESS | NODE_NETWORK_LIMITED | NODE_UASF_REDUCED_DATA if self.options.v2transport: expected_services |= NODE_P2P_V2 diff --git a/test/functional/rpc_net.py b/test/functional/rpc_net.py index 2bcad54fad2e..24fce8c00422 100755 --- a/test/functional/rpc_net.py +++ b/test/functional/rpc_net.py @@ -14,7 +14,7 @@ import test_framework.messages from test_framework.messages import ( - NODE_BIP444, + NODE_UASF_REDUCED_DATA, NODE_NETWORK, NODE_WITNESS, ) @@ -315,8 +315,8 @@ def test_getnodeaddresses(self): assert_greater_than(10000, len(node_addresses)) for a in node_addresses: assert_greater_than(a["time"], 1527811200) # 1st June 2018 - # addpeeraddress stores addresses with default services (NODE_NETWORK | NODE_WITNESS | NODE_BIP444) - assert_equal(a["services"], NODE_NETWORK | NODE_WITNESS | NODE_BIP444) + # addpeeraddress stores addresses with default services (NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA) + assert_equal(a["services"], NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA) assert a["address"] in imported_addrs assert_equal(a["port"], 8333) assert_equal(a["network"], "ipv4") @@ -327,8 +327,8 @@ def test_getnodeaddresses(self): assert_equal(res[0]["address"], ipv6_addr) assert_equal(res[0]["network"], "ipv6") assert_equal(res[0]["port"], 8333) - # addpeeraddress stores addresses with default services (NODE_NETWORK | NODE_WITNESS | NODE_BIP444) - assert_equal(res[0]["services"], NODE_NETWORK | NODE_WITNESS | NODE_BIP444) + # addpeeraddress stores addresses with default services (NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA) + assert_equal(res[0]["services"], NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA) # Test for the absence of onion, I2P and CJDNS addresses. for network in ["onion", "i2p", "cjdns"]: @@ -506,7 +506,7 @@ def check_getrawaddrman_entries(expected): "bucket_position": "82/8", "address": "2.0.0.0", "port": 8333, - "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP444, + "services": NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA, "network": "ipv4", "source": "2.0.0.0", "source_network": "ipv4", @@ -515,7 +515,7 @@ def check_getrawaddrman_entries(expected): "bucket_position": "336/24", "address": "fc00:1:2:3:4:5:6:7", "port": 8333, - "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP444, + "services": NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA, "network": "cjdns", "source": "fc00:1:2:3:4:5:6:7", "source_network": "cjdns", @@ -524,7 +524,7 @@ def check_getrawaddrman_entries(expected): "bucket_position": "963/46", "address": "c4gfnttsuwqomiygupdqqqyy5y5emnk5c73hrfvatri67prd7vyq.b32.i2p", "port": 8333, - "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP444, + "services": NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA, "network": "i2p", "source": "c4gfnttsuwqomiygupdqqqyy5y5emnk5c73hrfvatri67prd7vyq.b32.i2p", "source_network": "i2p", @@ -532,7 +532,7 @@ def check_getrawaddrman_entries(expected): { "bucket_position": "613/6", "address": "2803:0:1234:abcd::1", - "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP444, + "services": NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA, "network": "ipv6", "source": "2803:0:1234:abcd::1", "source_network": "ipv6", @@ -544,7 +544,7 @@ def check_getrawaddrman_entries(expected): "bucket_position": "6/33", "address": "1.2.3.4", "port": 8333, - "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP444, + "services": NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA, "network": "ipv4", "source": "1.2.3.4", "source_network": "ipv4", @@ -553,7 +553,7 @@ def check_getrawaddrman_entries(expected): "bucket_position": "197/34", "address": "1233:3432:2434:2343:3234:2345:6546:4534", "port": 8333, - "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP444, + "services": NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA, "network": "ipv6", "source": "1233:3432:2434:2343:3234:2345:6546:4534", "source_network": "ipv6", @@ -562,7 +562,7 @@ def check_getrawaddrman_entries(expected): "bucket_position": "72/61", "address": "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion", "port": 8333, - "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP444, + "services": NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA, "network": "onion", "source": "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion", "source_network": "onion" @@ -570,7 +570,7 @@ def check_getrawaddrman_entries(expected): { "bucket_position": "139/46", "address": "nrfj6inpyf73gpkyool35hcmne5zwfmse3jl3aw23vk7chdemalyaqad.onion", - "services": NODE_NETWORK | NODE_WITNESS | NODE_BIP444, + "services": NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA, "network": "onion", "source": "nrfj6inpyf73gpkyool35hcmne5zwfmse3jl3aw23vk7chdemalyaqad.onion", "source_network": "onion", diff --git a/test/functional/test_framework/messages.py b/test/functional/test_framework/messages.py index a3221c89e4be..174952b0a305 100755 --- a/test/functional/test_framework/messages.py +++ b/test/functional/test_framework/messages.py @@ -55,7 +55,7 @@ NODE_COMPACT_FILTERS = (1 << 6) NODE_NETWORK_LIMITED = (1 << 10) NODE_P2P_V2 = (1 << 11) -NODE_BIP444 = (1 << 27) +NODE_UASF_REDUCED_DATA = (1 << 27) MSG_TX = 1 MSG_BLOCK = 2 diff --git a/test/functional/test_framework/p2p.py b/test/functional/test_framework/p2p.py index d71b237bc614..395d9e77618b 100755 --- a/test/functional/test_framework/p2p.py +++ b/test/functional/test_framework/p2p.py @@ -73,7 +73,7 @@ msg_wtxidrelay, NODE_NETWORK, NODE_WITNESS, - NODE_BIP444, + NODE_UASF_REDUCED_DATA, MAGIC_BYTES, sha256, ) @@ -96,7 +96,7 @@ # Version 70016 supports wtxid relay P2P_VERSION = 70016 # The services that this test framework offers in its `version` message -P2P_SERVICES = NODE_NETWORK | NODE_WITNESS | NODE_BIP444 +P2P_SERVICES = NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA # The P2P user agent string that this test framework sends in its `version` message P2P_SUBVERSION = "/python-p2p-tester:0.0.3/" # Value for relay that this test framework sends in its `version` message From 88904be9695722b811bbddcdff9fc4e7cbb6f790 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Wed, 19 Nov 2025 18:49:20 -0600 Subject: [PATCH 48/81] Add mainnet configuration for REDUCED_DATA deployment --- src/kernel/chainparams.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/kernel/chainparams.cpp b/src/kernel/chainparams.cpp index 80f0635a2193..7f7ebd550167 100644 --- a/src/kernel/chainparams.cpp +++ b/src/kernel/chainparams.cpp @@ -104,7 +104,7 @@ class CMainParams : public CChainParams { consensus.fPowAllowMinDifficultyBlocks = false; consensus.enforce_BIP94 = false; consensus.fPowNoRetargeting = false; - consensus.nRuleChangeActivationThreshold = 1815; // 90% of 2016 + consensus.nRuleChangeActivationThreshold = 1109; // 55% of 2016 consensus.nMinerConfirmationWindow = 2016; // nPowTargetTimespan / nPowTargetSpacing consensus.vDeployments[Consensus::DEPLOYMENT_TESTDUMMY].bit = 28; consensus.vDeployments[Consensus::DEPLOYMENT_TESTDUMMY].nStartTime = Consensus::BIP9Deployment::NEVER_ACTIVE; @@ -117,9 +117,18 @@ 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 + consensus.nMinimumChainWork = uint256{"0000000000000000000000000000000000000000b1f3b93b65b16d035a82be84"}; consensus.defaultAssumeValid = uint256{"00000000000000000001b658dd1120e82e66d2790811f89ede9742ada3ed6d77"}; // 886157 + // Deployment of UASF-ReducedData (temporary UASF) + 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 + /** * The message start string is designed to be unlikely to occur in normal data. * The characters are rarely used upper ASCII, not valid as UTF-8, and produce From ed83b72537c6181dc4a16916bf86160ec78a96cd Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Fri, 21 Nov 2025 13:43:39 -0600 Subject: [PATCH 49/81] Remove UASF uacomment for mainline Core --- src/clientversion.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/clientversion.cpp b/src/clientversion.cpp index 60d3088f2804..10e9472b84ba 100644 --- a/src/clientversion.cpp +++ b/src/clientversion.cpp @@ -66,7 +66,7 @@ std::string FormatSubVersion(const std::string& name, int nClientVersion, const { std::string comments_str; if (!comments.empty()) comments_str = strprintf("(%s)", Join(comments, "; ")); - return strprintf("/%s:%s%s/%s/", name, FormatVersion(nClientVersion), comments_str, "UASF-ReducedData:0.1"); + return strprintf("/%s:%s%s/", name, FormatVersion(nClientVersion), comments_str); } std::string CopyrightHolders(const std::string& strPrefix) From 671c8416f75b08f1fb93b9545bb4df85b2bd0728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Haf?= Date: Sun, 14 Dec 2025 14:06:10 +0100 Subject: [PATCH 50/81] test: Fix fuzz for miniscript.cpp --- src/test/fuzz/miniscript.cpp | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/test/fuzz/miniscript.cpp b/src/test/fuzz/miniscript.cpp index 60d096bb5a99..132f9f9d4d28 100644 --- a/src/test/fuzz/miniscript.cpp +++ b/src/test/fuzz/miniscript.cpp @@ -1132,13 +1132,25 @@ 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 || + 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), - // 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 || + 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); } if (mal_success && (!nonmal_success || witness_mal.stack != witness_nonmal.stack)) { @@ -1148,8 +1160,15 @@ 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 || + 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); } if (node->IsSane()) { From 56a5f974564ae6c47979f7446a896f94ad809416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Haf?= Date: Mon, 15 Dec 2025 10:52:17 +0000 Subject: [PATCH 51/81] test: change permission and remove some f-string in logs --- test/functional/feature_reduced_data_temporary_deployment.py | 0 test/functional/feature_reduced_data_utxo_height.py | 2 +- test/functional/feature_uasf_max_activation_height.py | 2 +- test/functional/feature_uasf_reduced_data.py | 2 +- test/functional/mempool_dust.py | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) mode change 100644 => 100755 test/functional/feature_reduced_data_temporary_deployment.py mode change 100644 => 100755 test/functional/feature_reduced_data_utxo_height.py mode change 100644 => 100755 test/functional/feature_uasf_max_activation_height.py diff --git a/test/functional/feature_reduced_data_temporary_deployment.py b/test/functional/feature_reduced_data_temporary_deployment.py old mode 100644 new mode 100755 diff --git a/test/functional/feature_reduced_data_utxo_height.py b/test/functional/feature_reduced_data_utxo_height.py old mode 100644 new mode 100755 index e24e7ae7fcef..999c9c2811ec --- a/test/functional/feature_reduced_data_utxo_height.py +++ b/test/functional/feature_reduced_data_utxo_height.py @@ -432,7 +432,7 @@ def run_test(self): result = node.submitblock(block.serialize().hex()) assert result is not None and 'mandatory-script-verify-flag-failed' in result, f"Expected rejection, got: {result}" - self.log.info(f"✓ SUCCESS: Mixed transaction REJECTED (new input violated rules, even though old input was exempt)") + self.log.info("✓ SUCCESS: Mixed transaction REJECTED (new input violated rules, even though old input was exempt)") # Restore chain node.reconsiderblock(current_tip2) diff --git a/test/functional/feature_uasf_max_activation_height.py b/test/functional/feature_uasf_max_activation_height.py old mode 100644 new mode 100755 index 4b8cebba3cf1..ea68ca075e33 --- a/test/functional/feature_uasf_max_activation_height.py +++ b/test/functional/feature_uasf_max_activation_height.py @@ -396,7 +396,7 @@ def run_test(self): assert_equal(node.getblockcount(), 577) # Note: Status may still show 'active' but deployment should no longer be enforced # This matches the behavior in feature_temporary_deployment.py - self.log.info(f"Block 577: Deployment has expired (no longer enforced)") + self.log.info("Block 577: Deployment has expired (no longer enforced)") self.log.info("\n=== TEST 5 COMPLETE ===") self.log.info("SUCCESS: Temporary deployment with max_height activated and expired correctly") diff --git a/test/functional/feature_uasf_reduced_data.py b/test/functional/feature_uasf_reduced_data.py index 2bfa38db9b91..154836dcc2b1 100755 --- a/test/functional/feature_uasf_reduced_data.py +++ b/test/functional/feature_uasf_reduced_data.py @@ -340,7 +340,7 @@ def test_undefined_witness_versions(self): self.log.info(f" ✓ Witness v{version} spending correctly rejected ({result['reject-reason']})") # All undefined versions (v2-v16) are validated identically - self.log.info(f" ✓ Witness versions v2-v16 are all similarly rejected") + self.log.info(" ✓ Witness versions v2-v16 are all similarly rejected") def test_taproot_annex_rejection(self): """Test spec 4: Witness stacks with a Taproot annex are invalid.""" diff --git a/test/functional/mempool_dust.py b/test/functional/mempool_dust.py index 557c2938f746..1a25838c85af 100755 --- a/test/functional/mempool_dust.py +++ b/test/functional/mempool_dust.py @@ -163,7 +163,7 @@ def test_output_size_limit(self): tx.vout.append(CTxOut(nValue=1000, scriptPubKey=script_34)) res = node.testmempoolaccept([tx.serialize().hex()])[0] assert_equal(res['allowed'], True) - self.log.info(f" ✓ Exactly 34 bytes accepted (boundary)") + 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 From bc5c16098204dbf623e5121e18ff794412551914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Haf?= Date: Mon, 15 Dec 2025 13:17:29 +0000 Subject: [PATCH 52/81] test: use the correct flag for ignore_rejects in feature_uasf_reduced_data.py --- test/functional/feature_uasf_reduced_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/functional/feature_uasf_reduced_data.py b/test/functional/feature_uasf_reduced_data.py index 154836dcc2b1..2588966829f5 100755 --- a/test/functional/feature_uasf_reduced_data.py +++ b/test/functional/feature_uasf_reduced_data.py @@ -760,7 +760,7 @@ def test_mandatory_flags_cannot_be_bypassed(self): self.log.info(" This bypasses PolicyScriptChecks but NOT ConsensusScriptChecks") result_bypass = node.testmempoolaccept( rawtxs=[spending_tx_257.serialize().hex()], - ignore_rejects=["non-mandatory-script-verify-flag"] + ignore_rejects=["mempool-script-verify-flag-failed"] )[0] # The transaction should still be rejected because ConsensusScriptChecks From 19ed3ef9e323fbeec457c32a45b3258b701c5a96 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Mon, 22 Dec 2025 10:42:57 -0600 Subject: [PATCH 53/81] test: Add retry logic to mempool_limit for i686 race condition --- test/functional/mempool_limit.py | 44 +++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/test/functional/mempool_limit.py b/test/functional/mempool_limit.py index 1398b2cd541a..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, ) @@ -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() From 43465ff5bca679135cdf3c4ebf37d067da5de954 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Mon, 22 Dec 2025 22:55:58 -0600 Subject: [PATCH 54/81] ci: Disable DEBUG=1 for depends in CentOS job to avoid deadlock DEBUG=1 in depends is already tested in the CI job "previous releases, depends DEBUG". Testing with DEBUG=1 is considered equivalent in these two CI jobs in Bitcoin Core PR #32560, which thus does effectively the reverse of this commit. --- ci/test/00_setup_env_native_centos.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/ci/test/00_setup_env_native_centos.sh b/ci/test/00_setup_env_native_centos.sh index af27a5ebe1b7..4ab106f94fa7 100755 --- a/ci/test/00_setup_env_native_centos.sh +++ b/ci/test/00_setup_env_native_centos.sh @@ -10,6 +10,5 @@ export CONTAINER_NAME=ci_native_centos export CI_IMAGE_NAME_TAG="quay.io/centos/centos:stream10" export CI_BASE_PACKAGES="gcc-c++ glibc-devel libstdc++-devel ccache make git python3 python3-pip which patch xz procps-ng rsync coreutils bison e2fsprogs cmake dash" export PIP_PACKAGES="pyzmq" -export DEP_OPTS="DEBUG=1" # Temporarily enable a DEBUG=1 build to check for GCC-bug-117966 regressions. This can be removed once the minimum GCC version is bumped to 12 in the previous releases task, see https://github.com/bitcoin/bitcoin/issues/31436#issuecomment-2530717875 export GOAL="install" export BITCOIN_CONFIG="-DWITH_ZMQ=ON -DBUILD_GUI=ON -DREDUCE_EXPORTS=ON -DCMAKE_BUILD_TYPE=Debug" From 8bfd8a6013af4d75e5843fc022efacc6590ec2a6 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Mon, 22 Dec 2025 22:36:16 -0600 Subject: [PATCH 55/81] Fix false unknown deployment warning for Taproot on mainnet --- src/kernel/chainparams.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/kernel/chainparams.cpp b/src/kernel/chainparams.cpp index 7f7ebd550167..0806c20245e7 100644 --- a/src/kernel/chainparams.cpp +++ b/src/kernel/chainparams.cpp @@ -97,7 +97,7 @@ class CMainParams : public CChainParams { consensus.BIP66Height = 363725; // 00000000000000000379eaa19dce8c9b722d46ae6a57c2f1a988119488b50931 consensus.CSVHeight = 419328; // 000000000000000004a1b34462cb8aeebd5799177f7a29cf28f2d1961716b5b5 consensus.SegwitHeight = 481824; // 0000000000000000001c8018d9cb3b742ef25114f27563e3fc4a1902167f9893 - consensus.MinBIP9WarningHeight = 483840; // segwit activation height + miner confirmation window + consensus.MinBIP9WarningHeight = 711648; // taproot activation height + miner confirmation window consensus.powLimit = uint256{"00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff"}; consensus.nPowTargetTimespan = 14 * 24 * 60 * 60; // two weeks consensus.nPowTargetSpacing = 10 * 60; From ef73ad58be69192baaee63bda2bdbbd034719532 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Mon, 29 Dec 2025 15:14:08 -0600 Subject: [PATCH 56/81] Add per-deployment BIP9 threshold; restore global to 90%, set reduced_data to 55% --- src/consensus/params.h | 3 +++ src/kernel/chainparams.cpp | 5 ++++- src/versionbits.cpp | 6 +++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/consensus/params.h b/src/consensus/params.h index fa840d133967..2dc76f770ad7 100644 --- a/src/consensus/params.h +++ b/src/consensus/params.h @@ -60,6 +60,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/kernel/chainparams.cpp b/src/kernel/chainparams.cpp index 0806c20245e7..485b63ec5171 100644 --- a/src/kernel/chainparams.cpp +++ b/src/kernel/chainparams.cpp @@ -104,7 +104,7 @@ class CMainParams : public CChainParams { consensus.fPowAllowMinDifficultyBlocks = false; consensus.enforce_BIP94 = false; consensus.fPowNoRetargeting = false; - consensus.nRuleChangeActivationThreshold = 1109; // 55% of 2016 + consensus.nRuleChangeActivationThreshold = 1815; // 90% of 2016 consensus.nMinerConfirmationWindow = 2016; // nPowTargetTimespan / nPowTargetSpacing consensus.vDeployments[Consensus::DEPLOYMENT_TESTDUMMY].bit = 28; consensus.vDeployments[Consensus::DEPLOYMENT_TESTDUMMY].nStartTime = Consensus::BIP9Deployment::NEVER_ACTIVE; @@ -128,6 +128,7 @@ class CMainParams : public CChainParams { 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 /** * The message start string is designed to be unlikely to occur in normal data. @@ -261,6 +262,7 @@ class CTestNetParams : public CChainParams { 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 @@ -627,6 +629,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/versionbits.cpp b/src/versionbits.cpp index d6d2ebd4e2c1..60ed300ee0ce 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 1eee4b128fdae63b83d386b421a0df4df48d69c7 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Mon, 29 Dec 2025 15:16:13 -0600 Subject: [PATCH 57/81] Support regtest vbparams for per-deployment threshold --- src/chainparams.cpp | 11 ++- src/kernel/chainparams.h | 1 + .../feature_uasf_max_activation_height.py | 68 ++++++++++++++++++- 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/chainparams.cpp b/src/chainparams.cpp index fec14916f677..4affdbfe8c15 100644 --- a/src/chainparams.cpp +++ b/src/chainparams.cpp @@ -68,8 +68,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)) { @@ -95,6 +95,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 UASF, not both.", vbparams.timeout, vbparams.max_activation_height, vDeploymentParams[0])); @@ -104,7 +109,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.h b/src/kernel/chainparams.h index b40f5f2f89f4..412cf74a8783 100644 --- a/src/kernel/chainparams.h +++ b/src/kernel/chainparams.h @@ -148,6 +148,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 }; /** diff --git a/test/functional/feature_uasf_max_activation_height.py b/test/functional/feature_uasf_max_activation_height.py index ea68ca075e33..257a3175d362 100755 --- a/test/functional/feature_uasf_max_activation_height.py +++ b/test/functional/feature_uasf_max_activation_height.py @@ -35,16 +35,19 @@ class MaxActivationHeightTest(BitcoinTestFramework): def set_test_params(self): - self.num_nodes = 5 # 5 nodes for tests 1-5 (test 0 validation is done separately) + self.num_nodes = 6 # 6 nodes for tests 1-6 (test 0 validation is done separately) self.setup_clean_chain = True # NO_TIMEOUT = std::numeric_limits::max() = 9223372036854775807 NO_TIMEOUT = '9223372036854775807' + # INT_MAX = std::numeric_limits::max() = 2147483647 + INT_MAX = '2147483647' self.extra_args = [ [f'-vbparams=testdummy:0:{NO_TIMEOUT}:0:576'], # Test 1: max_height=576 (shows full flow) ['-vbparams=testdummy:0:999999999999'], # Test 2: no max_height (uses timeout) [f'-vbparams=testdummy:0:{NO_TIMEOUT}:0:576'], # Test 3: max_height=576 (early activation) [f'-vbparams=testdummy:0:{NO_TIMEOUT}:0:432'], # Test 4: verify permanent ACTIVE [f'-vbparams=testdummy:0:{NO_TIMEOUT}:0:432:144'], # Test 5: max_height + active_duration + [f'-vbparams=testdummy:0:999999999999:0:{INT_MAX}:{INT_MAX}:72'], # Test 6: custom 50% threshold (72/144) ] def setup_network(self): @@ -401,6 +404,69 @@ def run_test(self): self.log.info("\n=== TEST 5 COMPLETE ===") self.log.info("SUCCESS: Temporary deployment with max_height activated and expired correctly") + # Test 6: Custom per-deployment threshold + self.log.info("\n\n=== TEST 6: Custom per-deployment threshold (50% = 72/144 blocks) ===") + node = self.nodes[5] + + # This node has threshold=72 (50% of 144 blocks) + # Default regtest threshold is 108 (75%), but this deployment should activate at 72 + + # Period 0 (0-143): DEFINED + self.log.info("Mining period 0 (blocks 0-143) without signaling...") + self.mine_blocks(node, 143, signal=False) + assert_equal(node.getblockcount(), 143) + status, _ = self.get_status(node) + assert_equal(status, 'defined') + + # Block 144: Transition to STARTED + self.log.info("Mining block 144 to transition to STARTED...") + self.mine_blocks(node, 1, signal=False) + assert_equal(node.getblockcount(), 144) + status, since = self.get_status(node) + self.log.info(f"Block 144: Status={status}, Since={since}") + assert_equal(status, 'started') + assert_equal(since, 144) + + # Period 1 (144-287): Mine exactly 72 signaling blocks (50%) + # With custom threshold of 72, this should be enough to lock in + self.log.info("Mining period 1 with exactly 72 signaling blocks (50%)...") + self.mine_blocks(node, 72, signal=True) # 72 signaling blocks + self.mine_blocks(node, 71, signal=False) # 71 non-signaling blocks + assert_equal(node.getblockcount(), 287) + status, since = self.get_status(node) + self.log.info(f"Block 287: Status={status}") + assert_equal(status, 'started') # Still started until next period boundary + + # Block 288: Should transition to LOCKED_IN (threshold met in previous period) + self.log.info("Mining block 288 to check lock-in...") + self.mine_blocks(node, 1, signal=False) + assert_equal(node.getblockcount(), 288) + status, since = self.get_status(node) + self.log.info(f"Block 288: Status={status}, Since={since}") + assert_equal(status, 'locked_in') + assert_equal(since, 288) + + # Mine through locked_in period to activate + self.log.info("Mining through locked_in period (289-431)...") + self.mine_blocks(node, 143, signal=False) + assert_equal(node.getblockcount(), 431) + status, since = self.get_status(node) + assert_equal(status, 'locked_in') + + # Block 432: Should transition to ACTIVE + self.log.info("Mining block 432 to activate...") + self.mine_blocks(node, 1, signal=False) + assert_equal(node.getblockcount(), 432) + status, since = self.get_status(node) + self.log.info(f"Block 432: Status={status}, Since={since}") + assert_equal(status, 'active') + assert_equal(since, 432) + + self.log.info("\n=== TEST 6 COMPLETE ===") + self.log.info("SUCCESS: Deployment activated with custom 50% threshold (72/144 blocks)") + self.log.info("- Custom threshold overrode default 75% threshold") + self.log.info("- Lock-in occurred with only 50% signaling support") + if __name__ == '__main__': MaxActivationHeightTest(__file__).main() From 535e819fd5e214f28c5a463cfd0081a122b82e97 Mon Sep 17 00:00:00 2001 From: v72t Date: Mon, 16 Feb 2026 00:09:30 +0100 Subject: [PATCH 58/81] add BIP-110 DNS seeds --- src/kernel/chainparams.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/kernel/chainparams.cpp b/src/kernel/chainparams.cpp index 485b63ec5171..d79a9966daa1 100644 --- a/src/kernel/chainparams.cpp +++ b/src/kernel/chainparams.cpp @@ -156,6 +156,8 @@ class CMainParams : public CChainParams { // release ASAP to avoid it where possible. vSeeds.emplace_back("seed.bitcoin.sipa.be."); // Pieter Wuille, only supports x1, x5, x9, and xd vSeeds.emplace_back("dnsseed.bluematt.me."); // Matt Corallo, only supports x9 + vSeeds.emplace_back("dnsseed.bitcoin.dashjr-list-of-p2p-nodes.us."); // Luke Dashjr, support BIP110 seeding (x8000009) + vSeeds.emplace_back("seed.bitcoin.haf.ovh."); // Léo Haf, support BIP110 seeding (x8000009) vSeeds.emplace_back("seed.bitcoin.jonasschnelli.ch."); // Jonas Schnelli, only supports x1, x5, x9, and xd vSeeds.emplace_back("seed.btc.petertodd.net."); // Peter Todd, only supports x1, x5, x9, and xd vSeeds.emplace_back("seed.bitcoin.sprovoost.nl."); // Sjors Provoost From c2e0db845678b80e53dae84b5bef57c9da52cfa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Haf?= Date: Wed, 31 Dec 2025 12:32:49 +0100 Subject: [PATCH 59/81] net: ask DNS seed for x8000009 --- src/protocol.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/protocol.h b/src/protocol.h index ac98a4d5aa39..052ff452bc7b 100644 --- a/src/protocol.h +++ b/src/protocol.h @@ -354,7 +354,7 @@ std::vector serviceFlagsToStr(uint64_t flags); * should be updated appropriately to filter for nodes with * desired service flags (compatible with our new flags). */ -constexpr ServiceFlags SeedsServiceFlags() { return ServiceFlags(NODE_NETWORK | NODE_WITNESS); } +constexpr ServiceFlags SeedsServiceFlags() { return ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA); } /** * Checks if a peer with the given service flags may be capable of having a From bcd309deed2727796a7fb1385d18ca2ac4f4dd8e Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Tue, 13 Jan 2026 14:53:47 -0600 Subject: [PATCH 60/81] net: allow up to 2 non-BIP110 outbound peers --- src/net.cpp | 14 ++++---- src/net.h | 8 +++-- src/net_processing.cpp | 26 +++++++++++++-- src/test/peerman_tests.cpp | 15 +++++---- test/functional/p2p_handshake.py | 56 +++++++++++++++++++++++++------- 5 files changed, 90 insertions(+), 29 deletions(-) diff --git a/src/net.cpp b/src/net.cpp index 7684877ec352..2706637804ad 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -2264,7 +2264,7 @@ void CConnman::ThreadDNSAddressSeed() break; } - outbound_connection_count = GetFullOutboundConnCount(); + outbound_connection_count = GetBIP110FullOutboundConnCount(); if (outbound_connection_count >= SEED_OUTBOUND_CONNECTION_THRESHOLD) { LogPrintf("P2P peers available. Finished fetching data from seed nodes.\n"); break; @@ -2319,7 +2319,7 @@ void CConnman::ThreadDNSAddressSeed() if (!interruptNet.sleep_for(w)) return; to_wait -= w; - if (GetFullOutboundConnCount() >= SEED_OUTBOUND_CONNECTION_THRESHOLD) { + if (GetBIP110FullOutboundConnCount() >= SEED_OUTBOUND_CONNECTION_THRESHOLD) { if (found > 0) { LogPrintf("%d addresses found from DNS seeds\n", found); LogPrintf("P2P peers available. Finished DNS seeding.\n"); @@ -2432,14 +2432,15 @@ void CConnman::StartExtraBlockRelayPeers() m_start_extra_block_relay_peers = true; } -// Return the number of outbound connections that are full relay (not blocks only) -int CConnman::GetFullOutboundConnCount() const +// Return the number of BIP110 outbound connections that are full relay (not blocks only). +// Non-BIP110 outbound peers are excluded as they are "additional" and don't count toward limits. +int CConnman::GetBIP110FullOutboundConnCount() const { int nRelevant = 0; { LOCK(m_nodes_mutex); for (const CNode* pnode : m_nodes) { - if (pnode->fSuccessfullyConnected && pnode->IsFullOutboundConn()) ++nRelevant; + if (pnode->fSuccessfullyConnected && pnode->IsFullOutboundConn() && !pnode->m_is_non_bip110_outbound) ++nRelevant; } } return nRelevant; @@ -2642,7 +2643,8 @@ void CConnman::ThreadOpenConnections(const std::vector connect, Spa { LOCK(m_nodes_mutex); for (const CNode* pnode : m_nodes) { - if (pnode->IsFullOutboundConn()) nOutboundFullRelay++; + // Non-BIP110 outbound peers are "additional" - don't count toward limits + if (pnode->IsFullOutboundConn() && !pnode->m_is_non_bip110_outbound) nOutboundFullRelay++; if (pnode->IsBlockOnlyConn()) nOutboundBlockRelay++; // Make sure our persistent outbound slots to ipv4/ipv6 peers belong to different netgroups. diff --git a/src/net.h b/src/net.h index e025b20bcdef..f0e407912b87 100644 --- a/src/net.h +++ b/src/net.h @@ -850,6 +850,10 @@ class CNode /** Whether this peer provides all services that we want. Used for eviction decisions */ std::atomic_bool m_has_all_wanted_services{false}; + /** Whether this is a non-BIP110 outbound peer (lacks NODE_UASF_REDUCED_DATA). + * Used to exclude from outbound connection counts. Limited to 2 such peers. */ + std::atomic_bool m_is_non_bip110_outbound{false}; + /** Whether we should relay transactions to this peer. This only changes * from false to true. It will never change back to false. */ std::atomic_bool m_relays_txs{false}; @@ -1191,8 +1195,8 @@ class CConnman void StartExtraBlockRelayPeers(); - // Count the number of full-relay peer we have. - int GetFullOutboundConnCount() const; + // Count the number of BIP110 full-relay peers we have (excludes non-BIP110 peers). + int GetBIP110FullOutboundConnCount() const; // Return the number of outbound peers we have in excess of our target (eg, // if we previously called SetTryNewOutboundPeer(true), and have since set // to false, we may have extra peers that we wish to disconnect). This may diff --git a/src/net_processing.cpp b/src/net_processing.cpp index 3537cd07fdd9..3b3df0cc5aee 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -796,6 +796,9 @@ class PeerManagerImpl final : public PeerManager /** Number of peers with wtxid relay. */ std::atomic m_wtxid_relay_peers{0}; + /** Number of outbound peers without NODE_UASF_REDUCED_DATA (BIP-110). Limited to 2. */ + std::atomic m_num_non_bip110_outbound{0}; + /** Number of outbound peers with m_chain_sync.m_protect. */ int m_outbound_peers_with_protect_from_disconnect GUARDED_BY(cs_main) = 0; @@ -1572,6 +1575,11 @@ void PeerManagerImpl::FinalizeNode(const CNode& node) assert(peer != nullptr); m_wtxid_relay_peers -= peer->m_wtxid_relay; assert(m_wtxid_relay_peers >= 0); + // Decrement non-BIP110 counter if this was a non-BIP110 outbound peer + if (node.m_is_non_bip110_outbound) { + --m_num_non_bip110_outbound; + assert(m_num_non_bip110_outbound >= 0); + } } CNodeState *state = State(nodeid); assert(state != nullptr); @@ -1635,14 +1643,13 @@ bool PeerManagerImpl::HasAllDesirableServiceFlags(ServiceFlags services) const ServiceFlags PeerManagerImpl::GetDesirableServiceFlags(ServiceFlags services) const { - // We want to preferentially peer with other nodes that enforce UASF-ReducedData, in case of a chain split if (services & NODE_NETWORK_LIMITED) { // Limited peers are desirable when we are close to the tip. if (ApproximateBestBlockDepth() < NODE_NETWORK_LIMITED_ALLOW_CONN_BLOCKS) { - return ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_UASF_REDUCED_DATA); + return ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS); } } - return ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA); + return ServiceFlags(NODE_NETWORK | NODE_WITNESS); } PeerRef PeerManagerImpl::GetPeerRef(NodeId id) const @@ -3451,6 +3458,19 @@ void PeerManagerImpl::ProcessMessage(CNode& pfrom, const std::string& msg_type, } pfrom.m_has_all_wanted_services = HasAllDesirableServiceFlags(nServices); + // BIP-110: Allow up to 2 non-BIP110 outbound peers. + if (pfrom.ExpectServicesFromConn() && !(nServices & NODE_UASF_REDUCED_DATA)) { + if (m_num_non_bip110_outbound >= 2) { + LogDebug(BCLog::NET, "peer lacks NODE_UASF_REDUCED_DATA and already have 2 non-BIP110 outbound peers, %s\n", + pfrom.DisconnectMsg(fLogIPs)); + pfrom.fDisconnect = true; + return; + } + ++m_num_non_bip110_outbound; + pfrom.m_is_non_bip110_outbound = true; + LogDebug(BCLog::NET, "connected to non-BIP110 outbound peer (%d/2), %s\n", + m_num_non_bip110_outbound.load(), pfrom.ConnectionTypeAsString()); + } peer->m_their_services = nServices; pfrom.SetAddrLocal(addrMe); { diff --git a/src/test/peerman_tests.cpp b/src/test/peerman_tests.cpp index 88a216ab46c3..be1b350d2394 100644 --- a/src/test/peerman_tests.cpp +++ b/src/test/peerman_tests.cpp @@ -35,8 +35,9 @@ BOOST_AUTO_TEST_CASE(connections_desirable_service_flags) auto consensus = m_node.chainman->GetParams().GetConsensus(); // Check we start connecting to full nodes + // Note: NODE_UASF_REDUCED_DATA requirement is enforced separately in VERSION processing ServiceFlags peer_flags{NODE_WITNESS | NODE_NETWORK_LIMITED}; - BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA)); + BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK | NODE_WITNESS)); // Make peerman aware of the initial best block and verify we accept limited peers when we start close to the tip time. auto tip = WITH_LOCK(::cs_main, return m_node.chainman->ActiveChain().Tip()); @@ -45,15 +46,15 @@ BOOST_AUTO_TEST_CASE(connections_desirable_service_flags) peerman->SetBestBlock(tip_block_height, std::chrono::seconds{tip_block_time}); SetMockTime(tip_block_time + 1); // Set node time to tip time - BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_UASF_REDUCED_DATA)); + BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS)); // Check we don't disallow limited peers connections when we are behind but still recoverable (below the connection safety window) SetMockTime(GetTime() + std::chrono::seconds{consensus.nPowTargetSpacing * (NODE_NETWORK_LIMITED_ALLOW_CONN_BLOCKS - 1)}); - BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_UASF_REDUCED_DATA)); + BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS)); // Check we disallow limited peers connections when we are further than the limited peers safety window SetMockTime(GetTime() + std::chrono::seconds{consensus.nPowTargetSpacing * 2}); - BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA)); + BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK | NODE_WITNESS)); // By now, we tested that the connections desirable services flags change based on the node's time proximity to the tip. // Now, perform the same tests for when the node receives a block. @@ -62,15 +63,15 @@ BOOST_AUTO_TEST_CASE(connections_desirable_service_flags) // First, verify a block in the past doesn't enable limited peers connections // At this point, our time is (NODE_NETWORK_LIMITED_ALLOW_CONN_BLOCKS + 1) * 10 minutes ahead the tip's time. mineBlock(m_node, /*block_time=*/std::chrono::seconds{tip_block_time + 1}); - BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA)); + BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK | NODE_WITNESS)); // Verify a block close to the tip enables limited peers connections mineBlock(m_node, /*block_time=*/GetTime()); - BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_UASF_REDUCED_DATA)); + BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS)); // Lastly, verify the stale tip checks can disallow limited peers connections after not receiving blocks for a prolonged period. SetMockTime(GetTime() + std::chrono::seconds{consensus.nPowTargetSpacing * NODE_NETWORK_LIMITED_ALLOW_CONN_BLOCKS + 1}); - BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA)); + BOOST_CHECK(peerman->GetDesirableServiceFlags(peer_flags) == ServiceFlags(NODE_NETWORK | NODE_WITNESS)); } BOOST_AUTO_TEST_SUITE_END() diff --git a/test/functional/p2p_handshake.py b/test/functional/p2p_handshake.py index 3c13672252cf..9e680b2f84b5 100755 --- a/test/functional/p2p_handshake.py +++ b/test/functional/p2p_handshake.py @@ -21,12 +21,13 @@ from test_framework.util import p2p_port -# Desirable service flags for outbound non-pruned and pruned peers. Note that -# the desirable service flags for pruned peers are dynamic and only apply if -# 1. the peer's service flag NODE_NETWORK_LIMITED is set *and* -# 2. the local chain is close to the tip (<24h) -DESIRABLE_SERVICE_FLAGS_FULL = NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA -DESIRABLE_SERVICE_FLAGS_PRUNED = NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_UASF_REDUCED_DATA +# Base service flags (without BIP-110) +BASE_SERVICE_FLAGS_FULL = NODE_NETWORK | NODE_WITNESS +BASE_SERVICE_FLAGS_PRUNED = NODE_NETWORK_LIMITED | NODE_WITNESS + +# Full service flags (with BIP-110) +FULL_SERVICE_FLAGS_FULL = NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA +FULL_SERVICE_FLAGS_PRUNED = NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_UASF_REDUCED_DATA class P2PHandshakeTest(BitcoinTestFramework): @@ -72,19 +73,52 @@ def generate_at_mocktime(self, time): def run_test(self): node = self.nodes[0] - self.log.info("Check that lacking desired service flags leads to disconnect (non-pruned peers)") + + self.log.info("Check that peers lacking base service flags are disconnected") + # These should always be disconnected regardless of BIP-110 counter self.test_desirable_service_flags(node, [NODE_NONE, NODE_NETWORK, NODE_WITNESS], - DESIRABLE_SERVICE_FLAGS_FULL, expect_disconnect=True) + BASE_SERVICE_FLAGS_FULL, expect_disconnect=True) + + self.log.info("Check that first 2 non-BIP110 peers connect, 3rd is rejected") + # Connect first 2 non-BIP110 peers and keep them connected + non_bip110_services = NODE_NETWORK | NODE_WITNESS + if self.options.v2transport: + non_bip110_services |= NODE_P2P_V2 + peer1 = node.add_outbound_p2p_connection( + P2PInterface(), p2p_idx=0, wait_for_disconnect=False, + connection_type="outbound-full-relay", services=non_bip110_services, + supports_v2_p2p=self.options.v2transport, advertise_v2_p2p=self.options.v2transport) + peer1.sync_with_ping() + peer2 = node.add_outbound_p2p_connection( + P2PInterface(), p2p_idx=1, wait_for_disconnect=False, + connection_type="outbound-full-relay", services=non_bip110_services, + supports_v2_p2p=self.options.v2transport, advertise_v2_p2p=self.options.v2transport) + peer2.sync_with_ping() + assert len(node.getpeerinfo()) == 2 + # Third non-BIP110 peer should be rejected + with node.assert_debug_log(["peer lacks NODE_UASF_REDUCED_DATA and already have 2 non-BIP110 outbound peers"]): + node.add_outbound_p2p_connection( + P2PInterface(), p2p_idx=2, wait_for_disconnect=True, + connection_type="outbound-full-relay", services=non_bip110_services, + supports_v2_p2p=self.options.v2transport, advertise_v2_p2p=self.options.v2transport) + # Clean up - disconnect the 2 non-BIP110 peers + peer1.peer_disconnect() + peer2.peer_disconnect() + peer1.wait_for_disconnect() + peer2.wait_for_disconnect() + self.wait_until(lambda: len(node.getpeerinfo()) == 0) + + self.log.info("Check that BIP110 peers always connect") self.test_desirable_service_flags(node, [NODE_NETWORK | NODE_WITNESS | NODE_UASF_REDUCED_DATA], - DESIRABLE_SERVICE_FLAGS_FULL, expect_disconnect=False) + BASE_SERVICE_FLAGS_FULL, expect_disconnect=False) self.log.info("Check that limited peers are only desired if the local chain is close to the tip (<24h)") self.generate_at_mocktime(int(time.time()) - 25 * 3600) # tip outside the 24h window, should fail self.test_desirable_service_flags(node, [NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_UASF_REDUCED_DATA], - DESIRABLE_SERVICE_FLAGS_FULL, expect_disconnect=True) + BASE_SERVICE_FLAGS_FULL, expect_disconnect=True) self.generate_at_mocktime(int(time.time()) - 23 * 3600) # tip inside the 24h window, should succeed self.test_desirable_service_flags(node, [NODE_NETWORK_LIMITED | NODE_WITNESS | NODE_UASF_REDUCED_DATA], - DESIRABLE_SERVICE_FLAGS_PRUNED, expect_disconnect=False) + BASE_SERVICE_FLAGS_PRUNED, expect_disconnect=False) self.log.info("Check that feeler connections get disconnected immediately") with node.assert_debug_log(["feeler connection completed"]): From cc8bd4c867afac489bad57cbf031dd76fc0e78f9 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Tue, 13 Jan 2026 21:34:02 -0600 Subject: [PATCH 61/81] test: add two-node chain split and reorg tests for temporary deployment --- ...ature_reduced_data_temporary_deployment.py | 332 +++++++++++------- 1 file changed, 204 insertions(+), 128 deletions(-) diff --git a/test/functional/feature_reduced_data_temporary_deployment.py b/test/functional/feature_reduced_data_temporary_deployment.py index c89b6795621d..f6a4641ab8c9 100755 --- a/test/functional/feature_reduced_data_temporary_deployment.py +++ b/test/functional/feature_reduced_data_temporary_deployment.py @@ -8,16 +8,24 @@ after the specified number of blocks. We use REDUCED_DATA as the test deployment with active_duration=144 blocks. -The test verifies two critical behaviors: -1. Consensus rules ARE enforced during the active period (blocks 432-575) -2. Consensus rules STOP being enforced after expiry (block 576+) +The test uses two nodes: +- Node 0: BIP-110 enforcing (active_duration=144) +- Node 1: Non-BIP-110 (never active, simulates Bitcoin Core) + +The test verifies: +1. BIP9 state transitions: DEFINED -> STARTED -> LOCKED_IN -> ACTIVE +2. Consensus rules ARE enforced during the active period (blocks 432-575) +3. Chain split: BIP-110 node rejects invalid blocks, non-BIP-110 accepts +4. Reorg: Longer valid chain wins when nodes reconnect +5. Consensus rules STOP being enforced after expiry (block 576+) +6. Post-expiry convergence: Both nodes accept the same blocks Expected timeline: - Period 0 (blocks 0-143): DEFINED - Period 1 (blocks 144-287): STARTED (signaling happens here) - Period 2 (blocks 288-431): LOCKED_IN -- Period 3 (blocks 432-575): ACTIVE (144 blocks total, from activation_height 432 to 575 inclusive) -- Block 576+: EXPIRED (deployment no longer active, rules no longer enforced) +- Period 3 (blocks 432-575): ACTIVE (144 blocks, rules enforced on node0 only) +- Block 576+: EXPIRED (rules no longer enforced, nodes converge) """ from test_framework.blocktools import ( @@ -42,22 +50,28 @@ class TemporaryDeploymentTest(BitcoinTestFramework): def set_test_params(self): - self.num_nodes = 1 + self.num_nodes = 2 self.setup_clean_chain = True - # Set active_duration to 144 blocks (1 period) for REDUCED_DATA - # Format: deployment:start:end:min_activation_height:max_activation_height:active_duration - # start=0, timeout=999999999999, min_activation_height=0, max_activation_height=2147483647 (INT_MAX, disabled), active_duration=144 - self.extra_args = [[ - '-vbparams=reduced_data:0:999999999999:0:2147483647:144', - '-acceptnonstdtxn=1', - ]] - - def create_test_block(self, txs, signal=False): - """Create a block with the given transactions.""" - tip = self.nodes[0].getbestblockhash() - height = self.nodes[0].getblockcount() + 1 - tip_header = self.nodes[0].getblockheader(tip) - block_time = tip_header['time'] + 1 + # Node 0: BIP-110 with active_duration=144 blocks + # Node 1: BIP-110 never active (simulates Bitcoin Core) + # NEVER_ACTIVE = -2 for start_time prevents deployment from ever leaving DEFINED state + self.extra_args = [ + ['-vbparams=reduced_data:0:999999999999:0:2147483647:144', '-acceptnonstdtxn=1'], + ['-vbparams=reduced_data:-2:-1', '-acceptnonstdtxn=1'], + ] + + def setup_network(self): + self.setup_nodes() + self.connect_nodes(0, 1) + + def create_block_for_node(self, node, txs=None, signal=False, time_offset=0): + """Create a block for a specific node.""" + if txs is None: + txs = [] + tip = node.getbestblockhash() + height = node.getblockcount() + 1 + tip_header = node.getblockheader(tip) + block_time = tip_header['time'] + 1 + time_offset block = create_block(int(tip, 16), create_coinbase(height), ntime=block_time, txlist=txs) if signal: block.nVersion = VERSIONBITS_TOP_BITS | (1 << REDUCED_DATA_BIT) @@ -65,149 +79,211 @@ def create_test_block(self, txs, signal=False): block.solve() return block - def mine_blocks(self, count, signal=False): - """Mine count blocks, optionally signaling for REDUCED_DATA.""" + def mine_blocks_on_node(self, node, count, signal=False): + """Mine count blocks on a specific node.""" for _ in range(count): - block = self.create_test_block([], signal=signal) - self.nodes[0].submitblock(block.serialize().hex()) + block = self.create_block_for_node(node, signal=signal) + node.submitblock(block.serialize().hex()) - def create_tx_with_data(self, data_size): - """Create a transaction with OP_RETURN output of specified size.""" - # Start with a valid transaction from the wallet - tx_dict = self.wallet.create_self_transfer() + def create_tx_with_large_output(self, wallet): + """Create a transaction with 84-byte OP_RETURN (violates BIP-110's 83-byte limit).""" + tx_dict = wallet.create_self_transfer() tx = tx_dict['tx'] - - # Add an OP_RETURN output with specified data size - tx.vout.append(CTxOut(0, CScript([OP_RETURN, b'x' * data_size]))) + # 81 bytes data = 84-byte script (OP_RETURN + OP_PUSHDATA1 + len + data) + tx.vout.append(CTxOut(0, CScript([OP_RETURN, b'x' * 81]))) tx.rehash() - return tx - def get_deployment_status(self, deployment_info, deployment_name): - """Helper to get deployment status from getdeploymentinfo().""" - rd = deployment_info['deployments'][deployment_name] + def get_deployment_status(self, node): + """Get reduced_data deployment status.""" + info = node.getdeploymentinfo() + rd = info['deployments']['reduced_data'] if 'bip9' in rd: return rd['bip9']['status'], rd['bip9'].get('since', 'N/A') return rd.get('status'), rd.get('since', 'N/A') def run_test(self): - node = self.nodes[0] + node_bip110 = self.nodes[0] + node_core = self.nodes[1] - # MiniWallet provides a simple wallet for test transactions - self.wallet = MiniWallet(node) + wallet = MiniWallet(node_bip110) - self.log.info("Mining initial blocks to get spendable coins...") - self.generate(self.wallet, 101) + # ===================================================================== + # Phase 1: Build common chain through BIP9 state transitions + # ===================================================================== + self.log.info("Phase 1: Building common chain through BIP9 states") - # Get deployment info at genesis - info = node.getdeploymentinfo() - status, since = self.get_deployment_status(info, 'reduced_data') - self.log.info(f"Block 101 - Status: {status}, Since: {since}") - assert_equal(status, 'defined') + self.log.info("Mining initial blocks for spendable coins...") + self.generate(wallet, 101) + self.sync_all() - # Mine through period 0 (blocks 102-143) - should remain DEFINED - self.log.info("Mining through period 0 (blocks 102-143)...") - self.generate(node, 42) # Get to block 143 - info = node.getdeploymentinfo() - status, since = self.get_deployment_status(info, 'reduced_data') - self.log.info(f"Block 143 - Status: {status}") + status, _ = self.get_deployment_status(node_bip110) assert_equal(status, 'defined') - # Mine period 1 (blocks 144-287) with signaling - should transition to STARTED - self.log.info("Mining period 1 (blocks 144-287) with 100% signaling...") - self.mine_blocks(144, signal=True) - assert_equal(node.getblockcount(), 287) - info = node.getdeploymentinfo() - status, since = self.get_deployment_status(info, 'reduced_data') - self.log.info(f"Block 287 - Status: {status}") + # Mine to end of period 0 + self.log.info("Mining through period 0 (DEFINED)...") + self.generate(node_bip110, 42) + self.sync_all() + assert_equal(node_bip110.getblockcount(), 143) + + # Period 1: Signal for activation + self.log.info("Mining period 1 with signaling (STARTED)...") + self.mine_blocks_on_node(node_bip110, 144, signal=True) + self.sync_all() + assert_equal(node_bip110.getblockcount(), 287) + status, _ = self.get_deployment_status(node_bip110) assert_equal(status, 'started') - # Mine period 2 (blocks 288-431) - should transition to LOCKED_IN - self.log.info("Mining period 2 (blocks 288-431)...") - self.mine_blocks(144, signal=True) - assert_equal(node.getblockcount(), 431) - info = node.getdeploymentinfo() - status, since = self.get_deployment_status(info, 'reduced_data') - self.log.info(f"Block 431 - Status: {status}, Since: {since}") + # Period 2: Lock in + self.log.info("Mining period 2 (LOCKED_IN)...") + self.mine_blocks_on_node(node_bip110, 144, signal=True) + self.sync_all() + assert_equal(node_bip110.getblockcount(), 431) + status, since = self.get_deployment_status(node_bip110) assert_equal(status, 'locked_in') assert_equal(since, 288) - # Mine one more block to activate (block 432 starts period 3) - self.log.info("Mining block 432 (activation block)...") - self.mine_blocks(1) - assert_equal(node.getblockcount(), 432) - info = node.getdeploymentinfo() - status, since = self.get_deployment_status(info, 'reduced_data') + # ===================================================================== + # Phase 2: Test activation and chain split + # ===================================================================== + self.log.info("Phase 2: Testing activation and chain split behavior") + + # Mine block 432 (activation) + self.mine_blocks_on_node(node_bip110, 1) + self.sync_all() + assert_equal(node_bip110.getblockcount(), 432) + status, since = self.get_deployment_status(node_bip110) self.log.info(f"Block 432 - Status: {status}, Since: {since}") assert_equal(status, 'active') assert_equal(since, 432) - # Test that REDUCED_DATA rules are enforced at block 432 (first active block) - self.log.info("Testing REDUCED_DATA rules are enforced at block 432...") - tx_large_data = self.create_tx_with_data(81) - block_invalid = self.create_test_block([tx_large_data]) - result = node.submitblock(block_invalid.serialize().hex()) - self.log.info(f"Submitting block with 81-byte OP_RETURN at height 432: {result}") - # 81 bytes data becomes 84-byte script (OP_RETURN + OP_PUSHDATA1 + len + data), exceeds 83-byte limit - assert_equal(result, 'bad-txns-vout-script-toolarge') + # Disconnect nodes BEFORE creating invalid block to prevent P2P relay + # (Bitcoin Core relays blocks via compact blocks before full validation completes) + self.log.info("Disconnecting nodes for chain split test...") + self.disconnect_nodes(0, 1) - # Mine a valid block instead - tx_valid = self.create_tx_with_data(80) - block_valid = self.create_test_block([tx_valid]) - assert_equal(node.submitblock(block_valid.serialize().hex()), None) - assert_equal(node.getblockcount(), 433) + # Create the invalid block (84-byte OP_RETURN violates BIP-110's 83-byte limit) + self.log.info("Test: BIP-110 node rejects block with 84-byte OP_RETURN output") + tx_invalid = self.create_tx_with_large_output(wallet) + block_invalid = self.create_block_for_node(node_bip110, [tx_invalid]) - # Mine through most of the active period (blocks 434-574) - self.log.info("Mining through active period to block 574...") - self.generate(node, 141) # 434 to 574 - assert_equal(node.getblockcount(), 574) - info = node.getdeploymentinfo() - status, since = self.get_deployment_status(info, 'reduced_data') - self.log.info(f"Block 574 - Status: {status}") - assert_equal(status, 'active') + # Submit to BIP-110 node - should be rejected + result_bip110 = node_bip110.submitblock(block_invalid.serialize().hex()) + assert_equal(result_bip110, 'bad-txns-vout-script-toolarge') + assert_equal(node_bip110.getblockcount(), 432) + + # Submit to non-BIP-110 node - should be accepted + self.log.info("Test: Non-BIP-110 node accepts the same block") + result_core = node_core.submitblock(block_invalid.serialize().hex()) + assert_equal(result_core, None) + assert_equal(node_core.getblockcount(), 433) - # Test that REDUCED_DATA rules are still enforced at block 575 (last active block, 432 + 144 - 1) - self.log.info("Testing REDUCED_DATA rules are still enforced at block 575 (last active block)...") - tx_large_data = self.create_tx_with_data(81) - block_invalid = self.create_test_block([tx_large_data]) - result = node.submitblock(block_invalid.serialize().hex()) - self.log.info(f"Submitting block with 81-byte OP_RETURN at height 575: {result}") + # Chain split confirmed + self.log.info(f"Chain split: BIP-110={node_bip110.getblockcount()}, Core={node_core.getblockcount()}") + + # ===================================================================== + # Phase 3: Test reorg behavior + # ===================================================================== + self.log.info("Phase 3: Testing reorg behavior") + + # Non-BIP-110 extends its chain + self.log.info("Non-BIP-110 node extends chain with 3 more blocks...") + for i in range(3): + block = self.create_block_for_node(node_core, time_offset=i) + node_core.submitblock(block.serialize().hex()) + assert_equal(node_core.getblockcount(), 436) + + # BIP-110 node builds longer valid chain + self.log.info("BIP-110 node builds longer valid chain (5 blocks)...") + for i in range(5): + block = self.create_block_for_node(node_bip110, time_offset=i+10) + node_bip110.submitblock(block.serialize().hex()) + assert_equal(node_bip110.getblockcount(), 437) + + # Reconnect - non-BIP-110 should reorg to BIP-110's chain + self.log.info("Reconnecting nodes - expecting reorg...") + self.connect_nodes(0, 1) + self.sync_blocks() + + assert_equal(node_core.getbestblockhash(), node_bip110.getbestblockhash()) + assert_equal(node_core.getblockcount(), 437) + self.log.info(f"Reorg complete: both nodes at height {node_core.getblockcount()}") + + # ===================================================================== + # Phase 4: Test rules enforced until expiry + # ===================================================================== + self.log.info("Phase 4: Testing rules enforced until expiry") + + # Mine to block 574 (one before last active block) + # active_duration=144, activation at 432, so last active block is 432+144-1=575 + blocks_to_574 = 574 - node_bip110.getblockcount() + self.log.info(f"Mining {blocks_to_574} blocks to reach block 574...") + self.generate(node_bip110, blocks_to_574) + self.sync_all() + assert_equal(node_bip110.getblockcount(), 574) + + # Disconnect nodes to prevent compact block relay of invalid block + self.disconnect_nodes(0, 1) + + # Verify rules still enforced at block 575 (last active block) + self.log.info("Test: Rules still enforced at block 575 (last active block)") + tx_invalid = self.create_tx_with_large_output(wallet) + block_invalid = self.create_block_for_node(node_bip110, [tx_invalid]) + result = node_bip110.submitblock(block_invalid.serialize().hex()) assert_equal(result, 'bad-txns-vout-script-toolarge') # Mine valid block 575 (last active block) - tx_valid = self.create_tx_with_data(80) - block_valid = self.create_test_block([tx_valid]) - assert_equal(node.submitblock(block_valid.serialize().hex()), None) - assert_equal(node.getblockcount(), 575) - info = node.getdeploymentinfo() - status, since = self.get_deployment_status(info, 'reduced_data') - self.log.info(f"Block 575 - Status: {status}") - assert_equal(status, 'active') + block_valid = self.create_block_for_node(node_bip110) + node_bip110.submitblock(block_valid.serialize().hex()) + assert_equal(node_bip110.getblockcount(), 575) + + # Reconnect and sync + self.connect_nodes(0, 1) + self.sync_all() - # Test that REDUCED_DATA rules are NO LONGER enforced at block 576 (first expired block, 432 + 144) - self.log.info("Testing REDUCED_DATA rules are NOT enforced at block 576 (first expired block, 432 + 144)...") - tx_large_data = self.create_tx_with_data(81) - block_after_expiry = self.create_test_block([tx_large_data]) - result = node.submitblock(block_after_expiry.serialize().hex()) - self.log.info(f"Submitting block with 81-byte OP_RETURN at height 576: {result}") + # ===================================================================== + # Phase 5: Test expiry - rules no longer enforced + # ===================================================================== + self.log.info("Phase 5: Testing expiry - rules no longer enforced") + + # At block 576, deployment has expired (first expired block = 432 + 144) + self.log.info("Test: BIP-110 node accepts 'invalid' block at height 576 (expired)") + tx_invalid = self.create_tx_with_large_output(wallet) + block_after_expiry = self.create_block_for_node(node_bip110, [tx_invalid]) + result = node_bip110.submitblock(block_after_expiry.serialize().hex()) assert_equal(result, None) - assert_equal(node.getblockcount(), 576) + self.sync_all() + assert_equal(node_bip110.getblockcount(), 576) + + # ===================================================================== + # Phase 6: Test post-expiry convergence + # ===================================================================== + self.log.info("Phase 6: Testing post-expiry convergence") + + # Both nodes should accept the same "invalid" blocks now + self.log.info("Test: Both nodes accept 'invalid' blocks after expiry") + for i in range(5): + tx = self.create_tx_with_large_output(wallet) + block = self.create_block_for_node(node_bip110, [tx], time_offset=i) + result_bip110 = node_bip110.submitblock(block.serialize().hex()) + assert_equal(result_bip110, None) + self.sync_all() + assert_equal(node_core.getbestblockhash(), node_bip110.getbestblockhash()) + + final_height = node_bip110.getblockcount() + self.log.info(f"Final height: {final_height}, both nodes synced") + + # ===================================================================== + # Summary + # ===================================================================== + self.log.info("All tests passed:") + self.log.info(" - BIP9 state transitions (DEFINED -> STARTED -> LOCKED_IN -> ACTIVE)") + self.log.info(" - Chain split at activation (BIP-110 rejects, Core accepts)") + self.log.info(" - Reorg to longer valid chain on reconnect") + self.log.info(" - Rules enforced during active period (432-575)") + self.log.info(" - Rules not enforced after expiry (576+)") + self.log.info(" - Post-expiry convergence (both nodes accept same blocks)") - # Check deployment status after expiry - # Note: BIP9 status may still show 'active' but rules are no longer enforced - info = node.getdeploymentinfo() - status, since = self.get_deployment_status(info, 'reduced_data') - self.log.info(f"Block 576 - Status: {status}, Since: {since}") - - # Verify rules remain unenforced for several more blocks - self.log.info("Verifying REDUCED_DATA rules remain unenforced after expiry...") - for i in range(10): - tx_large = self.create_tx_with_data(81) - block = self.create_test_block([tx_large]) - result = node.submitblock(block.serialize().hex()) - assert_equal(result, None) - - self.log.info(f"Final block height: {node.getblockcount()}") if __name__ == '__main__': TemporaryDeploymentTest(__file__).main() From dfcd0cd1851de244480250be112dae9aa50fb21b Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Wed, 14 Jan 2026 12:48:15 -0600 Subject: [PATCH 62/81] consensus: apply output size limit to generation transactions --- src/consensus/tx_verify.cpp | 20 +++-- src/consensus/tx_verify.h | 7 ++ src/validation.cpp | 12 ++- test/functional/feature_uasf_reduced_data.py | 87 ++++++++++++++++++++ 4 files changed, 118 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..4a95f49d2e90 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). + */ +[[nodiscard]] 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 a53a40528c1a..406981bd75ea 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -2642,7 +2642,17 @@ bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state, ? m_chainman.m_versionbitscache.StateSinceHeight(pindex->pprev, params.GetConsensus(), Consensus::DEPLOYMENT_REDUCED_DATA) : std::numeric_limits::max(); - 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; diff --git a/test/functional/feature_uasf_reduced_data.py b/test/functional/feature_uasf_reduced_data.py index 2588966829f5..ef27c677cfbb 100755 --- a/test/functional/feature_uasf_reduced_data.py +++ b/test/functional/feature_uasf_reduced_data.py @@ -770,11 +770,98 @@ def test_mandatory_flags_cannot_be_bypassed(self): self.log.info(f" ✓ Transaction correctly rejected: {result_bypass['reject-reason']}") self.log.info(" ✓ ConsensusScriptChecks prevents bypass of REDUCED_DATA consensus rules") + def test_generation_output_size_limit(self): + """Test that generation tx outputs are also subject to output size limits.""" + self.log.info("Testing generation tx output scriptPubKey size limits...") + + node = self.nodes[0] + + def create_block_with_generation_output(script_pubkey): + """Helper to create a block with a custom generation tx output script.""" + tip = node.getbestblockhash() + height = node.getblockcount() + 1 + tip_header = node.getblockheader(tip) + block_time = tip_header['time'] + 1 + coinbase = create_coinbase(height, script_pubkey=script_pubkey) + block = create_block(int(tip, 16), coinbase, ntime=block_time) + add_witness_commitment(block) + block.solve() + return block + + # Test 1: 34-byte P2WSH generation tx output (exactly at limit - should pass) + self.log.info(" Test: 34-byte P2WSH generation tx output (at limit)") + witness_program_32 = b'\x00' * 32 + script_p2wsh = CScript([OP_0, witness_program_32]) + assert_equal(len(script_p2wsh), 34) + + block_valid = create_block_with_generation_output(script_p2wsh) + result = node.submitblock(block_valid.serialize().hex()) + assert_equal(result, None) + self.log.info(" ✓ 34-byte P2WSH generation tx output accepted") + + # Test 2: 35-byte P2PK generation tx output (exceeds limit - should fail) + self.log.info(" Test: 35-byte P2PK generation tx output (exceeds limit)") + pubkey_33 = b'\x02' + b'\x00' * 32 # Compressed pubkey format + script_p2pk = CScript([pubkey_33, OP_CHECKSIG]) + assert_equal(len(script_p2pk), 35) + + block_invalid = create_block_with_generation_output(script_p2pk) + result = node.submitblock(block_invalid.serialize().hex()) + assert_equal(result, 'bad-txns-vout-script-toolarge') + self.log.info(" ✓ 35-byte P2PK generation tx output rejected") + + # Test 3: Generation tx with OP_RETURN at 83 bytes (at OP_RETURN limit - should pass) + self.log.info(" Test: Generation tx with 83-byte OP_RETURN extra output (at limit)") + # 80 bytes data = OP_RETURN (1) + push opcode (1) + data (80) = 82 bytes + # We need 83 bytes, so use 81 bytes of data with PUSHDATA1 + # OP_RETURN (1) + OP_PUSHDATA1 (1) + len (1) + data (80) = 83 bytes + data_80 = b'\x00' * 80 + script_opreturn_83 = CScript([OP_RETURN, data_80]) + # Verify we're at exactly 83 bytes (with CScript's encoding) + self.log.info(f" OP_RETURN script length: {len(script_opreturn_83)} bytes") + + # Create block with valid main output and OP_RETURN extra output + tip = node.getbestblockhash() + height = node.getblockcount() + 1 + tip_header = node.getblockheader(tip) + block_time = tip_header['time'] + 1 + coinbase = create_coinbase(height, extra_output_script=script_opreturn_83) + block_opreturn_valid = create_block(int(tip, 16), coinbase, ntime=block_time) + add_witness_commitment(block_opreturn_valid) + block_opreturn_valid.solve() + + result = node.submitblock(block_opreturn_valid.serialize().hex()) + if result is None: + self.log.info(" ✓ Generation tx with 83-byte OP_RETURN output accepted") + else: + self.log.info(f" Note: Generation tx OP_RETURN result: {result}") + + # Test 4: Generation tx with OP_RETURN at 84 bytes (exceeds limit - should fail) + self.log.info(" Test: Generation tx with 84-byte OP_RETURN extra output (exceeds limit)") + # 81 bytes data = OP_RETURN (1) + OP_PUSHDATA1 (1) + len (1) + data (81) = 84 bytes + data_81 = b'\x00' * 81 + script_opreturn_84 = CScript([OP_RETURN, data_81]) + self.log.info(f" OP_RETURN script length: {len(script_opreturn_84)} bytes") + + tip = node.getbestblockhash() + height = node.getblockcount() + 1 + tip_header = node.getblockheader(tip) + block_time = tip_header['time'] + 1 + coinbase = create_coinbase(height, extra_output_script=script_opreturn_84) + block_opreturn_invalid = create_block(int(tip, 16), coinbase, ntime=block_time) + add_witness_commitment(block_opreturn_invalid) + block_opreturn_invalid.solve() + + result = node.submitblock(block_opreturn_invalid.serialize().hex()) + assert_equal(result, 'bad-txns-vout-script-toolarge') + self.log.info(" ✓ Generation tx with 84-byte OP_RETURN output rejected") + def run_test(self): self.init_test() # Run all spec tests self.test_output_script_size_limit() + self.test_generation_output_size_limit() self.test_pushdata_size_limit() self.test_undefined_witness_versions() self.test_taproot_annex_rejection() From bf2db068ea0c659b2964f402173762769e4a3a35 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Mon, 26 Jan 2026 22:37:45 -0600 Subject: [PATCH 63/81] 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..8dfd1e599944 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 UASF-style flag-day activation. + * 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 (UASF-style) + // 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 (temporary UASF) + // 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 9a58b4baee6d0c209fecf3c2d94b5b84366c9e9b Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Thu, 12 Feb 2026 00:28:04 -0600 Subject: [PATCH 64/81] script: require empty witness for P2A spends --- src/script/interpreter.cpp | 2 +- test/functional/feature_uasf_reduced_data.py | 61 ++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/script/interpreter.cpp b/src/script/interpreter.cpp index e1089f184b32..065f2d737b66 100644 --- a/src/script/interpreter.cpp +++ b/src/script/interpreter.cpp @@ -1992,7 +1992,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) { diff --git a/test/functional/feature_uasf_reduced_data.py b/test/functional/feature_uasf_reduced_data.py index ef27c677cfbb..73835294c495 100755 --- a/test/functional/feature_uasf_reduced_data.py +++ b/test/functional/feature_uasf_reduced_data.py @@ -79,6 +79,7 @@ add_witness_commitment, ) from test_framework.script_util import ( + PAY_TO_ANCHOR, script_to_p2wsh_script, script_to_p2sh_script, ) @@ -856,6 +857,65 @@ def create_block_with_generation_output(script_pubkey): assert_equal(result, 'bad-txns-vout-script-toolarge') self.log.info(" ✓ Generation tx with 84-byte OP_RETURN output rejected") + def test_p2a_witness_rejected(self): + """Test that P2A (PayToAnchor) spends with non-empty witness are rejected.""" + self.log.info("Testing P2A non-empty witness rejection...") + node = self.nodes[0] + + # Create a P2A output (4 bytes, within the 34-byte limit) + p2a_funding = self.create_test_transaction(PAY_TO_ANCHOR) + p2a_funding.rehash() + p2a_value = p2a_funding.vout[0].nValue + + block_height = node.getblockcount() + 1 + block = create_block(int(node.getbestblockhash(), 16), create_coinbase(block_height), int(node.getblockheader(node.getbestblockhash())['time']) + 1) + block.vtx.append(p2a_funding) + add_witness_commitment(block) + block.solve() + assert_equal(node.submitblock(block.serialize().hex()), None) + self.log.info(" P2A output created") + + # Test 1: Spend with 100 KB of arbitrary witness data (must be rejected) + self.log.info(" Test: P2A spend with large arbitrary witness (should be rejected)") + arbitrary_data = b'\xab' * 100_000 + + p2a_spend = CTransaction() + p2a_spend.vin = [CTxIn(COutPoint(int(p2a_funding.rehash(), 16), 0))] + p2a_spend.vout = [CTxOut(p2a_value - 1000, CScript([OP_0, hash160(b'\x01' * 33)]))] + p2a_spend.wit.vtxinwit = [CTxInWitness()] + p2a_spend.wit.vtxinwit[0].scriptWitness.stack = [arbitrary_data] + p2a_spend.rehash() + + block_height = node.getblockcount() + 1 + block_bad = create_block(int(node.getbestblockhash(), 16), create_coinbase(block_height), int(node.getblockheader(node.getbestblockhash())['time']) + 1) + block_bad.vtx.append(p2a_spend) + add_witness_commitment(block_bad) + block_bad.solve() + + result = node.submitblock(block_bad.serialize().hex()) + assert result is not None + assert_equal(node.getblockcount(), block_height - 1) + self.log.info(f" ✓ P2A spend with 100 KB witness rejected ({result})") + + # Test 2: Spend with empty witness (must still be accepted) + self.log.info(" Test: P2A spend with empty witness (should be accepted)") + p2a_spend_empty = CTransaction() + p2a_spend_empty.vin = [CTxIn(COutPoint(int(p2a_funding.rehash(), 16), 0))] + p2a_spend_empty.vout = [CTxOut(p2a_value - 1000, CScript([OP_0, hash160(b'\x01' * 33)]))] + p2a_spend_empty.wit.vtxinwit = [CTxInWitness()] + p2a_spend_empty.wit.vtxinwit[0].scriptWitness.stack = [] + p2a_spend_empty.rehash() + + block_height = node.getblockcount() + 1 + block_good = create_block(int(node.getbestblockhash(), 16), create_coinbase(block_height), int(node.getblockheader(node.getbestblockhash())['time']) + 1) + block_good.vtx.append(p2a_spend_empty) + add_witness_commitment(block_good) + block_good.solve() + + assert_equal(node.submitblock(block_good.serialize().hex()), None) + assert_equal(node.getblockcount(), block_height) + self.log.info(" ✓ P2A spend with empty witness accepted") + def run_test(self): self.init_test() @@ -869,6 +929,7 @@ def run_test(self): self.test_op_success_rejection() self.test_op_if_notif_rejection() self.test_mandatory_flags_cannot_be_bypassed() + self.test_p2a_witness_rejected() self.log.info("All UASF-ReducedData tests completed") From eeaec45364957be0199481a4fa0c36a8cfad5b38 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Fri, 6 Feb 2026 19:12:52 -0600 Subject: [PATCH 65/81] chainparams: enable BIP-110 deployment on testnet4 --- src/kernel/chainparams.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/kernel/chainparams.cpp b/src/kernel/chainparams.cpp index d79a9966daa1..dc3f22a95335 100644 --- a/src/kernel/chainparams.cpp +++ b/src/kernel/chainparams.cpp @@ -365,10 +365,13 @@ 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 + // Deployment of UASF-ReducedData (temporary UASF) 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].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 From b362f0bec25bb4aa09e11353d085654b6a17ce91 Mon Sep 17 00:00:00 2001 From: Luke Dashjr Date: Fri, 13 Feb 2026 19:49:15 +0000 Subject: [PATCH 66/81] Bugfix: validation: Do not cache the result of CheckInputScripts if flags_per_input is used (and avoid using it when unnecessary) --- src/validation.cpp | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/validation.cpp b/src/validation.cpp index 406981bd75ea..2a78cdda63f2 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -2146,9 +2146,9 @@ 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). + * WARNING: flags_per_input deviations from flags must be handled with care. It should only be more + * relaxed than flags, never stricter (or a cached result could be wrong). Do not provide + * flags_per_input if every input uses the same flags, or the result will not be cached. * * 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, @@ -2232,7 +2232,7 @@ bool CheckInputScripts(const CTransaction& tx, TxValidationState& state, } } - if (cacheFullScriptStore && !pvChecks) { + if (cacheFullScriptStore && (!pvChecks) && flags_per_input.empty()) { // We executed all of the provided scripts, and were told to // cache the result. Do so now. validation_cache.m_script_execution_cache.insert(hashCacheEntry); @@ -2689,10 +2689,13 @@ bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state, // BIP68 lock checks (as opposed to nLockTime checks) must // be in ConnectBlock because they require the UTXO set prevheights.resize(tx.vin.size()); - flags_per_input.resize(tx.vin.size()); + flags_per_input.clear(); for (size_t j = 0; j < tx.vin.size(); j++) { prevheights[j] = view.AccessCoin(tx.vin[j].prevout).nHeight; - flags_per_input[j] = (prevheights[j] < reduced_data_start_height) ? (flags & ~REDUCED_DATA_MANDATORY_VERIFY_FLAGS) : flags; + if (prevheights[j] < reduced_data_start_height) { + flags_per_input.resize(tx.vin.size(), flags); + flags_per_input[j] = flags & ~REDUCED_DATA_MANDATORY_VERIFY_FLAGS; + } } if (!SequenceLocks(tx, nLockTimeFlags, prevheights, *pindex)) { From 2f7f9402bf8c414813300a00006d5a55b1b260ea Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Fri, 13 Feb 2026 15:04:54 -0600 Subject: [PATCH 67/81] test: add unit and functional tests for CheckInputScripts cache-poisoning via activation-boundary reorg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lőrinc --- src/test/txvalidationcache_tests.cpp | 74 +++++++++++++++++++ .../feature_reduced_data_utxo_height.py | 54 +++++++++++++- 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/src/test/txvalidationcache_tests.cpp b/src/test/txvalidationcache_tests.cpp index 5b98172fcf28..f38757a63116 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