Merge bitcoin/bitcoin#33105: validation: detect witness stripping without re-running Script checks

27aefac425 validation: detect witness stripping without re-running Script checks (Antoine Poinsot)
2907b58834 policy: introduce a helper to detect whether a transaction spends Segwit outputs (Antoine Poinsot)
eb073209db qa: test witness stripping in p2p_segwit (Antoine Poinsot)

Pull request description:

  Since it was introduced in 4eb515574e (#18044), the detection of a stripped witness relies on running the Script checks 3 times. In the worst case, this consists in running Script validation for every single input 3 times.

  Detection of a stripped witness is necessary because in this case wtxid==txid, and the transaction's wtxid must not be added to the reject filter or it could allow a malicious peer to interfere with txid-based orphan resolution as used in 1p1c package relay.

  However it is not necessary to run Script validation to detect a stripped witness (much less so doing it 3 times in a row). There are 3 types of witness program: defined program types (Taproot, P2WPKH and P2WSH), undefined types, and the Pay-to-anchor carve-out.

  For defined program types, Script validation with an empty witness will always fail (by consensus). For undefined program types, Script validation is always going to fail regardless of the witness (by standardness). For P2A, an empty witness is never going to lead to a failure.

  Therefore it holds that we can always detect a stripped witness without re-running Script validation. However this might lead to more "false positives" (cases where we return witness stripping for an otherwise invalid transaction) than the existing implementation. For instance a transaction with one P2PKH input with an invalid signature and one P2WPKH input with its witness stripped. The existing implementation would treat it as consensus invalid while the implementation in this PR would always consider it witness stripped.

  h/t AJ: this essentially implements a variant of https://github.com/bitcoin/bitcoin/pull/33066#issuecomment-3135258539.

ACKs for top commit:
  sipa:
    re-ACK 27aefac425
  Crypt-iQ:
    re-ACK 27aefac425
  glozow:
    reACK 27aefac425

Tree-SHA512: 70cf76b655b52bc8fa2759133315a3f11140844b6b80d9de3c95f592050978cc01a87bd2446e3a9c25cc872efea7659d6da3337b1a709511771fece206e9f149
This commit is contained in:
merge-script
2025-08-08 14:18:04 -04:00
5 changed files with 211 additions and 7 deletions

View File

@@ -337,6 +337,42 @@ bool IsWitnessStandard(const CTransaction& tx, const CCoinsViewCache& mapInputs)
return true;
}
bool SpendsNonAnchorWitnessProg(const CTransaction& tx, const CCoinsViewCache& prevouts)
{
if (tx.IsCoinBase()) {
return false;
}
int version;
std::vector<uint8_t> program;
for (const auto& txin: tx.vin) {
const auto& prev_spk{prevouts.AccessCoin(txin.prevout).out.scriptPubKey};
// Note this includes not-yet-defined witness programs.
if (prev_spk.IsWitnessProgram(version, program) && !prev_spk.IsPayToAnchor(version, program)) {
return true;
}
// For P2SH extract the redeem script and check if it spends a non-Taproot witness program. Note
// this is fine to call EvalScript (as done in AreInputsStandard/IsWitnessStandard) because this
// function is only ever called after IsStandardTx, which checks the scriptsig is pushonly.
if (prev_spk.IsPayToScriptHash()) {
// If EvalScript fails or results in an empty stack, the transaction is invalid by consensus.
std::vector <std::vector<uint8_t>> stack;
if (!EvalScript(stack, txin.scriptSig, SCRIPT_VERIFY_NONE, BaseSignatureChecker{}, SigVersion::BASE)
|| stack.empty()) {
continue;
}
const CScript redeem_script{stack.back().begin(), stack.back().end()};
if (redeem_script.IsWitnessProgram(version, program)) {
return true;
}
}
}
return false;
}
int64_t GetVirtualTransactionSize(int64_t nWeight, int64_t nSigOpCost, unsigned int bytes_per_sigop)
{
return (std::max(nWeight, nSigOpCost * bytes_per_sigop) + WITNESS_SCALE_FACTOR - 1) / WITNESS_SCALE_FACTOR;

View File

@@ -166,6 +166,11 @@ bool AreInputsStandard(const CTransaction& tx, const CCoinsViewCache& mapInputs)
* Also enforce a maximum stack item size limit and no annexes for tapscript spends.
*/
bool IsWitnessStandard(const CTransaction& tx, const CCoinsViewCache& mapInputs);
/**
* Check whether this transaction spends any witness program but P2A, including not-yet-defined ones.
* May return `false` early for consensus-invalid transactions.
*/
bool SpendsNonAnchorWitnessProg(const CTransaction& tx, const CCoinsViewCache& prevouts);
/** Compute the virtual transaction size (weight reinterpreted as bytes). */
int64_t GetVirtualTransactionSize(int64_t nWeight, int64_t nSigOpCost, unsigned int bytes_per_sigop);

View File

@@ -1149,4 +1149,159 @@ BOOST_AUTO_TEST_CASE(max_standard_legacy_sigops)
BOOST_CHECK(!::AreInputsStandard(CTransaction(tx_max_sigops), coins));
}
/** Sanity check the return value of SpendsNonAnchorWitnessProg for various output types. */
BOOST_AUTO_TEST_CASE(spends_witness_prog)
{
CCoinsView coins_dummy;
CCoinsViewCache coins(&coins_dummy);
CKey key;
key.MakeNewKey(true);
const CPubKey pubkey{key.GetPubKey()};
CMutableTransaction tx_create{}, tx_spend{};
tx_create.vout.emplace_back(0, CScript{});
tx_spend.vin.emplace_back(Txid{}, 0);
std::vector<std::vector<uint8_t>> sol_dummy;
// CNoDestination, PubKeyDestination, PKHash, ScriptHash, WitnessV0ScriptHash, WitnessV0KeyHash,
// WitnessV1Taproot, PayToAnchor, WitnessUnknown.
static_assert(std::variant_size_v<CTxDestination> == 9);
// Go through all defined output types and sanity check SpendsNonAnchorWitnessProg.
// P2PK
tx_create.vout[0].scriptPubKey = GetScriptForDestination(PubKeyDestination{pubkey});
BOOST_CHECK_EQUAL(Solver(tx_create.vout[0].scriptPubKey, sol_dummy), TxoutType::PUBKEY);
tx_spend.vin[0].prevout.hash = tx_create.GetHash();
AddCoins(coins, CTransaction{tx_create}, 0, false);
BOOST_CHECK(!::SpendsNonAnchorWitnessProg(CTransaction{tx_spend}, coins));
// P2PKH
tx_create.vout[0].scriptPubKey = GetScriptForDestination(PKHash{pubkey});
BOOST_CHECK_EQUAL(Solver(tx_create.vout[0].scriptPubKey, sol_dummy), TxoutType::PUBKEYHASH);
tx_spend.vin[0].prevout.hash = tx_create.GetHash();
AddCoins(coins, CTransaction{tx_create}, 0, false);
BOOST_CHECK(!::SpendsNonAnchorWitnessProg(CTransaction{tx_spend}, coins));
// P2SH
auto redeem_script{CScript{} << OP_1 << OP_CHECKSIG};
tx_create.vout[0].scriptPubKey = GetScriptForDestination(ScriptHash{redeem_script});
BOOST_CHECK_EQUAL(Solver(tx_create.vout[0].scriptPubKey, sol_dummy), TxoutType::SCRIPTHASH);
tx_spend.vin[0].prevout.hash = tx_create.GetHash();
tx_spend.vin[0].scriptSig = CScript{} << OP_0 << ToByteVector(redeem_script);
AddCoins(coins, CTransaction{tx_create}, 0, false);
BOOST_CHECK(!::SpendsNonAnchorWitnessProg(CTransaction{tx_spend}, coins));
tx_spend.vin[0].scriptSig.clear();
// native P2WSH
const auto witness_script{CScript{} << OP_12 << OP_HASH160 << OP_DUP << OP_EQUAL};
tx_create.vout[0].scriptPubKey = GetScriptForDestination(WitnessV0ScriptHash{witness_script});
BOOST_CHECK_EQUAL(Solver(tx_create.vout[0].scriptPubKey, sol_dummy), TxoutType::WITNESS_V0_SCRIPTHASH);
tx_spend.vin[0].prevout.hash = tx_create.GetHash();
AddCoins(coins, CTransaction{tx_create}, 0, false);
BOOST_CHECK(::SpendsNonAnchorWitnessProg(CTransaction{tx_spend}, coins));
// P2SH-wrapped P2WSH
redeem_script = tx_create.vout[0].scriptPubKey;
tx_create.vout[0].scriptPubKey = GetScriptForDestination(ScriptHash(redeem_script));
BOOST_CHECK_EQUAL(Solver(tx_create.vout[0].scriptPubKey, sol_dummy), TxoutType::SCRIPTHASH);
tx_spend.vin[0].prevout.hash = tx_create.GetHash();
tx_spend.vin[0].scriptSig = CScript{} << ToByteVector(redeem_script);
AddCoins(coins, CTransaction{tx_create}, 0, false);
BOOST_CHECK(::SpendsNonAnchorWitnessProg(CTransaction{tx_spend}, coins));
tx_spend.vin[0].scriptSig.clear();
BOOST_CHECK(!::SpendsNonAnchorWitnessProg(CTransaction{tx_spend}, coins));
// native P2WPKH
tx_create.vout[0].scriptPubKey = GetScriptForDestination(WitnessV0KeyHash{pubkey});
BOOST_CHECK_EQUAL(Solver(tx_create.vout[0].scriptPubKey, sol_dummy), TxoutType::WITNESS_V0_KEYHASH);
tx_spend.vin[0].prevout.hash = tx_create.GetHash();
AddCoins(coins, CTransaction{tx_create}, 0, false);
BOOST_CHECK(::SpendsNonAnchorWitnessProg(CTransaction{tx_spend}, coins));
// P2SH-wrapped P2WPKH
redeem_script = tx_create.vout[0].scriptPubKey;
tx_create.vout[0].scriptPubKey = GetScriptForDestination(ScriptHash(redeem_script));
BOOST_CHECK_EQUAL(Solver(tx_create.vout[0].scriptPubKey, sol_dummy), TxoutType::SCRIPTHASH);
tx_spend.vin[0].prevout.hash = tx_create.GetHash();
tx_spend.vin[0].scriptSig = CScript{} << ToByteVector(redeem_script);
AddCoins(coins, CTransaction{tx_create}, 0, false);
BOOST_CHECK(::SpendsNonAnchorWitnessProg(CTransaction{tx_spend}, coins));
tx_spend.vin[0].scriptSig.clear();
BOOST_CHECK(!::SpendsNonAnchorWitnessProg(CTransaction{tx_spend}, coins));
// P2TR
tx_create.vout[0].scriptPubKey = GetScriptForDestination(WitnessV1Taproot{XOnlyPubKey{pubkey}});
BOOST_CHECK_EQUAL(Solver(tx_create.vout[0].scriptPubKey, sol_dummy), TxoutType::WITNESS_V1_TAPROOT);
tx_spend.vin[0].prevout.hash = tx_create.GetHash();
AddCoins(coins, CTransaction{tx_create}, 0, false);
BOOST_CHECK(::SpendsNonAnchorWitnessProg(CTransaction{tx_spend}, coins));
// P2SH-wrapped P2TR (undefined, non-standard)
redeem_script = tx_create.vout[0].scriptPubKey;
tx_create.vout[0].scriptPubKey = GetScriptForDestination(ScriptHash(redeem_script));
BOOST_CHECK_EQUAL(Solver(tx_create.vout[0].scriptPubKey, sol_dummy), TxoutType::SCRIPTHASH);
tx_spend.vin[0].prevout.hash = tx_create.GetHash();
tx_spend.vin[0].scriptSig = CScript{} << ToByteVector(redeem_script);
AddCoins(coins, CTransaction{tx_create}, 0, false);
BOOST_CHECK(::SpendsNonAnchorWitnessProg(CTransaction{tx_spend}, coins));
tx_spend.vin[0].scriptSig.clear();
BOOST_CHECK(!::SpendsNonAnchorWitnessProg(CTransaction{tx_spend}, coins));
// P2A
tx_create.vout[0].scriptPubKey = GetScriptForDestination(PayToAnchor{});
BOOST_CHECK_EQUAL(Solver(tx_create.vout[0].scriptPubKey, sol_dummy), TxoutType::ANCHOR);
tx_spend.vin[0].prevout.hash = tx_create.GetHash();
AddCoins(coins, CTransaction{tx_create}, 0, false);
BOOST_CHECK(!::SpendsNonAnchorWitnessProg(CTransaction{tx_spend}, coins));
// P2SH-wrapped P2A (undefined, non-standard)
redeem_script = tx_create.vout[0].scriptPubKey;
tx_create.vout[0].scriptPubKey = GetScriptForDestination(ScriptHash(redeem_script));
BOOST_CHECK_EQUAL(Solver(tx_create.vout[0].scriptPubKey, sol_dummy), TxoutType::SCRIPTHASH);
tx_spend.vin[0].prevout.hash = tx_create.GetHash();
tx_spend.vin[0].scriptSig = CScript{} << ToByteVector(redeem_script);
AddCoins(coins, CTransaction{tx_create}, 0, false);
BOOST_CHECK(::SpendsNonAnchorWitnessProg(CTransaction{tx_spend}, coins));
tx_spend.vin[0].scriptSig.clear();
// Undefined version 1 witness program
tx_create.vout[0].scriptPubKey = GetScriptForDestination(WitnessUnknown{1, {0x42, 0x42}});
BOOST_CHECK_EQUAL(Solver(tx_create.vout[0].scriptPubKey, sol_dummy), TxoutType::WITNESS_UNKNOWN);
tx_spend.vin[0].prevout.hash = tx_create.GetHash();
AddCoins(coins, CTransaction{tx_create}, 0, false);
BOOST_CHECK(::SpendsNonAnchorWitnessProg(CTransaction{tx_spend}, coins));
// P2SH-wrapped undefined version 1 witness program
redeem_script = tx_create.vout[0].scriptPubKey;
tx_create.vout[0].scriptPubKey = GetScriptForDestination(ScriptHash(redeem_script));
BOOST_CHECK_EQUAL(Solver(tx_create.vout[0].scriptPubKey, sol_dummy), TxoutType::SCRIPTHASH);
tx_spend.vin[0].prevout.hash = tx_create.GetHash();
tx_spend.vin[0].scriptSig = CScript{} << ToByteVector(redeem_script);
AddCoins(coins, CTransaction{tx_create}, 0, false);
BOOST_CHECK(::SpendsNonAnchorWitnessProg(CTransaction{tx_spend}, coins));
tx_spend.vin[0].scriptSig.clear();
BOOST_CHECK(!::SpendsNonAnchorWitnessProg(CTransaction{tx_spend}, coins));
// Various undefined version >1 32-byte witness programs.
const auto program{ToByteVector(XOnlyPubKey{pubkey})};
for (int i{2}; i <= 16; ++i) {
tx_create.vout[0].scriptPubKey = GetScriptForDestination(WitnessUnknown{i, program});
BOOST_CHECK_EQUAL(Solver(tx_create.vout[0].scriptPubKey, sol_dummy), TxoutType::WITNESS_UNKNOWN);
tx_spend.vin[0].prevout.hash = tx_create.GetHash();
AddCoins(coins, CTransaction{tx_create}, 0, false);
BOOST_CHECK(::SpendsNonAnchorWitnessProg(CTransaction{tx_spend}, coins));
// It's also detected within P2SH.
redeem_script = tx_create.vout[0].scriptPubKey;
tx_create.vout[0].scriptPubKey = GetScriptForDestination(ScriptHash(redeem_script));
BOOST_CHECK_EQUAL(Solver(tx_create.vout[0].scriptPubKey, sol_dummy), TxoutType::SCRIPTHASH);
tx_spend.vin[0].prevout.hash = tx_create.GetHash();
tx_spend.vin[0].scriptSig = CScript{} << ToByteVector(redeem_script);
AddCoins(coins, CTransaction{tx_create}, 0, false);
BOOST_CHECK(::SpendsNonAnchorWitnessProg(CTransaction{tx_spend}, coins));
tx_spend.vin[0].scriptSig.clear();
BOOST_CHECK(!::SpendsNonAnchorWitnessProg(CTransaction{tx_spend}, coins));
}
}
BOOST_AUTO_TEST_SUITE_END()

View File

@@ -1254,13 +1254,8 @@ bool MemPoolAccept::PolicyScriptChecks(const ATMPArgs& args, Workspace& ws)
// Check input scripts and signatures.
// This is done last to help prevent CPU exhaustion denial-of-service attacks.
if (!CheckInputScripts(tx, state, m_view, scriptVerifyFlags, true, false, ws.m_precomputed_txdata, GetValidationCache())) {
// SCRIPT_VERIFY_CLEANSTACK requires SCRIPT_VERIFY_WITNESS, so we
// need to turn both off, and compare against just turning off CLEANSTACK
// to see if the failure is specifically due to witness validation.
TxValidationState state_dummy; // Want reported failures to be from first CheckInputScripts
if (!tx.HasWitness() && CheckInputScripts(tx, state_dummy, m_view, scriptVerifyFlags & ~(SCRIPT_VERIFY_WITNESS | SCRIPT_VERIFY_CLEANSTACK), true, false, ws.m_precomputed_txdata, GetValidationCache()) &&
!CheckInputScripts(tx, state_dummy, m_view, scriptVerifyFlags & ~SCRIPT_VERIFY_CLEANSTACK, true, false, ws.m_precomputed_txdata, GetValidationCache())) {
// Only the witness is missing, so the transaction itself may be fine.
// Detect a failure due to a missing witness so that p2p code can handle rejection caching appropriately.
if (!tx.HasWitness() && SpendsNonAnchorWitnessProg(tx, m_view)) {
state.Invalid(TxValidationResult::TX_WITNESS_STRIPPED,
state.GetRejectReason(), state.GetDebugMessage());
}

View File

@@ -693,6 +693,12 @@ class SegWitTest(BitcoinTestFramework):
expected_msgs=[spend_tx.txid_hex, 'was not accepted: mandatory-script-verify-flag-failed (Witness program was passed an empty witness)']):
test_transaction_acceptance(self.nodes[0], self.test_node, spend_tx, with_witness=False, accepted=False)
# The transaction was detected as witness stripped above and not added to the reject
# filter. Trying again will check it again and result in the same error.
with self.nodes[0].assert_debug_log(
expected_msgs=[spend_tx.txid_hex, 'was not accepted: mandatory-script-verify-flag-failed (Witness program was passed an empty witness)']):
test_transaction_acceptance(self.nodes[0], self.test_node, spend_tx, with_witness=False, accepted=False)
# Try to put the witness script in the scriptSig, should also fail.
spend_tx.vin[0].scriptSig = CScript([p2wsh_pubkey, b'a'])
with self.nodes[0].assert_debug_log(
@@ -1245,6 +1251,13 @@ class SegWitTest(BitcoinTestFramework):
test_transaction_acceptance(self.nodes[0], self.test_node, tx2, with_witness=True, accepted=True)
test_transaction_acceptance(self.nodes[0], self.test_node, tx3, with_witness=True, accepted=False)
# Now do the opposite: strip the witness entirely. This will be detected as witness stripping and
# the (w)txid won't be added to the reject filter: we can try again and get the same error.
tx3.wit.vtxinwit[0].scriptWitness.stack = []
reason = "was not accepted: mandatory-script-verify-flag-failed (Witness program was passed an empty witness)"
test_transaction_acceptance(self.nodes[0], self.test_node, tx3, with_witness=False, accepted=False, reason=reason)
test_transaction_acceptance(self.nodes[0], self.test_node, tx3, with_witness=False, accepted=False, reason=reason)
# Get rid of the extra witness, and verify acceptance.
tx3.wit.vtxinwit[0].scriptWitness.stack = [witness_script]
# Also check that old_node gets a tx announcement, even though this is