Add psbt_version to PSBT RPCs and default to v2
What changed, and why it matters
This commit updates Bitcoin Core's Partially Signed Bitcoin Transaction (PSBT) RPC commands so they can produce the newer PSBT version 2 format, and makes version 2 the default. It is a feature and compatibility change, not a fix for an active security vulnerability. The code validates that only versions 0 or 2 can be requested, and updates tests to match the new output structure. There is no indication in the commit that this resolves a disclosed security issue.
Treat as a routine feature/compatibility update. Review downstream tooling that parses PSBT RPC output, since fields like `tx.vin`/`vout` are replaced by `inputs`/`outputs` in decoded PSBT v2 results. No urgent security action is indicated by the commit itself.
Security signals we found
Input validation: RPCs reject PSBT versions other than 0 and 2
Default behavior change: newly created PSBTs now use version 2
No memory-safety or cryptographic changes visible in the diff
No disclosed vulnerability or CVE referenced in commit message or diff
Evidence from the diff
The change adds a psbt_version parameter to createpsbt, converttopsbt, walletcreatefundedpsbt, and psbtbumpfee, defaulting to 2 and restricting values to 0 or 2. It raises PSBT_HIGHEST_VERSION from 0 to 2 and updates many functional tests to read PSBT v2 fields (inputs, outputs, previous_txid, etc.) instead of the embedded unsigned transaction. The patch is defensive (input validation, no unsafe deserialization) and appears to be a standards-compliance/feature update rather than a security patch.
Changed components
src/psbt.hsrc/rpc/client.cppsrc/rpc/rawtransaction.cppsrc/wallet/rpc/spend.cppPSBT RPC commands: createpsbt, converttopsbt, walletcreatefundedpsbt, psbtbumpfeeInspect captured patch +172 / −89
diff --git a/src/psbt.h b/src/psbt.h
index c5bbff89..9e514f2e 100644
--- a/src/psbt.h
+++ b/src/psbt.h
@@ -92,7 +92,7 @@ static constexpr uint8_t PSBT_SEPARATOR = 0x00;
const std::streamsize MAX_FILE_SIZE_PSBT = 100000000; // 100 MB
// PSBT version number
-static constexpr uint32_t PSBT_HIGHEST_VERSION = 0;
+static constexpr uint32_t PSBT_HIGHEST_VERSION = 2;
/** A structure for PSBT proprietary types */
struct PSBTProprietary
diff --git a/src/rpc/client.cpp b/src/rpc/client.cpp
index 8d3e551d..a28543fb 100644
--- a/src/rpc/client.cpp
+++ b/src/rpc/client.cpp
@@ -208,6 +208,7 @@ static const CRPCConvertParam vRPCConvertParams[] =
{ "walletcreatefundedpsbt", 3, "max_tx_weight"},
{ "walletcreatefundedpsbt", 4, "bip32derivs" },
{ "walletcreatefundedpsbt", 5, "version" },
+ { "walletcreatefundedpsbt", 6, "psbt_version" },
{ "walletprocesspsbt", 0, "psbt", ParamFormat::STRING },
{ "walletprocesspsbt", 1, "sign" },
{ "walletprocesspsbt", 2, "sighashtype", ParamFormat::STRING },
@@ -223,12 +224,14 @@ static const CRPCConvertParam vRPCConvertParams[] =
{ "createpsbt", 2, "locktime" },
{ "createpsbt", 3, "replaceable" },
{ "createpsbt", 4, "version" },
+ { "createpsbt", 5, "psbt_version" },
{ "combinepsbt", 0, "txs"},
{ "joinpsbts", 0, "txs"},
{ "finalizepsbt", 0, "psbt", ParamFormat::STRING },
{ "finalizepsbt", 1, "extract"},
{ "converttopsbt", 1, "permitsigdata"},
{ "converttopsbt", 2, "iswitness"},
+ { "converttopsbt", 3, "psbt_version"},
{ "gettxout", 1, "n" },
{ "gettxout", 2, "include_mempool" },
{ "gettxoutproof", 0, "txids" },
@@ -328,6 +331,7 @@ static const CRPCConvertParam vRPCConvertParams[] =
{ "psbtbumpfee", 1, "replaceable"},
{ "psbtbumpfee", 1, "outputs"},
{ "psbtbumpfee", 1, "original_change_index"},
+ { "psbtbumpfee", 1, "psbt_version"},
{ "logging", 0, "include" },
{ "logging", 1, "exclude" },
{ "disconnectnode", 1, "nodeid" },
diff --git a/src/rpc/rawtransaction.cpp b/src/rpc/rawtransaction.cpp
index 539c24cd..396768ed 100644
--- a/src/rpc/rawtransaction.cpp
+++ b/src/rpc/rawtransaction.cpp
@@ -1685,7 +1685,12 @@ static RPCMethod createpsbt()
"Implements the Creator role.\n"
"Note that the transaction's inputs are not signed, and\n"
"it is not stored in the wallet or transmitted to the network.\n",
- CreateTxDoc(),
+ Cat<std::vector<RPCArg>>(
+ CreateTxDoc(),
+ {
+ {"psbt_version", RPCArg::Type::NUM, RPCArg::Default{2}, "The PSBT version number to use."},
+ }
+ ),
RPCResult{
RPCResult::Type::STR, "", "The resulting raw transaction (base64-encoded string)"
},
@@ -1694,7 +1699,6 @@ static RPCMethod createpsbt()
},
[](const RPCMethod& self, const JSONRPCRequest& request) -> UniValue
{
-
std::optional<bool> rbf;
if (!request.params[3].isNull()) {
rbf = request.params[3].get_bool();
@@ -1702,7 +1706,14 @@ static RPCMethod createpsbt()
CMutableTransaction rawTx = ConstructTransaction(request.params[0], request.params[1], request.params[2], rbf, self.Arg<uint32_t>("version"));
// Make a blank psbt
- PartiallySignedTransaction psbtx(rawTx);
+ uint32_t psbt_version = 2;
+ if (!request.params[5].isNull()) {
+ psbt_version = request.params[5].getInt<uint32_t>();
+ }
+ if (psbt_version != 2 && psbt_version != 0) {
+ throw JSONRPCError(RPC_INVALID_PARAMETER, "The PSBT version can only be 2 or 0");
+ }
+ PartiallySignedTransaction psbtx(rawTx, psbt_version);
// Serialize the PSBT
DataStream ssTx{};
@@ -1730,6 +1741,7 @@ static RPCMethod converttopsbt()
"This boolean should reflect whether the transaction has inputs\n"
"(e.g. fully valid, or on-chain transactions), if known by the caller."
},
+ {"psbt_version", RPCArg::Type::NUM, RPCArg::Default{2}, "The PSBT version number to use."},
},
RPCResult{
RPCResult::Type::STR, "", "The resulting raw transaction (base64-encoded string)"
@@ -1763,7 +1775,14 @@ static RPCMethod converttopsbt()
}
// Make a blank psbt
- PartiallySignedTransaction psbtx(tx);
+ uint32_t psbt_version = 2;
+ if (!request.params[3].isNull()) {
+ psbt_version = request.params[3].getInt<uint32_t>();
+ }
+ if (psbt_version != 2 && psbt_version != 0) {
+ throw JSONRPCError(RPC_INVALID_PARAMETER, "The PSBT version can only be 2 or 0");
+ }
+ PartiallySignedTransaction psbtx(tx, psbt_version);
// Serialize the PSBT
DataStream ssTx{};
diff --git a/src/wallet/rpc/spend.cpp b/src/wallet/rpc/spend.cpp
index ef59c1f8..b6cdc860 100644
--- a/src/wallet/rpc/spend.cpp
+++ b/src/wallet/rpc/spend.cpp
@@ -109,7 +109,7 @@ static UniValue FinishTransaction(const std::shared_ptr<CWallet> pwallet, const
}
// Make a blank psbt
- PartiallySignedTransaction psbtx(rawTx);
+ PartiallySignedTransaction psbtx(rawTx, /*version=*/2);
// First fill transaction with our data without signing,
// so external signers are not asked to sign more than once.
@@ -979,6 +979,7 @@ static RPCMethod bumpfee_helper(std::string method_name)
{
{"txid", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "The txid to be bumped"},
{"options", RPCArg::Type::OBJ_NAMED_PARAMS, RPCArg::Optional::OMITTED, "",
+ Cat(
{
{"conf_target", RPCArg::Type::NUM, RPCArg::DefaultHint{"wallet -txconfirmtarget"}, "Confirmation target in blocks\n"},
{"fee_rate", RPCArg::Type::AMOUNT, RPCArg::DefaultHint{"not set, fall back to wallet fee estimation"},
@@ -1007,6 +1008,8 @@ static RPCMethod bumpfee_helper(std::string method_name)
"original change output. The change output’s amount can increase if bumping the transaction "
"adds new inputs, otherwise it will decrease. Cannot be used in combination with the 'outputs' option."},
},
+ want_psbt ? std::vector<RPCArg>{{"psbt_version", RPCArg::Type::NUM, RPCArg::Default(2), "The PSBT version number to use."}} : std::vector<RPCArg>()
+ ),
RPCArgOptions{.oneline_description="options"}},
},
RPCResult{
@@ -1045,6 +1048,8 @@ static RPCMethod bumpfee_helper(std::string method_name)
std::optional<uint32_t> original_change_index;
+ uint32_t psbt_version = 2;
+
if (!request.params[1].isNull()) {
UniValue options = request.params[1];
RPCTypeCheckObj(options,
@@ -1056,6 +1061,7 @@ static RPCMethod bumpfee_helper(std::string method_name)
{"estimate_mode", UniValueType(UniValue::VSTR)},
{"outputs", UniValueType()}, // will be checked by AddOutputs()
{"original_change_index", UniValueType(UniValue::VNUM)},
+ {"psbt_version", UniValueType(UniValue::VNUM)},
},
true, true);
@@ -1083,6 +1089,13 @@ static RPCMethod bumpfee_helper(std::string method_name)
if (options.exists("original_change_index")) {
original_change_index = options["original_change_index"].getInt<uint32_t>();
}
+
+ if (options.exists("psbt_version")) {
+ psbt_version = options["psbt_version"].getInt<uint32_t>();
+ }
+ if (psbt_version != 2 && psbt_version != 0) {
+ throw JSONRPCError(RPC_INVALID_PARAMETER, "The PSBT version can only be 2 or 0");
+ }
}
// Make sure the results are valid at least up to the most recent block
@@ -1136,7 +1149,7 @@ static RPCMethod bumpfee_helper(std::string method_name)
result.pushKV("txid", txid.GetHex());
} else {
- PartiallySignedTransaction psbtx(mtx);
+ PartiallySignedTransaction psbtx(mtx, psbt_version);
bool complete = false;
const auto err{pwallet->FillPSBT(psbtx, {.sign = false, .bip32_derivs = true}, complete)};
CHECK_NONFATAL(!err);
@@ -1723,6 +1736,7 @@ RPCMethod walletcreatefundedpsbt()
RPCArgOptions{.oneline_description="options"}},
{"bip32derivs", RPCArg::Type::BOOL, RPCArg::Default{true}, "Include BIP 32 derivation paths for public keys if we know them"},
{"version", RPCArg::Type::NUM, RPCArg::Default{DEFAULT_WALLET_TX_VERSION}, "Transaction version"},
+ {"psbt_version", RPCArg::Type::NUM, RPCArg::Default(2), "The PSBT version number to use."},
},
RPCResult{
RPCResult::Type::OBJ, "", "",
@@ -1772,7 +1786,15 @@ RPCMethod walletcreatefundedpsbt()
auto txr = FundTransaction(wallet, rawTx, recipients, options, coin_control, /*override_min_fee=*/true);
// Make a blank psbt
- PartiallySignedTransaction psbtx(CMutableTransaction(*txr.tx));
+ uint32_t psbt_version = 2;
+ if (!request.params[6].isNull()) {
+ psbt_version = request.params[6].getInt<int>();
+ }
+ if (psbt_version != 2 && psbt_version != 0) {
+ throw JSONRPCError(RPC_INVALID_PARAMETER, "The PSBT version can only be 2 or 0");
+ }
+
+ PartiallySignedTransaction psbtx(CMutableTransaction(*txr.tx), psbt_version);
// Fill transaction with out data but don't sign
bool bip32derivs = request.params[4].isNull() ? true : request.params[4].get_bool();
diff --git a/test/functional/data/rpc_psbt.json b/test/functional/data/rpc_psbt.json
index fd46312a..8c049b8c 100644
--- a/test/functional/data/rpc_psbt.json
+++ b/test/functional/data/rpc_psbt.json
@@ -180,6 +180,7 @@
"bcrt1qqzh2ngh97ru8dfvgma25d6r595wcwqy0cee4cc": 1
}
],
+ "version": 0,
"result" : "cHNidP8BAJoCAAAAAljoeiG1ba8MI76OcHBFbDNvfLqlyHV5JPVFiHuyq911AAAAAAD/////g40EJ9DsZQpoqka7CwmK6kQiwHGyyng1Kgd5WdB86h0BAAAAAP////8CcKrwCAAAAAAWABTYXCtx0AYLCcmIauuBXlCZHdoSTQDh9QUAAAAAFgAUAK6pouXw+HaliN9VRuh0LR2HAI8AAAAAAAAAAAA="
}
],
diff --git a/test/functional/rpc_psbt.py b/test/functional/rpc_psbt.py
index cd47f61a..3c3e7288 100755
--- a/test/functional/rpc_psbt.py
+++ b/test/functional/rpc_psbt.py
@@ -37,6 +37,7 @@ from test_framework.psbt import (
PSBT_IN_WITNESS_UTXO,
PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS,
PSBT_OUT_TAP_TREE,
+ PSBT_OUT_SCRIPT,
)
from test_framework.script import CScript, OP_TRUE, SIGHASH_ALL, SIGHASH_ANYONECANPAY
from test_framework.script_util import MIN_STANDARD_TX_NONWITNESS_SIZE
@@ -110,9 +111,7 @@ class PSBTTest(BitcoinTestFramework):
# Modify the raw transaction by changing the output address, so the signature is no longer valid
signed_psbt_obj = PSBT.from_base64(signed_psbt)
- substitute_addr = wallet.getnewaddress()
- raw = wallet.createrawtransaction([{"txid": utxos[0]["txid"], "vout": utxos[0]["vout"]}], [{substitute_addr: 0.9999}])
- signed_psbt_obj.g.map[PSBT_GLOBAL_UNSIGNED_TX] = bytes.fromhex(raw)
+ signed_psbt_obj.o[0].map[PSBT_OUT_SCRIPT] = CScript([OP_TRUE])
# Check that the walletprocesspsbt call succeeds but also recognizes that the transaction is not complete
signed_psbt_incomplete = wallet.walletprocesspsbt(psbt=signed_psbt_obj.to_base64(), finalize=False)
@@ -185,11 +184,11 @@ class PSBTTest(BitcoinTestFramework):
psbtx1 = wallet.walletcreatefundedpsbt([], {target_address: 0.1}, 0, {'fee_rate': 1, 'maxconf': 0})['psbt']
# Make sure we only had the one input
- tx1_inputs = self.nodes[0].decodepsbt(psbtx1)['tx']['vin']
+ tx1_inputs = self.nodes[0].decodepsbt(psbtx1)['inputs']
assert_equal(len(tx1_inputs), 1)
utxo1 = tx1_inputs[0]
- assert_equal(unconfirmed_txid, utxo1['txid'])
+ assert_equal(unconfirmed_txid, utxo1['previous_txid'])
signed_tx1 = wallet.walletprocesspsbt(psbtx1)
txid1 = self.nodes[0].sendrawtransaction(signed_tx1['hex'])
@@ -198,23 +197,23 @@ class PSBTTest(BitcoinTestFramework):
assert txid1 in mempool
self.log.info("Fail to craft a new PSBT that sends more funds with add_inputs = False")
- assert_raises_rpc_error(-4, "The preselected coins total amount does not cover the transaction target. Please allow other inputs to be automatically selected or include more coins manually", wallet.walletcreatefundedpsbt, [{'txid': utxo1['txid'], 'vout': utxo1['vout']}], {target_address: 1}, 0, {'add_inputs': False})
+ assert_raises_rpc_error(-4, "The preselected coins total amount does not cover the transaction target. Please allow other inputs to be automatically selected or include more coins manually", wallet.walletcreatefundedpsbt, [{'txid': utxo1['previous_txid'], 'vout': utxo1['previous_vout']}], {target_address: 1}, 0, {'add_inputs': False})
self.log.info("Fail to craft a new PSBT with minconf above highest one")
- assert_raises_rpc_error(-4, "Insufficient funds", wallet.walletcreatefundedpsbt, [{'txid': utxo1['txid'], 'vout': utxo1['vout']}], {target_address: 1}, 0, {'add_inputs': True, 'minconf': 3, 'fee_rate': 10})
+ assert_raises_rpc_error(-4, "Insufficient funds", wallet.walletcreatefundedpsbt, [{'txid': utxo1['previous_txid'], 'vout': utxo1['previous_vout']}], {target_address: 1}, 0, {'add_inputs': True, 'minconf': 3, 'fee_rate': 10})
self.log.info("Fail to broadcast a new PSBT with maxconf 0 due to BIP125 rules to verify it actually chose unconfirmed outputs")
- psbt_invalid = wallet.walletcreatefundedpsbt([{'txid': utxo1['txid'], 'vout': utxo1['vout']}], {target_address: 1}, 0, {'add_inputs': True, 'maxconf': 0, 'fee_rate': 10})['psbt']
+ psbt_invalid = wallet.walletcreatefundedpsbt([{'txid': utxo1['previous_txid'], 'vout': utxo1['previous_vout']}], {target_address: 1}, 0, {'add_inputs': True, 'maxconf': 0, 'fee_rate': 10})['psbt']
signed_invalid = wallet.walletprocesspsbt(psbt_invalid)
assert_raises_rpc_error(-26, "bad-txns-spends-conflicting-tx", self.nodes[0].sendrawtransaction, signed_invalid['hex'])
self.log.info("Craft a replacement adding inputs with highest confs possible")
- psbtx2 = wallet.walletcreatefundedpsbt([{'txid': utxo1['txid'], 'vout': utxo1['vout']}], {target_address: 1}, 0, {'add_inputs': True, 'minconf': 2, 'fee_rate': 10})['psbt']
- tx2_inputs = self.nodes[0].decodepsbt(psbtx2)['tx']['vin']
+ psbtx2 = wallet.walletcreatefundedpsbt([{'txid': utxo1['previous_txid'], 'vout': utxo1['previous_vout']}], {target_address: 1}, 0, {'add_inputs': True, 'minconf': 2, 'fee_rate': 10})['psbt']
+ tx2_inputs = self.nodes[0].decodepsbt(psbtx2)['inputs']
assert_greater_than_or_equal(len(tx2_inputs), 2)
for vin in tx2_inputs:
- if vin['txid'] != unconfirmed_txid:
- assert_greater_than_or_equal(self.nodes[0].gettxout(vin['txid'], vin['vout'])['confirmations'], 2)
+ if vin['previous_txid'] != unconfirmed_txid:
+ assert_greater_than_or_equal(self.nodes[0].gettxout(vin['previous_txid'], vin['previous_vout'])['confirmations'], 2)
signed_tx2 = wallet.walletprocesspsbt(psbtx2)
txid2 = self.nodes[0].sendrawtransaction(signed_tx2['hex'])
@@ -387,7 +386,7 @@ class PSBTTest(BitcoinTestFramework):
# The decodepsbt RPC is stateless and independent of any settings, we can always just call it on the first node
decoded_psbt = self.nodes[0].decodepsbt(psbtx["psbt"])
changepos = psbtx["changepos"]
- assert_equal(decoded_psbt["tx"]["vout"][changepos]["scriptPubKey"]["type"], expected_type)
+ assert_equal(decoded_psbt["outputs"][changepos]["script"]["type"], expected_type)
def test_psbt_named_parameter_handling(self):
"""Test that PSBT Base64 parameters with '=' padding are handled correctly in -named mode"""
@@ -429,17 +428,45 @@ class PSBTTest(BitcoinTestFramework):
def test_psbt_roundtrip(self):
self.log.info("Test that PSBTs roundtrip when RPC does nothing")
utxo = self.nodes[0].listunspent()[0]
- psbt = self.nodes[0].walletcreatefundedpsbt(inputs=[utxo], outputs=[{self.nodes[0].getnewaddress(): utxo["amount"] / 2}])["psbt"]
-
- rt_psbts = [
- self.nodes[0].combinepsbt([psbt, psbt]),
- self.nodes[0].finalizepsbt(psbt)["psbt"],
- self.nodes[0].utxoupdatepsbt(psbt),
- self.nodes[0].descriptorprocesspsbt(psbt, [])["psbt"],
- self.nodes[0].walletprocesspsbt(psbt, sign=False)["psbt"],
- ]
- for p in rt_psbts:
- assert_equal(psbt, p)
+ for ver in [0, 2]:
+ psbt = self.nodes[0].walletcreatefundedpsbt(inputs=[utxo], outputs=[{self.nodes[0].getnewaddress(): utxo["amount"] / 2}], psbt_version=ver)["psbt"]
+
+ rt_psbts = [
+ self.nodes[0].combinepsbt([psbt, psbt]),
+ self.nodes[0].finalizepsbt(psbt)["psbt"],
+ self.nodes[0].utxoupdatepsbt(psbt),
+ self.nodes[0].descriptorprocesspsbt(psbt, [])["psbt"],
+ self.nodes[0].walletprocesspsbt(psbt, sign=False)["psbt"],
+ ]
+ for p in rt_psbts:
+ assert_equal(psbt, p)
+
+ def test_psbt_version(self):
+ tobump = self.nodes[0].sendtoaddress(self.nodes[0].getnewaddress(), 1)
+ utxo = self.nodes[0].listunspent()[0]
+ outputs = [{self.nodes[0].getnewaddress(): utxo["amount"] / 2}]
+ rawtx = self.nodes[0].createrawtransaction(inputs=[utxo], outputs=outputs)
+ for ver in [0, 2]:
+ psbt = self.nodes[0].createpsbt(inputs=[utxo], outputs=outputs, psbt_version=ver)
+ dec = self.nodes[0].decodepsbt(psbt)
+ assert_equal(ver, dec["psbt_version"])
+
+ psbt = self.nodes[0].walletcreatefundedpsbt(inputs=[utxo], outputs=outputs, psbt_version=ver)
+ dec = self.nodes[0].decodepsbt(psbt["psbt"])
+ assert_equal(ver, dec["psbt_version"])
+
+ psbt = self.nodes[0].converttopsbt(hexstring=rawtx, psbt_version=ver)
+ dec = self.nodes[0].decodepsbt(psbt)
+ assert_equal(ver, dec["psbt_version"])
+
+ psbt = self.nodes[0].psbtbumpfee(txid=tobump, psbt_version=ver)
+ dec = self.nodes[0].decodepsbt(psbt["psbt"])
+ assert_equal(ver, dec["psbt_version"])
+
+ assert_raises_rpc_error(-8, "The PSBT version can only be 2 or 0", self.nodes[0].createpsbt, inputs=[utxo], outputs=outputs, psbt_version=1)
+ assert_raises_rpc_error(-8, "The PSBT version can only be 2 or 0", self.nodes[0].walletcreatefundedpsbt, inputs=[utxo], outputs=outputs, psbt_version=1)
+ assert_raises_rpc_error(-8, "The PSBT version can only be 2 or 0", self.nodes[0].converttopsbt, hexstring=rawtx, psbt_version=1)
+ assert_raises_rpc_error(-8, "The PSBT version can only be 2 or 0", self.nodes[0].psbtbumpfee, txid=tobump, psbt_version=1)
def run_test(self):
# Create and fund a raw tx for sending 10 BTC
@@ -481,7 +508,9 @@ class PSBTTest(BitcoinTestFramework):
max_tx_weight_sufficient = 1000 # 1k vbytes is enough
psbt = self.nodes[0].walletcreatefundedpsbt(outputs=dest_arg,locktime=0, options={"max_tx_weight": max_tx_weight_sufficient})["psbt"]
- weight = self.nodes[0].decodepsbt(psbt)["tx"]["weight"]
+ psbt = self.nodes[0].walletprocesspsbt(psbt)["psbt"]
+ final_tx = self.nodes[0].finalizepsbt(psbt)["hex"]
+ weight = self.nodes[0].decoderawtransaction(final_tx)["weight"]
# ensure the transaction's weight is below the specified max_tx_weight.
assert_greater_than_or_equal(max_tx_weight_sufficient, weight)
@@ -492,7 +521,7 @@ class PSBTTest(BitcoinTestFramework):
self.nodes[0].walletcreatefundedpsbt, [{"txid": utxo1['txid'], "vout": utxo1['vout']}], {self.nodes[2].getnewaddress():90})
psbtx1 = self.nodes[0].walletcreatefundedpsbt([{"txid": utxo1['txid'], "vout": utxo1['vout']}], {self.nodes[2].getnewaddress():90}, 0, {"add_inputs": True})['psbt']
- assert_equal(len(self.nodes[0].decodepsbt(psbtx1)['tx']['vin']), 2)
+ assert_equal(len(self.nodes[0].decodepsbt(psbtx1)['inputs']), 2)
# Inputs argument can be null
self.nodes[0].walletcreatefundedpsbt(None, {self.nodes[2].getnewaddress():10})
@@ -735,12 +764,14 @@ class PSBTTest(BitcoinTestFramework):
# Update psbts, should only have data for one input and not the other
psbt1 = self.nodes[1].walletprocesspsbt(psbt_orig, False, "ALL")['psbt']
psbt1_decoded = self.nodes[0].decodepsbt(psbt1)
- assert psbt1_decoded['inputs'][0] and not psbt1_decoded['inputs'][1]
+ assert len(psbt1_decoded['inputs'][0].keys()) > 3
+ assert len(psbt1_decoded['inputs'][1].keys()) == 3
# Check that BIP32 path was added
assert "bip32_derivs" in psbt1_decoded['inputs'][0]
psbt2 = self.nodes[2].walletprocesspsbt(psbt_orig, False, "ALL", False)['psbt']
psbt2_decoded = self.nodes[0].decodepsbt(psbt2)
- assert not psbt2_decoded['inputs'][0] and psbt2_decoded['inputs'][1]
+ assert len(psbt2_decoded['inputs'][0].keys()) == 3
+ assert len(psbt2_decoded['inputs'][1].keys()) > 3
# Check that BIP32 paths were not added
assert "bip32_derivs" not in psbt2_decoded['inputs'][1]
@@ -762,33 +793,33 @@ class PSBTTest(BitcoinTestFramework):
unspent = self.nodes[0].listunspent()[0]
psbtx_info = self.nodes[0].walletcreatefundedpsbt([{"txid":unspent["txid"], "vout":unspent["vout"]}], [{self.nodes[2].getnewaddress():unspent["amount"]+1}], block_height+2, {"replaceable": False, "add_inputs": True}, False)
decoded_psbt = self.nodes[0].decodepsbt(psbtx_info["psbt"])
- for tx_in, psbt_in in zip(decoded_psbt["tx"]["vin"], decoded_psbt["inputs"]):
- assert_greater_than(tx_in["sequence"], MAX_BIP125_RBF_SEQUENCE)
+ for psbt_in in decoded_psbt["inputs"]:
+ assert_greater_than(psbt_in["sequence"], MAX_BIP125_RBF_SEQUENCE)
assert "bip32_derivs" not in psbt_in
- assert_equal(decoded_psbt["tx"]["locktime"], block_height+2)
+ assert_equal(decoded_psbt["fallback_locktime"], block_height+2)
# Same construction with only locktime set and RBF explicitly enabled
psbtx_info = self.nodes[0].walletcreatefundedpsbt([{"txid":unspent["txid"], "vout":unspent["vout"]}], [{self.nodes[2].getnewaddress():unspent["amount"]+1}], block_height, {"replaceable": True, "add_inputs": True}, True)
decoded_psbt = self.nodes[0].decodepsbt(psbtx_info["psbt"])
- for tx_in, psbt_in in zip(decoded_psbt["tx"]["vin"], decoded_psbt["inputs"]):
- assert_equal(tx_in["sequence"], MAX_BIP125_RBF_SEQUENCE)
+ for psbt_in in decoded_psbt["inputs"]:
+ assert_equal(psbt_in["sequence"], MAX_BIP125_RBF_SEQUENCE)
assert "bip32_derivs" in psbt_in
- assert_equal(decoded_psbt["tx"]["locktime"], block_height)
+ assert_equal(decoded_psbt["fallback_locktime"], block_height)
# Same construction without optional arguments
psbtx_info = self.nodes[0].walletcreatefundedpsbt([], [{self.nodes[2].getnewaddress():unspent["amount"]+1}])
decoded_psbt = self.nodes[0].decodepsbt(psbtx_info["psbt"])
- for tx_in, psbt_in in zip(decoded_psbt["tx"]["vin"], decoded_psbt["inputs"]):
- assert_equal(tx_in["sequence"], MAX_BIP125_RBF_SEQUENCE)
+ for psbt_in in decoded_psbt["inputs"]:
+ assert_equal(psbt_in["sequence"], MAX_BIP125_RBF_SEQUENCE)
assert "bip32_derivs" in psbt_in
- assert_equal(decoded_psbt["tx"]["locktime"], 0)
+ assert_equal(decoded_psbt["fallback_locktime"], 0)
# Same construction without optional arguments, for a node with -walletrbf=0
unspent1 = self.nodes[1].listunspent()[0]
psbtx_info = self.nodes[1].walletcreatefundedpsbt([{"txid":unspent1["txid"], "vout":unspent1["vout"]}], [{self.nodes[2].getnewaddress():unspent1["amount"]+1}], block_height, {"add_inputs": True})
decoded_psbt = self.nodes[1].decodepsbt(psbtx_info["psbt"])
- for tx_in, psbt_in in zip(decoded_psbt["tx"]["vin"], decoded_psbt["inputs"]):
- assert_greater_than(tx_in["sequence"], MAX_BIP125_RBF_SEQUENCE)
+ for psbt_in in decoded_psbt["inputs"]:
+ assert_greater_than(psbt_in["sequence"], MAX_BIP125_RBF_SEQUENCE)
assert "bip32_derivs" in psbt_in
# Make sure change address wallet does not have P2SH innerscript access to results in success
@@ -869,7 +900,7 @@ class PSBTTest(BitcoinTestFramework):
# Creator Tests
for creator in creators:
- created_tx = self.nodes[0].createpsbt(inputs=creator['inputs'], outputs=creator['outputs'], replaceable=False)
+ created_tx = self.nodes[0].createpsbt(inputs=creator['inputs'], outputs=creator['outputs'], replaceable=False, psbt_version=creator['version'])
assert_equal(created_tx, creator['result'])
# Signer tests
@@ -923,46 +954,55 @@ class PSBTTest(BitcoinTestFramework):
utxo1, utxo2, utxo3 = self.create_outpoints(self.nodes[1], outputs=[{addr1: 11}, {addr2: 11}, {addr3: 11}])
self.sync_all()
+ psbt_v2_required_keys = ["previous_txid", "previous_vout", "sequence"]
+
def test_psbt_input_keys(psbt_input, keys):
"""Check that the psbt input has only the expected keys."""
+ keys.extend(["previous_txid", "previous_vout", "sequence"])
assert_equal(set(keys), set(psbt_input.keys()))
# Create a PSBT. None of the inputs are filled initially
psbt = self.nodes[1].createpsbt([utxo1, utxo2, utxo3], {self.nodes[0].getnewaddress():32.999})
decoded = self.nodes[1].decodepsbt(psbt)
- test_psbt_input_keys(decoded['inputs'][0], [])
- test_psbt_input_keys(decoded['inputs'][1], [])
- test_psbt_input_keys(decoded['inputs'][2], [])
+ test_psbt_input_keys(decoded['inputs'][0], psbt_v2_required_keys)
+ test_psbt_input_keys(decoded['inputs'][1], psbt_v2_required_keys)
+ test_psbt_input_keys(decoded['inputs'][2], psbt_v2_required_keys)
# Update a PSBT with UTXOs from the node
# Bech32 inputs should be filled with witness UTXO. Other inputs should not be filled because they are non-witness
updated = self.nodes[1].utxoupdatepsbt(psbt)
decoded = self.nodes[1].decodepsbt(updated)
- test_psbt_input_keys(decoded['inputs'][0], ['witness_utxo', 'non_witness_utxo'])
- test_psbt_input_keys(decoded['inputs'][1], ['non_witness_utxo'])
- test_psbt_input_keys(decoded['inputs'][2], ['non_witness_utxo'])
+ test_psbt_input_keys(decoded['inputs'][0], psbt_v2_required_keys + ['witness_utxo', 'non_witness_utxo'])
+ test_psbt_input_keys(decoded['inputs'][1], psbt_v2_required_keys + ['non_witness_utxo'])
+ test_psbt_input_keys(decoded['inputs'][2], psbt_v2_required_keys + ['non_witness_utxo'])
# Try again, now while providing descriptors, making P2SH-segwit work, and causing bip32_derivs and redeem_script to be filled in
descs = [self.nodes[1].getaddressinfo(addr)['desc'] for addr in [addr1,addr2,addr3]]
updated = self.nodes[1].utxoupdatepsbt(psbt=psbt, descriptors=descs)
decoded = self.nodes[1].decodepsbt(updated)
- test_psbt_input_keys(decoded['inputs'][0], ['witness_utxo', 'non_witness_utxo', 'bip32_derivs'])
- test_psbt_input_keys(decoded['inputs'][1], ['non_witness_utxo', 'bip32_derivs'])
- test_psbt_input_keys(decoded['inputs'][2], ['non_witness_utxo','witness_utxo', 'bip32_derivs', 'redeem_script'])
+ test_psbt_input_keys(decoded['inputs'][0], psbt_v2_required_keys + ['witness_utxo', 'non_witness_utxo', 'bip32_derivs'])
+ test_psbt_input_keys(decoded['inputs'][1], psbt_v2_required_keys + ['non_witness_utxo', 'bip32_derivs'])
+ test_psbt_input_keys(decoded['inputs'][2], psbt_v2_required_keys + ['non_witness_utxo', 'witness_utxo', 'bip32_derivs', 'redeem_script'])
+
+ # Cannot join PSBTv2s
+ psbt1 = self.nodes[1].createpsbt(inputs=[utxo1], outputs={self.nodes[0].getnewaddress():Decimal('10.999')}, psbt_version=0)
+ psbt2 = self.nodes[1].createpsbt(inputs=[utxo1], outputs={self.nodes[0].getnewaddress():Decimal('10.999')}, psbt_version=2)
+ assert_raises_rpc_error(-8, "joinpsbts only operates on version 0 PSBTs", self.nodes[1].joinpsbts, [psbt1, psbt2])
# Two PSBTs with a common input should not be joinable
- psbt1 = self.nodes[1].createpsbt([utxo1], {self.nodes[0].getnewaddress():Decimal('10.999')})
- assert_raises_rpc_error(-8, "exists in multiple PSBTs", self.nodes[1].joinpsbts, [psbt1, updated])
+ psbt2 = self.nodes[1].createpsbt([utxo1], {self.nodes[0].getnewaddress():Decimal('10.999')}, psbt_version=0)
+ assert_raises_rpc_error(-8, "exists in multiple PSBTs", self.nodes[1].joinpsbts, [psbt1, psbt2])
# Join two distinct PSBTs
+ psbt1 = self.nodes[1].createpsbt(inputs=[utxo1, utxo2, utxo3], outputs={self.nodes[0].getnewaddress():32.999}, psbt_version=0)
addr4 = self.nodes[1].getnewaddress("", "p2sh-segwit")
utxo4 = self.create_outpoints(self.nodes[0], outputs=[{addr4: 5}])[0]
self.generate(self.nodes[0], 6)
- psbt2 = self.nodes[1].createpsbt([utxo4], {self.nodes[0].getnewaddress():Decimal('4.999')})
+ psbt2 = self.nodes[1].createpsbt([utxo4], {self.nodes[0].getnewaddress():Decimal('4.999')}, psbt_version=0)
psbt2 = self.nodes[1].walletprocesspsbt(psbt2)['psbt']
psbt2_decoded = self.nodes[0].decodepsbt(psbt2)
assert "final_scriptwitness" in psbt2_decoded['inputs'][0] and "final_scriptSig" in psbt2_decoded['inputs'][0]
- joined = self.nodes[0].joinpsbts([psbt, psbt2])
+ joined = self.nodes[0].joinpsbts([psbt1, psbt2])
joined_decoded = self.nodes[0].decodepsbt(joined)
assert_equal(len(joined_decoded['inputs']), 4)
assert_equal(len(joined_decoded['outputs']), 2)
@@ -973,7 +1013,7 @@ class PSBTTest(BitcoinTestFramework):
# 10 attempts should be enough to get a shuffled join
shuffled = False
for _ in range(10):
- shuffled_joined = self.nodes[0].joinpsbts([psbt, psbt2])
+ shuffled_joined = self.nodes[0].joinpsbts([psbt1, psbt2])
shuffled |= joined != shuffled_joined
if shuffled:
break
@@ -1071,11 +1111,9 @@ class PSBTTest(BitcoinTestFramework):
final = signed['hex']
dec = self.nodes[0].decodepsbt(signed["psbt"])
- for i, txin in enumerate(dec["tx"]["vin"]):
- if txin["txid"] == ext_utxo["txid"] and txin["vout"] == ext_utxo["vout"]:
- input_idx = i
+ for psbt_in in dec["inputs"]:
+ if psbt_in["previous_txid"] == ext_utxo["txid"] and psbt_in["previous_vout"] == ext_utxo["vout"]:
break
- psbt_in = dec["inputs"][input_idx]
scriptsig_hex = psbt_in["final_scriptSig"]["hex"] if "final_scriptSig" in psbt_in else ""
witness_stack_hex = psbt_in["final_scriptwitness"] if "final_scriptwitness" in psbt_in else None
input_weight = calculate_input_weight(scriptsig_hex, witness_stack_hex)
@@ -1195,7 +1233,7 @@ class PSBTTest(BitcoinTestFramework):
assert_equal(comb_psbt, psbt)
self.log.info("Test walletprocesspsbt raises if an invalid sighashtype is passed")
- assert_raises_rpc_error(-8, "'all' is not a valid sighash parameter.", self.nodes[0].walletprocesspsbt, psbt, sighashtype="all")
+ assert_raises_rpc_error(-8, "'all' is not a valid sighash parameter.", self.nodes[0].walletprocesspsbt, psbt=psbt, sighashtype="all")
self.log.info("Test decoding PSBT with per-input preimage types")
# note that the decodepsbt RPC doesn't check whether preimages and hashes match
@@ -1303,13 +1341,14 @@ class PSBTTest(BitcoinTestFramework):
self.nodes[2].sendrawtransaction(processed_psbt['hex'])
self.log.info("Test descriptorprocesspsbt raises if an invalid sighashtype is passed")
- assert_raises_rpc_error(-8, "'all' is not a valid sighash parameter.", self.nodes[2].descriptorprocesspsbt, psbt, [descriptor], sighashtype="all")
+ assert_raises_rpc_error(-8, "'all' is not a valid sighash parameter.", self.nodes[2].descriptorprocesspsbt, psbt=psbt, descriptors=[descriptor], sighashtype="all")
if not self.options.usecli:
self.test_sighash_mismatch()
self.test_sighash_adding()
self.test_psbt_named_parameter_handling()
self.test_psbt_roundtrip()
+ self.test_psbt_version()
if __name__ == '__main__':
PSBTTest(__file__).main()
diff --git a/test/functional/wallet_bumpfee.py b/test/functional/wallet_bumpfee.py
index 80735e01..961f1a14 100755
--- a/test/functional/wallet_bumpfee.py
+++ b/test/functional/wallet_bumpfee.py
@@ -608,14 +608,14 @@ def test_watchonly_psbt(self, peer_node, rbf_node, dest_address):
{"fee_rate": 1, "add_inputs": False}, True)['psbt']
psbt_signed = signer.walletprocesspsbt(psbt=psbt, sign=True, sighashtype="ALL", bip32derivs=True)
original_txid = watcher.sendrawtransaction(psbt_signed["hex"])
- assert_equal(len(watcher.decodepsbt(psbt)["tx"]["vin"]), 1)
+ assert_equal(len(watcher.decodepsbt(psbt)["inputs"]), 1)
# bumpfee can't be used on watchonly wallets
assert_raises_rpc_error(-4, "bumpfee is not available with wallets that have private keys disabled. Use psbtbumpfee instead.", watcher.bumpfee, original_txid)
# Bump fee, obnoxiously high to add additional watchonly input
bumped_psbt = watcher.psbtbumpfee(original_txid, fee_rate=HIGH)
- assert_greater_than(len(watcher.decodepsbt(bumped_psbt['psbt'])["tx"]["vin"]), 1)
+ assert_greater_than(len(watcher.decodepsbt(bumped_psbt['psbt'])["inputs"]), 1)
assert "txid" not in bumped_psbt
assert_equal(bumped_psbt["origfee"], -watcher.gettransaction(original_txid)["fee"])
assert not watcher.finalizepsbt(bumped_psbt["psbt"])["complete"]
diff --git a/test/functional/wallet_fundrawtransaction.py b/test/functional/wallet_fundrawtransaction.py
index a00e7e68..02227029 100755
--- a/test/functional/wallet_fundrawtransaction.py
+++ b/test/functional/wallet_fundrawtransaction.py
@@ -1176,10 +1176,10 @@ class RawTransactionsTest(BitcoinTestFramework):
tx = wallet.send(outputs=[{addr1: 8}], **options)
assert tx["complete"]
# Check that only the preset inputs were added to the tx
- decoded_psbt_inputs = self.nodes[0].decodepsbt(tx["psbt"])['tx']['vin']
+ decoded_psbt_inputs = self.nodes[0].decodepsbt(tx["psbt"])["inputs"]
assert_equal(len(decoded_psbt_inputs), 2)
for input in decoded_psbt_inputs:
- assert_equal(input["txid"], source_tx["txid"])
+ assert_equal(input["previous_txid"], source_tx["txid"])
# Case (5), assert that inputs are added to the tx by explicitly setting add_inputs=true
options = {"add_inputs": True, "add_to_wallet": True}
@@ -1218,10 +1218,10 @@ class RawTransactionsTest(BitcoinTestFramework):
})
psbt_tx = wallet.walletcreatefundedpsbt(outputs=[{addr1: 8}], inputs=inputs, **options)
# Check that only the preset inputs were added to the tx
- decoded_psbt_inputs = self.nodes[0].decodepsbt(psbt_tx["psbt"])['tx']['vin']
+ decoded_psbt_inputs = self.nodes[0].decodepsbt(psbt_tx["psbt"])["inputs"]
assert_equal(len(decoded_psbt_inputs), 2)
for input in decoded_psbt_inputs:
- assert_equal(input["txid"], source_tx["txid"])
+ assert_equal(input["previous_txid"], source_tx["txid"])
# Case (5), 'walletcreatefundedpsbt' command
# Explicit add_inputs=true, no preset inputs
diff --git a/test/functional/wallet_multisig_descriptor_psbt.py b/test/functional/wallet_multisig_descriptor_psbt.py
index a3236a22..85051c34 100755
--- a/test/functional/wallet_multisig_descriptor_psbt.py
+++ b/test/functional/wallet_multisig_descriptor_psbt.py
@@ -36,13 +36,13 @@ class WalletMultisigDescriptorPSBTTest(BitcoinTestFramework):
@staticmethod
def _check_psbt(psbt, to, value, multisig):
"""Helper function for any of the N participants to check the psbt with decodepsbt and verify it is OK before signing."""
- tx = multisig.decodepsbt(psbt)["tx"]
+ decoded = multisig.decodepsbt(psbt)
amount = 0
- for vout in tx["vout"]:
- address = vout["scriptPubKey"]["address"]
+ for psbt_out in decoded["outputs"]:
+ address = psbt_out["script"]["address"]
assert_equal(multisig.getaddressinfo(address)["ischange"], address != to)
if address == to:
- amount += vout["value"]
+ amount += psbt_out["amount"]
assert_approx(amount, float(value), vspan=0.001)
def participants_create_multisigs(self, xpubs):
diff --git a/test/functional/wallet_send.py b/test/functional/wallet_send.py
index 213c4756..ba986990 100755
--- a/test/functional/wallet_send.py
+++ b/test/functional/wallet_send.py
@@ -395,10 +395,10 @@ class WalletSendTest(BitcoinTestFramework):
assert res["complete"]
res = self.test_send(from_wallet=w0, to_wallet=w1, amount=1, add_to_wallet=False, change_address=change_address, change_position=0)
assert res["complete"]
- assert_equal(self.nodes[0].decodepsbt(res["psbt"])["tx"]["vout"][0]["scriptPubKey"]["address"], change_address)
+ assert_equal(self.nodes[0].decodepsbt(res["psbt"])["outputs"][0]["script"]["address"], change_address)
res = self.test_send(from_wallet=w0, to_wallet=w1, amount=1, add_to_wallet=False, change_type="legacy", change_position=0)
assert res["complete"]
- change_address = self.nodes[0].decodepsbt(res["psbt"])["tx"]["vout"][0]["scriptPubKey"]["address"]
+ change_address = self.nodes[0].decodepsbt(res["psbt"])["outputs"][0]["script"]["address"]
assert change_address[0] in ("m", "n")
self.log.info("Set lock time...")
@@ -498,11 +498,9 @@ class WalletSendTest(BitcoinTestFramework):
assert signed["complete"]
dec = self.nodes[0].decodepsbt(signed["psbt"])
- for i, txin in enumerate(dec["tx"]["vin"]):
- if txin["txid"] == ext_utxo["txid"] and txin["vout"] == ext_utxo["vout"]:
- input_idx = i
+ for psbt_in in dec["inputs"]:
+ if psbt_in["previous_txid"] == ext_utxo["txid"] and psbt_in["previous_vout"] == ext_utxo["vout"]:
break
- psbt_in = dec["inputs"][input_idx]
scriptsig_hex = psbt_in["final_scriptSig"]["hex"] if "final_scriptSig" in psbt_in else ""
witness_stack_hex = psbt_in["final_scriptwitness"] if "final_scriptwitness" in psbt_in else None
input_weight = calculate_input_weight(scriptsig_hex, witness_stack_hex)
diff --git a/test/functional/wallet_sendall.py b/test/functional/wallet_sendall.py
index 1977a1e2..0524b5c8 100755
--- a/test/functional/wallet_sendall.py
+++ b/test/functional/wallet_sendall.py
@@ -308,9 +308,9 @@ class SendallTest(BitcoinTestFramework):
decoded = self.nodes[0].decodepsbt(psbt)
assert_equal(len(decoded["inputs"]), 1)
assert_equal(len(decoded["outputs"]), 1)
- assert_equal(decoded["tx"]["vin"][0]["txid"], utxo["txid"])
- assert_equal(decoded["tx"]["vin"][0]["vout"], utxo["vout"])
- assert_equal(decoded["tx"]["vout"][0]["scriptPubKey"]["address"], self.remainder_target)
+ assert_equal(decoded["inputs"][0]["previous_txid"], utxo["txid"])
+ assert_equal(decoded["inputs"][0]["previous_vout"], utxo["vout"])
+ assert_equal(decoded["outputs"][0]["script"]["address"], self.remainder_target)
@cleanup
def sendall_with_minconf(self):
diff --git a/test/functional/wallet_v3_txs.py b/test/functional/wallet_v3_txs.py
index 446dc086..18fa0d54 100755
--- a/test/functional/wallet_v3_txs.py
+++ b/test/functional/wallet_v3_txs.py
@@ -448,7 +448,7 @@ class WalletV3Test(BitcoinTestFramework):
outputs = {self.alice.getnewaddress() : 10}
psbt = self.charlie.createpsbt(inputs=[], outputs=outputs, version=3)
- assert_equal(self.charlie.decodepsbt(psbt)["tx"]["version"], 3)
+ assert_equal(self.charlie.decodepsbt(psbt)["tx_version"], 3)
@cleanup
def send_v3(self):
@@ -526,7 +526,7 @@ class WalletV3Test(BitcoinTestFramework):
outputs = {self.alice.getnewaddress() : 10}
psbt = self.charlie.walletcreatefundedpsbt(inputs=[], outputs=outputs, version=3)["psbt"]
- assert_equal(self.charlie.decodepsbt(psbt)["tx"]["version"], 3)
+ assert_equal(self.charlie.decodepsbt(psbt)["tx_version"], 3)
@cleanup
def sendall_truc_weight_limit(self):
Why this scored 24/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.