Merge bitcoin/bitcoin#35797: psbt: support output metadata updates before inputs are added
What changed, and why it matters
This commit fixes a crash bug in Bitcoin Core's PSBT (Partially Signed Bitcoin Transaction) handling. When a user created a PSBT that had outputs but no inputs yet—a valid situation in the newer PSBTv2 format—and then asked the node to update output metadata using the `descriptorprocesspsbt` RPC, the node could crash. The crash happened because the code tried to use input index 0 of a transaction that had no inputs. The fix creates a temporary one-input transaction just for safely walking through the output script, while still taking the actual output data from the PSBT itself. It is a denial-of-service class bug, not a theft-of-funds bug, and requires an authenticated RPC caller to trigger.
Apply the patch. It is a targeted, low-risk fix with regression tests. Node operators running versions affected by this bug should upgrade, especially if they expose descriptorprocesspsbt to authenticated wallet users. No immediate broader network impact is expected.
Security signals we found
Denial-of-service via authenticated RPC (descriptorprocesspsbt)
Null/invalid input access when PSBT has zero inputs
PSBTv2 output-before-input semantics
Miniscript timelock check path reachable during metadata update
Crash abort in node process
Evidence from the diff
UpdatePSBTOutput() in src/psbt.cpp previously passed the PSBT’s unsigned transaction directly to MutableTransactionSignatureCreator with input_idx=0. If the PSBT had outputs but no inputs (permitted by PSBTv2), accessing that missing input could cause an abort during ECDSA signing or a miniscript timelock check. The patch constructs a fresh CMutableTransaction with one dummy input and uses it only as the signature-creator context, while the real CTxOut is still read from unsigned_tx->vout.at(index). This preserves metadata extraction (keypaths, redeem/witness scripts, taproot paths) for outputs added before inputs. Tests are added covering P2PKH, P2WPKH, P2SH-P2WPKH, P2WSH, miniscript timelock, and raw Taproot descriptors, each with and without an input. A functional RPC test exercises descriptorprocesspsbt on both input-less and input-bearing PSBTs.
Changed components
src/psbt.cppsrc/test/psbt_tests.cpptest/functional/rpc_psbt.pyRPC: descriptorprocesspsbtPSBT output metadata update logicInspect captured patch +108 / −3
### src/psbt.cpp
@@ -605,8 +605,7 @@ void UpdatePSBTOutput(const SigningProvider& provider, PartiallySignedTransactio
if (!unsigned_tx) {
return;
}
- CMutableTransaction& tx = *unsigned_tx;
- const CTxOut& out = tx.vout.at(index);
+ const CTxOut& out = unsigned_tx->vout.at(index);
PSBTOutput& psbt_out = psbt.outputs.at(index);
// Fill a SignatureData with output info
@@ -616,6 +615,8 @@ void UpdatePSBTOutput(const SigningProvider& provider, PartiallySignedTransactio
// Construct a would-be spend of this output, to update sigdata with.
// Note that ProduceSignature is used to fill in metadata (not actual signatures),
// so provider does not need to provide any private keys (it can be a HidingSigningProvider).
+ CMutableTransaction tx{};
+ tx.vin.emplace_back();
MutableTransactionSignatureCreator creator(tx, /*input_idx=*/0, out.nValue, {.sighash_type = SIGHASH_ALL});
ProduceSignature(provider, creator, out.scriptPubKey, sigdata);
### src/test/psbt_tests.cpp
@@ -2,10 +2,21 @@
// Distributed under the MIT software license, see the accompanying
// file COPYING or https://www.opensource.org/licenses/mit-license.php.
+#include <addresstype.h>
+#include <key.h>
#include <psbt.h>
+#include <script/descriptor.h>
+#include <script/script.h>
+#include <script/signingprovider.h>
+#include <script/solver.h>
+#include <test/util/setup_common.h>
+#include <util/strencodings.h>
+#include <util/string.h>
#include <boost/test/unit_test.hpp>
-#include <test/util/setup_common.h>
+
+#include <string>
+#include <vector>
BOOST_FIXTURE_TEST_SUITE(psbt_tests, BasicTestingSetup)
@@ -216,4 +227,89 @@ BOOST_AUTO_TEST_CASE(merge_proprietary_fields)
BOOST_CHECK(output_it->value == right_prop.value);
}
+struct PSBTOutputTest {
+ CPubKey pubkey;
+ FlatSigningProvider provider;
+ CScript script_pubkey;
+
+ explicit PSBTOutputTest(std::string descriptor)
+ {
+ CKey key{GenerateRandomKey()};
+ pubkey = key.GetPubKey();
+ provider.keys.emplace(pubkey.GetID(), key);
+
+ util::ReplaceAll(descriptor, "<KEY>", HexStr(pubkey));
+ std::string error;
+ auto descriptors{Parse(descriptor, provider, error, /*require_checksum=*/false)};
+ BOOST_REQUIRE_MESSAGE(!descriptors.empty(), error);
+ std::vector<CScript> output_scripts;
+ BOOST_REQUIRE(descriptors[0]->Expand(/*pos=*/0, provider, output_scripts, provider));
+ BOOST_REQUIRE_EQUAL(output_scripts.size(), 1);
+ script_pubkey = output_scripts[0];
+ }
+
+ PSBTOutput UpdateOutput(bool has_input) const
+ {
+ CMutableTransaction tx;
+ if (has_input) tx.vin.emplace_back();
+ tx.vout.emplace_back(0, script_pubkey);
+ PartiallySignedTransaction psbt{tx};
+ UpdatePSBTOutput(provider, psbt, 0);
+ return psbt.outputs[0];
+ }
+};
+
+BOOST_AUTO_TEST_CASE(update_psbt_output_keypaths)
+{
+ for (bool has_input : {false, true}) {
+ for (const auto& descriptor : {"pkh(<KEY>)", "wpkh(<KEY>)"}) {
+ PSBTOutputTest test{descriptor};
+ auto out{test.UpdateOutput(has_input)};
+ BOOST_CHECK(out.hd_keypaths.contains(test.pubkey));
+ BOOST_CHECK(out.redeem_script.empty());
+ BOOST_CHECK(out.witness_script.empty());
+ }
+ }
+}
+
+BOOST_AUTO_TEST_CASE(update_psbt_output_redeem_script)
+{
+ for (bool has_input : {false, true}) {
+ PSBTOutputTest test{"sh(wpkh(<KEY>))"};
+ auto out{test.UpdateOutput(has_input)};
+ BOOST_CHECK(out.redeem_script == GetScriptForDestination(WitnessV0KeyHash{test.pubkey}));
+ BOOST_CHECK(out.hd_keypaths.contains(test.pubkey));
+ }
+}
+
+BOOST_AUTO_TEST_CASE(update_psbt_output_witness_script)
+{
+ for (bool has_input : {false, true}) {
+ PSBTOutputTest test{"wsh(pk(<KEY>))"};
+ auto out{test.UpdateOutput(has_input)};
+ BOOST_CHECK(out.witness_script == GetScriptForRawPubKey(test.pubkey));
+ BOOST_CHECK(out.hd_keypaths.contains(test.pubkey));
+ }
+}
+
+BOOST_AUTO_TEST_CASE(update_psbt_output_miniscript_timelock)
+{
+ for (bool has_input : {false, true}) {
+ PSBTOutputTest test{"wsh(and_v(v:pk(<KEY>),older(144)))"};
+ auto out{test.UpdateOutput(has_input)};
+ BOOST_CHECK(GetScriptForDestination(WitnessV0ScriptHash{out.witness_script}) == test.script_pubkey);
+ BOOST_CHECK(out.hd_keypaths.contains(test.pubkey));
+ }
+}
+
+BOOST_AUTO_TEST_CASE(update_psbt_output_taproot)
+{
+ for (bool has_input : {false, true}) {
+ PSBTOutputTest test{"rawtr(<KEY>)"};
+ auto out{test.UpdateOutput(has_input)};
+ BOOST_CHECK(out.m_tap_bip32_paths.contains(XOnlyPubKey{test.pubkey}));
+ BOOST_CHECK(out.hd_keypaths.empty());
+ }
+}
+
BOOST_AUTO_TEST_SUITE_END()
### test/functional/rpc_psbt.py
@@ -1424,6 +1424,14 @@ def test_psbt_input_keys(psbt_input, keys):
utxo = self.create_outpoints(self.nodes[0], outputs=[{address: 1}])[0]
self.sync_all()
+ self.log.info("Test descriptorprocesspsbt updates PSBT outputs")
+ for has_input in [False, True]:
+ output_psbt = self.nodes[2].createpsbt([utxo] if has_input else [], {address: 1})
+ processed = self.nodes[2].descriptorprocesspsbt(psbt=output_psbt, descriptors=[descriptor], finalize=False)
+ decoded = self.nodes[2].decodepsbt(processed["psbt"])
+ assert_equal(len(decoded["inputs"]), int(has_input))
+ assert_equal(len(decoded["outputs"][0]["bip32_derivs"]), 1)
+
psbt = self.nodes[2].createpsbt([utxo], {self.nodes[0].getnewaddress(): 0.99999})
decoded = self.nodes[2].decodepsbt(psbt)
test_psbt_input_keys(decoded['inputs'][0], [])Why this scored 60/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.