rpc: Support version 3 transaction creation
What changed, and why it matters
This commit adds a new 'version' parameter to several Bitcoin Core RPCs so users can create version 3 transactions. It is a feature addition, not a bug fix. The code validates that the requested version is within the allowed standard range and applies sensible defaults, so it does not appear to introduce a security vulnerability.
No immediate security action required. Treat as a normal feature review: verify the version bounds match network policy, ensure RPC help text is accurate, and confirm tests cover edge cases such as omitted version and out-of-range values.
Security signals we found
Input validation added: version is bounded to standard range before use
New RPC parameter exposed: increases attack surface slightly but is guarded
No memory safety, authentication, or consensus-critical changes observed
sendall adds compatibility check for unconfirmed v3 (TRUC) inputs
Evidence from the diff
The change extends createrawtransaction, createpsbt, send, sendall, and walletcreatefundedpsbt with an optional transaction version argument. ConstructTransaction now accepts a uint32_t version and rejects values outside [TX_MIN_STANDARD_VERSION, TX_MAX_STANDARD_VERSION]. Wallet RPCs propagate the version through CCoinControl.m_version, and sendall adds extra checks preventing mixing unconfirmed v3 inputs with non-v3 transactions and vice versa. Tests are updated to exercise valid and invalid version values.
Changed components
src/rpc/client.cppsrc/rpc/rawtransaction.cppsrc/rpc/rawtransaction_util.cppsrc/rpc/rawtransaction_util.hsrc/rpc/util.cppsrc/wallet/coincontrol.hsrc/wallet/rpc/spend.cppInspect captured patch +59 / −11
diff --git a/src/rpc/client.cpp b/src/rpc/client.cpp
index 0a33ae73..1fbe62d3 100644
--- a/src/rpc/client.cpp
+++ b/src/rpc/client.cpp
@@ -119,6 +119,7 @@ static const CRPCConvertParam vRPCConvertParams[] =
{ "createrawtransaction", 1, "outputs" },
{ "createrawtransaction", 2, "locktime" },
{ "createrawtransaction", 3, "replaceable" },
+ { "createrawtransaction", 4, "version" },
{ "decoderawtransaction", 1, "iswitness" },
{ "signrawtransactionwithkey", 1, "privkeys" },
{ "signrawtransactionwithkey", 2, "prevtxs" },
@@ -167,6 +168,7 @@ static const CRPCConvertParam vRPCConvertParams[] =
{ "walletcreatefundedpsbt", 3, "solving_data"},
{ "walletcreatefundedpsbt", 3, "max_tx_weight"},
{ "walletcreatefundedpsbt", 4, "bip32derivs" },
+ { "walletcreatefundedpsbt", 5, "version" },
{ "walletprocesspsbt", 1, "sign" },
{ "walletprocesspsbt", 3, "bip32derivs" },
{ "walletprocesspsbt", 4, "finalize" },
@@ -177,6 +179,7 @@ static const CRPCConvertParam vRPCConvertParams[] =
{ "createpsbt", 1, "outputs" },
{ "createpsbt", 2, "locktime" },
{ "createpsbt", 3, "replaceable" },
+ { "createpsbt", 4, "version" },
{ "combinepsbt", 0, "txs"},
{ "joinpsbts", 0, "txs"},
{ "finalizepsbt", 1, "extract"},
@@ -213,6 +216,7 @@ static const CRPCConvertParam vRPCConvertParams[] =
{ "send", 4, "replaceable"},
{ "send", 4, "solving_data"},
{ "send", 4, "max_tx_weight"},
+ { "send", 5, "version"},
{ "sendall", 0, "recipients" },
{ "sendall", 1, "conf_target" },
{ "sendall", 3, "fee_rate"},
@@ -230,6 +234,7 @@ static const CRPCConvertParam vRPCConvertParams[] =
{ "sendall", 4, "conf_target"},
{ "sendall", 4, "replaceable"},
{ "sendall", 4, "solving_data"},
+ { "sendall", 4, "version"},
{ "simulaterawtransaction", 0, "rawtxs" },
{ "simulaterawtransaction", 1, "options" },
{ "simulaterawtransaction", 1, "include_watchonly"},
diff --git a/src/rpc/rawtransaction.cpp b/src/rpc/rawtransaction.cpp
index c87741a5..b7f458ea 100644
--- a/src/rpc/rawtransaction.cpp
+++ b/src/rpc/rawtransaction.cpp
@@ -53,6 +53,8 @@ using node::GetTransaction;
using node::NodeContext;
using node::PSBTAnalysis;
+static constexpr decltype(CTransaction::version) DEFAULT_RAWTX_VERSION{CTransaction::CURRENT_VERSION};
+
static void TxToJSON(const CTransaction& tx, const uint256 hashBlock, UniValue& entry,
Chainstate& active_chainstate, const CTxUndo* txundo = nullptr,
TxVerbosity verbosity = TxVerbosity::SHOW_DETAILS)
@@ -158,6 +160,7 @@ static std::vector<RPCArg> CreateTxDoc()
{"locktime", RPCArg::Type::NUM, RPCArg::Default{0}, "Raw locktime. Non-0 value also locktime-activates inputs"},
{"replaceable", RPCArg::Type::BOOL, RPCArg::Default{true}, "Marks this transaction as BIP125-replaceable.\n"
"Allows this transaction to be replaced by a transaction with higher fees. If provided, it is an error if explicit sequence numbers are incompatible."},
+ {"version", RPCArg::Type::NUM, RPCArg::Default{DEFAULT_RAWTX_VERSION}, "Transaction version"},
};
}
@@ -437,7 +440,7 @@ static RPCHelpMan createrawtransaction()
if (!request.params[3].isNull()) {
rbf = request.params[3].get_bool();
}
- CMutableTransaction rawTx = ConstructTransaction(request.params[0], request.params[1], request.params[2], rbf);
+ CMutableTransaction rawTx = ConstructTransaction(request.params[0], request.params[1], request.params[2], rbf, self.Arg<uint32_t>("version"));
return EncodeHexTx(CTransaction(rawTx));
},
@@ -1679,7 +1682,7 @@ static RPCHelpMan createpsbt()
if (!request.params[3].isNull()) {
rbf = request.params[3].get_bool();
}
- CMutableTransaction rawTx = ConstructTransaction(request.params[0], request.params[1], request.params[2], rbf);
+ CMutableTransaction rawTx = ConstructTransaction(request.params[0], request.params[1], request.params[2], rbf, self.Arg<uint32_t>("version"));
// Make a blank psbt
PartiallySignedTransaction psbtx;
diff --git a/src/rpc/rawtransaction_util.cpp b/src/rpc/rawtransaction_util.cpp
index 17a111ae..2fba6c03 100644
--- a/src/rpc/rawtransaction_util.cpp
+++ b/src/rpc/rawtransaction_util.cpp
@@ -18,6 +18,7 @@
#include <tinyformat.h>
#include <univalue.h>
#include <util/rbf.h>
+#include <util/string.h>
#include <util/strencodings.h>
#include <util/translation.h>
@@ -143,7 +144,7 @@ void AddOutputs(CMutableTransaction& rawTx, const UniValue& outputs_in)
}
}
-CMutableTransaction ConstructTransaction(const UniValue& inputs_in, const UniValue& outputs_in, const UniValue& locktime, std::optional<bool> rbf)
+CMutableTransaction ConstructTransaction(const UniValue& inputs_in, const UniValue& outputs_in, const UniValue& locktime, std::optional<bool> rbf, const uint32_t version)
{
CMutableTransaction rawTx;
@@ -154,6 +155,11 @@ CMutableTransaction ConstructTransaction(const UniValue& inputs_in, const UniVal
rawTx.nLockTime = nLockTime;
}
+ if (version < TX_MIN_STANDARD_VERSION || version > TX_MAX_STANDARD_VERSION) {
+ throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("Invalid parameter, version out of range(%d~%d)", TX_MIN_STANDARD_VERSION, TX_MAX_STANDARD_VERSION));
+ }
+ rawTx.version = version;
+
AddInputs(rawTx, inputs_in, rbf);
AddOutputs(rawTx, outputs_in);
diff --git a/src/rpc/rawtransaction_util.h b/src/rpc/rawtransaction_util.h
index 40d6bbba..33017e91 100644
--- a/src/rpc/rawtransaction_util.h
+++ b/src/rpc/rawtransaction_util.h
@@ -53,6 +53,6 @@ std::vector<std::pair<CTxDestination, CAmount>> ParseOutputs(const UniValue& out
void AddOutputs(CMutableTransaction& rawTx, const UniValue& outputs_in);
/** Create a transaction from univalue parameters */
-CMutableTransaction ConstructTransaction(const UniValue& inputs_in, const UniValue& outputs_in, const UniValue& locktime, std::optional<bool> rbf);
+CMutableTransaction ConstructTransaction(const UniValue& inputs_in, const UniValue& outputs_in, const UniValue& locktime, std::optional<bool> rbf, const uint32_t version);
#endif // BITCOIN_RPC_RAWTRANSACTION_UTIL_H
diff --git a/src/rpc/util.cpp b/src/rpc/util.cpp
index 5da02b4d..0604dec2 100644
--- a/src/rpc/util.cpp
+++ b/src/rpc/util.cpp
@@ -731,6 +731,7 @@ TMPL_INST(CheckRequiredOrDefault, const UniValue&, *CHECK_NONFATAL(maybe_arg););
TMPL_INST(CheckRequiredOrDefault, bool, CHECK_NONFATAL(maybe_arg)->get_bool(););
TMPL_INST(CheckRequiredOrDefault, int, CHECK_NONFATAL(maybe_arg)->getInt<int>(););
TMPL_INST(CheckRequiredOrDefault, uint64_t, CHECK_NONFATAL(maybe_arg)->getInt<uint64_t>(););
+TMPL_INST(CheckRequiredOrDefault, uint32_t, CHECK_NONFATAL(maybe_arg)->getInt<uint32_t>(););
TMPL_INST(CheckRequiredOrDefault, const std::string&, CHECK_NONFATAL(maybe_arg)->get_str(););
bool RPCHelpMan::IsValidNumArgs(size_t num_args) const
diff --git a/src/wallet/coincontrol.h b/src/wallet/coincontrol.h
index 1cba19af..6431dc86 100644
--- a/src/wallet/coincontrol.h
+++ b/src/wallet/coincontrol.h
@@ -21,6 +21,8 @@ namespace wallet {
const int DEFAULT_MIN_DEPTH = 0;
const int DEFAULT_MAX_DEPTH = 9999999;
+const int DEFAULT_WALLET_TX_VERSION = CTransaction::CURRENT_VERSION;
+
//! Default for -avoidpartialspends
static constexpr bool DEFAULT_AVOIDPARTIALSPENDS = false;
@@ -110,7 +112,7 @@ public:
//! SigningProvider that has pubkeys and scripts to do spend size estimation for external inputs
FlatSigningProvider m_external_provider;
//! Version
- uint32_t m_version = CTransaction::CURRENT_VERSION;
+ uint32_t m_version = DEFAULT_WALLET_TX_VERSION;
//! Locktime
std::optional<uint32_t> m_locktime;
//! Caps weight of resulting tx
diff --git a/src/wallet/rpc/spend.cpp b/src/wallet/rpc/spend.cpp
index b643183e..dd7c9172 100644
--- a/src/wallet/rpc/spend.cpp
+++ b/src/wallet/rpc/spend.cpp
@@ -1276,6 +1276,7 @@ RPCHelpMan send()
},
FundTxDoc()),
RPCArgOptions{.oneline_description="options"}},
+ {"version", RPCArg::Type::NUM, RPCArg::Default{DEFAULT_WALLET_TX_VERSION}, "Transaction version"},
},
RPCResult{
RPCResult::Type::OBJ, "", "",
@@ -1315,8 +1316,9 @@ RPCHelpMan send()
ParseOutputs(outputs),
InterpretSubtractFeeFromOutputInstructions(options["subtract_fee_from_outputs"], outputs.getKeys())
);
- CMutableTransaction rawTx = ConstructTransaction(options["inputs"], request.params[0], options["locktime"], rbf);
CCoinControl coin_control;
+ coin_control.m_version = self.Arg<uint32_t>("version");
+ CMutableTransaction rawTx = ConstructTransaction(options["inputs"], request.params[0], options["locktime"], rbf, coin_control.m_version);
// Automatically select coins, unless at least one is manually selected. Can
// be overridden by options.add_inputs.
coin_control.m_allow_other_inputs = rawTx.vin.size() == 0;
@@ -1383,6 +1385,7 @@ RPCHelpMan sendall()
{"send_max", RPCArg::Type::BOOL, RPCArg::Default{false}, "When true, only use UTXOs that can pay for their own fees to maximize the output amount. When 'false' (default), no UTXO is left behind. send_max is incompatible with providing specific inputs."},
{"minconf", RPCArg::Type::NUM, RPCArg::Default{0}, "Require inputs with at least this many confirmations."},
{"maxconf", RPCArg::Type::NUM, RPCArg::Optional::OMITTED, "Require inputs with at most this many confirmations."},
+ {"version", RPCArg::Type::NUM, RPCArg::Default{DEFAULT_WALLET_TX_VERSION}, "Transaction version"},
},
FundTxDoc()
),
@@ -1463,6 +1466,10 @@ RPCHelpMan sendall()
}
}
+ if (options.exists("version")) {
+ coin_control.m_version = options["version"].getInt<int>();
+ }
+
if (coin_control.m_version == TRUC_VERSION) {
coin_control.m_max_tx_weight = TRUC_MAX_WEIGHT;
} else {
@@ -1483,7 +1490,7 @@ RPCHelpMan sendall()
throw JSONRPCError(RPC_WALLET_ERROR, "Fee estimation failed. Fallbackfee is disabled. Wait a few blocks or enable -fallbackfee.");
}
- CMutableTransaction rawTx{ConstructTransaction(options["inputs"], recipient_key_value_pairs, options["locktime"], rbf)};
+ CMutableTransaction rawTx{ConstructTransaction(options["inputs"], recipient_key_value_pairs, options["locktime"], rbf, coin_control.m_version)};
LOCK(pwallet->cs_wallet);
CAmount total_input_value(0);
@@ -1501,6 +1508,13 @@ RPCHelpMan sendall()
if (!tx || input.prevout.n >= tx->tx->vout.size() || !(pwallet->IsMine(tx->tx->vout[input.prevout.n]) & ISMINE_SPENDABLE)) {
throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("Input not found. UTXO (%s:%d) is not part of wallet.", input.prevout.hash.ToString(), input.prevout.n));
}
+ if (pwallet->GetTxDepthInMainChain(*tx) == 0) {
+ if (tx->tx->version == TRUC_VERSION && coin_control.m_version != TRUC_VERSION) {
+ throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("Can't spend unconfirmed version 3 pre-selected input with a version %d tx", coin_control.m_version));
+ } else if (coin_control.m_version == TRUC_VERSION && tx->tx->version != TRUC_VERSION) {
+ throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("Can't spend unconfirmed version %d pre-selected input with a version 3 tx", tx->tx->version));
+ }
+ }
total_input_value += tx->tx->vout[input.prevout.n].nValue;
}
} else {
@@ -1749,6 +1763,7 @@ RPCHelpMan walletcreatefundedpsbt()
FundTxDoc()),
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"},
},
RPCResult{
RPCResult::Type::OBJ, "", "",
@@ -1776,16 +1791,18 @@ RPCHelpMan walletcreatefundedpsbt()
UniValue options{request.params[3].isNull() ? UniValue::VOBJ : request.params[3]};
+ CCoinControl coin_control;
+ coin_control.m_version = self.Arg<uint32_t>("version");
+
const UniValue &replaceable_arg = options["replaceable"];
const bool rbf{replaceable_arg.isNull() ? wallet.m_signal_rbf : replaceable_arg.get_bool()};
- CMutableTransaction rawTx = ConstructTransaction(request.params[0], request.params[1], request.params[2], rbf);
+ CMutableTransaction rawTx = ConstructTransaction(request.params[0], request.params[1], request.params[2], rbf, coin_control.m_version);
UniValue outputs(UniValue::VOBJ);
outputs = NormalizeOutputs(request.params[1]);
std::vector<CRecipient> recipients = CreateRecipients(
ParseOutputs(outputs),
InterpretSubtractFeeFromOutputInstructions(options["subtractFeeFromOutputs"], outputs.getKeys())
);
- CCoinControl coin_control;
// Automatically select coins, unless at least one is manually selected. Can
// be overridden by options.add_inputs.
coin_control.m_allow_other_inputs = rawTx.vin.size() == 0;
diff --git a/test/functional/feature_taproot.py b/test/functional/feature_taproot.py
index b22d5731..348b2b67 100755
--- a/test/functional/feature_taproot.py
+++ b/test/functional/feature_taproot.py
@@ -19,6 +19,7 @@ from test_framework.messages import (
CTxOut,
SEQUENCE_FINAL,
tx_from_hex,
+ TX_MAX_STANDARD_VERSION,
WITNESS_SCALE_FACTOR,
)
from test_framework.script import (
@@ -666,7 +667,6 @@ SIG_ADD_ZERO = {"failure": {"sign": zero_appender(default_sign)}}
DUST_LIMIT = 600
MIN_FEE = 50000
-TX_MAX_STANDARD_VERSION = 3
TX_STANDARD_VERSIONS = [1, 2, TX_MAX_STANDARD_VERSION]
TRUC_MAX_VSIZE = 10000 # test doesn't cover in-mempool spends, so only this limit is hit
diff --git a/test/functional/rpc_rawtransaction.py b/test/functional/rpc_rawtransaction.py
index 4b93768d..d2610a1d 100755
--- a/test/functional/rpc_rawtransaction.py
+++ b/test/functional/rpc_rawtransaction.py
@@ -19,6 +19,8 @@ from itertools import product
from test_framework.messages import (
MAX_BIP125_RBF_SEQUENCE,
COIN,
+ TX_MAX_STANDARD_VERSION,
+ TX_MIN_STANDARD_VERSION,
CTransaction,
CTxOut,
tx_from_hex,
@@ -254,7 +256,11 @@ class RawTransactionsTest(BitcoinTestFramework):
assert_raises_rpc_error(-1, "createrawtransaction", self.nodes[0].createrawtransaction, [])
# Test `createrawtransaction` invalid extra parameters
- assert_raises_rpc_error(-1, "createrawtransaction", self.nodes[0].createrawtransaction, [], {}, 0, False, 'foo')
+ assert_raises_rpc_error(-1, "createrawtransaction", self.nodes[0].createrawtransaction, [], {}, 0, False, 2, 3, 'foo')
+
+ # Test `createrawtransaction` invalid version parameters
+ assert_raises_rpc_error(-8, f"Invalid parameter, version out of range({TX_MIN_STANDARD_VERSION}~{TX_MAX_STANDARD_VERSION})", self.nodes[0].createrawtransaction, [], {}, 0, False, TX_MIN_STANDARD_VERSION - 1)
+ assert_raises_rpc_error(-8, f"Invalid parameter, version out of range({TX_MIN_STANDARD_VERSION}~{TX_MAX_STANDARD_VERSION})", self.nodes[0].createrawtransaction, [], {}, 0, False, TX_MAX_STANDARD_VERSION + 1)
# Test `createrawtransaction` invalid `inputs`
assert_raises_rpc_error(-3, "JSON value of type string is not of expected type array", self.nodes[0].createrawtransaction, 'foo', {})
@@ -334,6 +340,11 @@ class RawTransactionsTest(BitcoinTestFramework):
self.nodes[2].createrawtransaction(inputs=[{'txid': TXID, 'vout': 9}], outputs=[{address: 99}, {address2: 99}, {'data': '99'}]),
)
+ for version in range(TX_MIN_STANDARD_VERSION, TX_MAX_STANDARD_VERSION + 1):
+ rawtx = self.nodes[2].createrawtransaction(inputs=[{'txid': TXID, 'vout': 9}], outputs=OrderedDict([(address, 99), (address2, 99)]), version=version)
+ tx = tx_from_hex(rawtx)
+ assert_equal(tx.version, version)
+
def sendrawtransaction_tests(self):
self.log.info("Test sendrawtransaction with missing input")
inputs = [{'txid': TXID, 'vout': 1}] # won't exist
diff --git a/test/functional/test_framework/messages.py b/test/functional/test_framework/messages.py
index 2c815ce5..ebb306a8 100755
--- a/test/functional/test_framework/messages.py
+++ b/test/functional/test_framework/messages.py
@@ -80,6 +80,9 @@ MAX_OP_RETURN_RELAY = 100_000
DEFAULT_MEMPOOL_EXPIRY_HOURS = 336 # hours
+TX_MIN_STANDARD_VERSION = 1
+TX_MAX_STANDARD_VERSION = 3
+
MAGIC_BYTES = {
"mainnet": b"\xf9\xbe\xb4\xd9",
"testnet4": b"\x1c\x16\x3f\x28",
Why this scored 20/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.