Merge bitcoin/bitcoin#32896: wallet, rpc: add v3 transaction creation and wallet support

5c8bf7b39e doc: add release notes for version 3 transactions (ishaanam)
4ef8065a5e test: add truc wallet tests (ishaanam)
5d932e14db test: extract `bulk_vout` from `bulk_tx` so it can be used by wallet tests (ishaanam)
2cb473d9f2 rpc: Support version 3 transaction creation (Bue-von-hon)
4c20343b4d rpc: Add transaction min standard version parameter (Bue-von-hon)
c5a2d08011 wallet: don't return utxos from multiple truc txs in AvailableCoins (ishaanam)
da8748ad62 wallet: limit v3 tx weight in coin selection (ishaanam)
85c5410615 wallet: mark unconfirmed v3 siblings as mempool conflicts (ishaanam)
0804fc3cb1 wallet: throw error at conflicting tx versions in pre-selected inputs (ishaanam)
cc155226fe wallet: set m_version in coin control to default value (ishaanam)
2e9617664e  wallet: don't include unconfirmed v3 txs with children in available coins (ishaanam)
ec2676becd wallet: unconfirmed ancestors and descendants are always truc (ishaanam)

Pull request description:

  This PR Implements the following:
  - If creating a v3 transaction, `AvailableCoins` doesn't return unconfirmed v2 utxos (and vice versa)
  - `AvailableCoins` doesn't return an unconfirmed v3 utxo if its transaction already has a child
  - If a v3 transaction is kicked out of the mempool by a sibling, mark the sibling as a mempool conflict
  - Throw an error if pre-selected inputs are of the wrong transaction version
  - Allow setting version to 3 manually in `createrawtransaction` (uses commits from #31936)
  - Limits a v3 transaction weight in coin selection

  Closes #31348

  To-Do:
  - [x] Test a v3 sibling conflict kicking out one of our transactions from the mempool
  - [x] Implement separate size limit for TRUC children
  - [x] Test that we can't fund a v2 transaction when everything is v3 unconfirmed
  - [x] Test a v3 sibling conflict being removed from the mempool
  - [x] Test limiting v3 transaction weight in coin selection
  - [x] Simplify tests
  - [x] Add documentation
  - [x] Test that user-input max weight is not overwritten by truc max weight
  - [x] Test v3 in RPCs other than `createrawtransaction`

ACKs for top commit:
  glozow:
    reACK 5c8bf7b39e
  achow101:
    ACK 5c8bf7b39e
  rkrux:
    ACK 5c8bf7b39e

Tree-SHA512: da8aea51c113e193dd0b442eff765bd6b8dc0e5066272d3e52190a223c903f48788795f32c554f268af0d2607b5b8c3985c648879cb176c65540837c05d0abb5
This commit is contained in:
merge-script
2025-08-19 06:00:50 -04:00
25 changed files with 836 additions and 32 deletions

View File

@@ -0,0 +1,19 @@
Updated RPCs
------------
The following RPCs now contain a `version` parameter that allows
the user to create transactions of any standard version number (1-3):
- `createrawtransaction`
- `createpsbt`
- `send`
- `sendall`
- `walletcreatefundedpsbt`
Wallet
------
Support has been added for spending TRUC transactions received by the
wallet, as well as creating TRUC transactions. The wallet ensures that
TRUC policy rules are being met. The wallet will throw an error if the
user is trying to spend TRUC utxos with utxos of other versions.
Additionally, the wallet will treat unconfirmed TRUC sibling
transactions as mempool conflicts. The wallet will also ensure that
transactions spending TRUC utxos meet the required size restrictions.

View File

@@ -98,7 +98,7 @@ bool IsStandard(const CScript& scriptPubKey, TxoutType& whichType)
bool IsStandardTx(const CTransaction& tx, const std::optional<unsigned>& max_datacarrier_bytes, bool permit_bare_multisig, const CFeeRate& dust_relay_fee, std::string& reason)
{
if (tx.version > TX_MAX_STANDARD_VERSION || tx.version < 1) {
if (tx.version > TX_MAX_STANDARD_VERSION || tx.version < TX_MIN_STANDARD_VERSION) {
reason = "version";
return false;
}

View File

@@ -145,6 +145,7 @@ std::vector<uint32_t> GetDust(const CTransaction& tx, CFeeRate dust_relay_rate);
// Changing the default transaction version requires a two step process: first
// adapting relay policy by bumping TX_MAX_STANDARD_VERSION, and then later
// allowing the new transaction version in the wallet/RPC.
static constexpr decltype(CTransaction::version) TX_MIN_STANDARD_VERSION{1};
static constexpr decltype(CTransaction::version) TX_MAX_STANDARD_VERSION{3};
/**

View File

@@ -28,8 +28,10 @@ static constexpr unsigned int TRUC_ANCESTOR_LIMIT{2};
/** Maximum sigop-adjusted virtual size of all v3 transactions. */
static constexpr int64_t TRUC_MAX_VSIZE{10000};
static constexpr int64_t TRUC_MAX_WEIGHT{TRUC_MAX_VSIZE * WITNESS_SCALE_FACTOR};
/** Maximum sigop-adjusted virtual size of a tx which spends from an unconfirmed TRUC transaction. */
static constexpr int64_t TRUC_CHILD_MAX_VSIZE{1000};
static constexpr int64_t TRUC_CHILD_MAX_WEIGHT{TRUC_CHILD_MAX_VSIZE * WITNESS_SCALE_FACTOR};
// These limits are within the default ancestor/descendant limits.
static_assert(TRUC_MAX_VSIZE + TRUC_CHILD_MAX_VSIZE <= DEFAULT_ANCESTOR_SIZE_LIMIT_KVB * 1000);
static_assert(TRUC_MAX_VSIZE + TRUC_CHILD_MAX_VSIZE <= DEFAULT_DESCENDANT_SIZE_LIMIT_KVB * 1000);

View File

@@ -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"},

View File

@@ -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;

View File

@@ -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);

View File

@@ -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

View File

@@ -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

View File

@@ -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;
@@ -109,10 +111,10 @@ public:
int m_max_depth = DEFAULT_MAX_DEPTH;
//! SigningProvider that has pubkeys and scripts to do spend size estimation for external inputs
FlatSigningProvider m_external_provider;
//! Version
uint32_t m_version = DEFAULT_WALLET_TX_VERSION;
//! Locktime
std::optional<uint32_t> m_locktime;
//! Version
std::optional<uint32_t> m_version;
//! Caps weight of resulting tx
std::optional<int> m_max_tx_weight{std::nullopt};

View File

@@ -174,6 +174,8 @@ struct CoinSelectionParams {
* 1) Received from other wallets, 2) replacing other txs, 3) that have been replaced.
*/
bool m_include_unsafe_inputs = false;
/** The version of the transaction we are trying to create. */
uint32_t m_version{CTransaction::CURRENT_VERSION};
/** The maximum weight for this transaction. */
std::optional<int> m_max_tx_weight{std::nullopt};

View File

@@ -8,6 +8,7 @@
#include <key_io.h>
#include <node/types.h>
#include <policy/policy.h>
#include <policy/truc_policy.h>
#include <rpc/rawtransaction_util.h>
#include <rpc/util.h>
#include <script/script.h>
@@ -717,6 +718,12 @@ CreatedTransactionResult FundTransaction(CWallet& wallet, const CMutableTransact
coinControl.m_max_tx_weight = options["max_tx_weight"].getInt<int>();
}
if (tx.version == TRUC_VERSION) {
if (!coinControl.m_max_tx_weight.has_value() || coinControl.m_max_tx_weight.value() > TRUC_MAX_WEIGHT) {
coinControl.m_max_tx_weight = TRUC_MAX_WEIGHT;
}
}
if (recipients.empty())
throw JSONRPCError(RPC_INVALID_PARAMETER, "TX must have at least one output");
@@ -1269,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, "", "",
@@ -1308,14 +1316,16 @@ 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;
if (options.exists("max_tx_weight")) {
coin_control.m_max_tx_weight = options["max_tx_weight"].getInt<int>();
}
SetOptionsInputWeights(options["inputs"], options);
// Clear tx.vout since it is not meant to be used now that we are passing outputs directly.
// This sets us up for a future PR to completely remove tx from the function signature in favor of passing inputs directly
@@ -1375,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()
),
@@ -1455,6 +1466,16 @@ 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 {
coin_control.m_max_tx_weight = MAX_STANDARD_TX_WEIGHT;
}
const bool rbf{options.exists("replaceable") ? options["replaceable"].get_bool() : pwallet->m_signal_rbf};
FeeCalculation fee_calc_out;
@@ -1469,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);
@@ -1487,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 {
@@ -1497,6 +1525,10 @@ RPCHelpMan sendall()
if (send_max && fee_rate.GetFee(output.input_bytes) > output.txout.nValue) {
continue;
}
// we are spending an unconfirmed TRUC transaction, so lower max weight
if (output.depth == 0 && coin_control.m_version == TRUC_VERSION) {
coin_control.m_max_tx_weight = TRUC_CHILD_MAX_WEIGHT;
}
CTxIn input(output.outpoint.hash, output.outpoint.n, CScript(), rbf ? MAX_BIP125_RBF_SEQUENCE : CTxIn::SEQUENCE_FINAL);
rawTx.vin.push_back(input);
total_input_value += output.txout.nValue;
@@ -1529,7 +1561,7 @@ RPCHelpMan sendall()
}
// If this transaction is too large, e.g. because the wallet has many UTXOs, it will be rejected by the node's mempool.
if (tx_size.weight > MAX_STANDARD_TX_WEIGHT) {
if (tx_size.weight > coin_control.m_max_tx_weight) {
throw JSONRPCError(RPC_WALLET_ERROR, "Transaction too large.");
}
@@ -1731,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, "", "",
@@ -1758,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;

View File

@@ -12,6 +12,7 @@
#include <node/types.h>
#include <numeric>
#include <policy/policy.h>
#include <policy/truc_policy.h>
#include <primitives/transaction.h>
#include <primitives/transaction_identifier.h>
#include <script/script.h>
@@ -282,6 +283,14 @@ util::Result<PreSelectedInputs> FetchSelectedInputs(const CWallet& wallet, const
if (input_bytes == -1) {
input_bytes = CalculateMaximumSignedInputSize(txout, &wallet, &coin_control);
}
const CWalletTx& parent_tx = txo->GetWalletTx();
if (wallet.GetTxDepthInMainChain(parent_tx) == 0) {
if (parent_tx.tx->version == TRUC_VERSION && coin_control.m_version != TRUC_VERSION) {
return util::Error{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 && parent_tx.tx->version != TRUC_VERSION) {
return util::Error{strprintf(_("Can't spend unconfirmed version %d pre-selected input with a version 3 tx"), parent_tx.tx->version)};
}
}
} else {
// The input is external. We did not find the tx in mapWallet.
const auto out{coin_control.GetExternalOutput(outpoint)};
@@ -316,6 +325,9 @@ CoinsResult AvailableCoins(const CWallet& wallet,
AssertLockHeld(wallet.cs_wallet);
CoinsResult result;
// track unconfirmed truc outputs separately if we are tracking trucness
std::vector<std::pair<OutputType, COutput>> unconfirmed_truc_coins;
std::unordered_map<Txid, CAmount, SaltedTxidHasher> truc_txid_by_value;
// Either the WALLET_FLAG_AVOID_REUSE flag is not set (in which case we always allow), or we default to avoiding, and only in the case where
// a coin control object is provided, and has the avoid address reuse flag set to false, do we allow already used addresses
bool allow_used_addresses = !wallet.IsWalletFlagSet(WALLET_FLAG_AVOID_REUSE) || (coinControl && !coinControl->m_avoid_address_reuse);
@@ -386,6 +398,17 @@ CoinsResult AvailableCoins(const CWallet& wallet,
safeTx = false;
}
if (nDepth == 0 && params.check_version_trucness) {
if (coinControl->m_version == TRUC_VERSION) {
if (wtx.tx->version != TRUC_VERSION) continue;
// this unconfirmed v3 transaction already has a child
if (wtx.truc_child_in_mempool.has_value()) continue;
} else {
if (wtx.tx->version == TRUC_VERSION) continue;
Assume(!wtx.truc_child_in_mempool.has_value());
}
}
if (only_safe && !safeTx) {
continue;
}
@@ -450,8 +473,15 @@ CoinsResult AvailableCoins(const CWallet& wallet,
is_from_p2sh = true;
}
result.Add(GetOutputType(type, is_from_p2sh),
COutput(outpoint, output, nDepth, input_bytes, spendable, solvable, tx_safe, wtx.GetTxTime(), tx_from_me, feerate));
auto available_output_type = GetOutputType(type, is_from_p2sh);
auto available_output = COutput(outpoint, output, nDepth, input_bytes, spendable, solvable, tx_safe, wtx.GetTxTime(), tx_from_me, feerate);
if (wtx.tx->version == TRUC_VERSION && nDepth == 0 && params.check_version_trucness) {
unconfirmed_truc_coins.emplace_back(available_output_type, available_output);
auto [it, _] = truc_txid_by_value.try_emplace(wtx.tx->GetHash(), 0);
it->second += output.nValue;
} else {
result.Add(available_output_type, available_output);
}
outpoints.push_back(outpoint);
@@ -468,6 +498,23 @@ CoinsResult AvailableCoins(const CWallet& wallet,
}
}
// Return all the coins from one TRUC transaction, that have the highest value.
// This could be improved in the future by encoding these restrictions in
// the coin selection itself so that we don't have to filter out
// other unconfirmed TRUC coins beforehand.
if (params.check_version_trucness && unconfirmed_truc_coins.size() > 0) {
auto highest_value_truc_tx = std::max_element(truc_txid_by_value.begin(), truc_txid_by_value.end(), [](const auto& tx1, const auto& tx2){
return tx1.second < tx2.second;
});
const Txid& truc_txid = highest_value_truc_tx->first;
for (const auto& [type, output] : unconfirmed_truc_coins) {
if (output.outpoint.hash == truc_txid) {
result.Add(type, output);
}
}
}
if (feerate.has_value()) {
std::map<COutPoint, CAmount> map_of_bump_fees = wallet.chain().calculateIndividualBumpFees(outpoints, feerate.value());
@@ -484,6 +531,7 @@ CoinsResult AvailableCoins(const CWallet& wallet,
CoinsResult AvailableCoinsListUnspent(const CWallet& wallet, const CCoinControl* coinControl, CoinFilterParams params)
{
params.only_spendable = false;
params.check_version_trucness = false;
return AvailableCoins(wallet, coinControl, /*feerate=*/ std::nullopt, params);
}
@@ -904,11 +952,17 @@ util::Result<SelectionResult> AutomaticCoinSelection(const CWallet& wallet, Coin
// If no solution is found, return the first detailed error (if any).
// future: add "error level" so the worst one can be picked instead.
std::vector<util::Result<SelectionResult>> res_detailed_errors;
CoinSelectionParams updated_selection_params = coin_selection_params;
for (const auto& select_filter : ordered_filters) {
auto it = filtered_groups.find(select_filter.filter);
if (it == filtered_groups.end()) continue;
if (updated_selection_params.m_version == TRUC_VERSION && (select_filter.filter.conf_mine == 0 || select_filter.filter.conf_theirs == 0)) {
if (updated_selection_params.m_max_tx_weight > (TRUC_CHILD_MAX_WEIGHT)) {
updated_selection_params.m_max_tx_weight = TRUC_CHILD_MAX_WEIGHT;
}
}
if (auto res{AttemptSelection(wallet.chain(), value_to_select, it->second,
coin_selection_params, select_filter.allow_mixed_output_types)}) {
updated_selection_params, select_filter.allow_mixed_output_types)}) {
return res; // result found
} else {
// If any specific error message appears here, then something particularly wrong might have happened.
@@ -1019,14 +1073,13 @@ static util::Result<CreatedTransactionResult> CreateTransactionInternal(
FastRandomContext rng_fast;
CMutableTransaction txNew; // The resulting transaction that we make
if (coin_control.m_version) {
txNew.version = coin_control.m_version.value();
}
txNew.version = coin_control.m_version;
CoinSelectionParams coin_selection_params{rng_fast}; // Parameters for coin selection, init with dummy
coin_selection_params.m_avoid_partial_spends = coin_control.m_avoid_partial_spends;
coin_selection_params.m_include_unsafe_inputs = coin_control.m_include_unsafe_inputs;
coin_selection_params.m_max_tx_weight = coin_control.m_max_tx_weight.value_or(MAX_STANDARD_TX_WEIGHT);
coin_selection_params.m_version = coin_control.m_version;
int minimum_tx_weight = MIN_STANDARD_TX_NONWITNESS_SIZE * WITNESS_SCALE_FACTOR;
if (coin_selection_params.m_max_tx_weight.value() < minimum_tx_weight || coin_selection_params.m_max_tx_weight.value() > MAX_STANDARD_TX_WEIGHT) {
return util::Error{strprintf(_("Maximum transaction weight must be between %d and %d"), minimum_tx_weight, MAX_STANDARD_TX_WEIGHT)};

View File

@@ -83,6 +83,9 @@ struct CoinFilterParams {
bool include_immature_coinbase{false};
// By default, skip locked UTXOs
bool skip_locked{true};
// When true, filter unconfirmed coins by whether their
// version's TRUCness matches what is set by CCoinControl.
bool check_version_trucness{true};
};
/**

View File

@@ -258,6 +258,10 @@ public:
// BlockConflicted.
std::set<Txid> mempool_conflicts;
// Track v3 mempool tx that spends from this tx
// so that we don't try to create another unconfirmed child
std::optional<Txid> truc_child_in_mempool;
template<typename Stream>
void Serialize(Stream& s) const
{

View File

@@ -30,6 +30,7 @@
#include <node/types.h>
#include <outputtype.h>
#include <policy/feerate.h>
#include <policy/truc_policy.h>
#include <primitives/block.h>
#include <primitives/transaction.h>
#include <psbt.h>
@@ -1213,6 +1214,23 @@ bool CWallet::TransactionCanBeAbandoned(const Txid& hashTx) const
return wtx && !wtx->isAbandoned() && GetTxDepthInMainChain(*wtx) == 0 && !wtx->InMempool();
}
void CWallet::UpdateTrucSiblingConflicts(const CWalletTx& parent_wtx, const Txid& child_txid, bool add_conflict) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet)
{
// Find all other txs in our wallet that spend utxos from this parent
// so that we can mark them as mempool-conflicted by this new tx.
for (long unsigned int i = 0; i < parent_wtx.tx->vout.size(); i++) {
for (auto range = mapTxSpends.equal_range(COutPoint(parent_wtx.tx->GetHash(), i)); range.first != range.second; range.first++) {
const Txid& sibling_txid = range.first->second;
// Skip the child_tx itself
if (sibling_txid == child_txid) continue;
RecursiveUpdateTxState(/*batch=*/nullptr, sibling_txid, [&child_txid, add_conflict](CWalletTx& wtx) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet) {
return add_conflict ? (wtx.mempool_conflicts.insert(child_txid).second ? TxUpdate::CHANGED : TxUpdate::UNCHANGED)
: (wtx.mempool_conflicts.erase(child_txid) ? TxUpdate::CHANGED : TxUpdate::UNCHANGED);
});
}
}
}
void CWallet::MarkInputsDirty(const CTransactionRef& tx)
{
for (const CTxIn& txin : tx->vin) {
@@ -1368,6 +1386,25 @@ void CWallet::transactionAddedToMempool(const CTransactionRef& tx) {
return wtx.mempool_conflicts.insert(txid).second ? TxUpdate::CHANGED : TxUpdate::UNCHANGED;
});
}
}
if (tx->version == TRUC_VERSION) {
// Unconfirmed TRUC transactions are only allowed a 1-parent-1-child topology.
// For any unconfirmed v3 parents (there should be a maximum of 1 except in reorgs),
// record this child so the wallet doesn't try to spend any other outputs
for (const CTxIn& tx_in : tx->vin) {
auto parent_it = mapWallet.find(tx_in.prevout.hash);
if (parent_it != mapWallet.end()) {
CWalletTx& parent_wtx = parent_it->second;
if (parent_wtx.isUnconfirmed()) {
parent_wtx.truc_child_in_mempool = tx->GetHash();
// Even though these siblings do not spend the same utxos, they can't
// be present in the mempool at the same time because of TRUC policy rules
UpdateTrucSiblingConflicts(parent_wtx, txid, /*add_conflict=*/true);
}
}
}
}
}
@@ -1421,6 +1458,23 @@ void CWallet::transactionRemovedFromMempool(const CTransactionRef& tx, MemPoolRe
});
}
}
if (tx->version == TRUC_VERSION) {
// If this tx has a parent, unset its truc_child_in_mempool to make it possible
// to spend from the parent again. If this tx was replaced by another
// child of the same parent, transactionAddedToMempool
// will update truc_child_in_mempool
for (const CTxIn& tx_in : tx->vin) {
auto parent_it = mapWallet.find(tx_in.prevout.hash);
if (parent_it != mapWallet.end()) {
CWalletTx& parent_wtx = parent_it->second;
if (parent_wtx.truc_child_in_mempool == tx->GetHash()) {
parent_wtx.truc_child_in_mempool = std::nullopt;
UpdateTrucSiblingConflicts(parent_wtx, txid, /*add_conflict=*/false);
}
}
}
}
}
void CWallet::blockConnected(ChainstateRole role, const interfaces::BlockInfo& block)

View File

@@ -450,6 +450,9 @@ private:
// Update last block processed in memory only
void SetLastBlockProcessedInMem(int block_height, uint256 block_hash) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
//! Update mempool conflicts for TRUC sibling transactions
void UpdateTrucSiblingConflicts(const CWalletTx& parent_wtx, const Txid& child_txid, bool add_conflict) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
public:
/**
* Main wallet lock.

View File

@@ -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

View File

@@ -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

View File

@@ -34,6 +34,9 @@ DEFAULT_MIN_RELAY_TX_FEE = 100
# Default for -incrementalrelayfee in sat/kvB
DEFAULT_INCREMENTAL_RELAY_FEE = 100
TRUC_MAX_VSIZE = 10000
TRUC_CHILD_MAX_VSIZE = 1000
def assert_mempool_contents(test_framework, node, expected=None, sync=True):
"""Assert that all transactions in expected are in the mempool,
and no additional ones exist. 'expected' is an array of

View File

@@ -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",

View File

@@ -13,6 +13,7 @@ from test_framework.messages import (
CTxIn,
CTxInWitness,
CTxOut,
ser_compact_size,
sha256,
)
from test_framework.script import (
@@ -35,6 +36,8 @@ from test_framework.script import (
hash160,
)
from test_framework.util import assert_equal
# Maximum number of potentially executed legacy signature operations in validating a transaction.
MAX_STD_LEGACY_SIGOPS = 2_500
@@ -128,6 +131,16 @@ def script_to_p2sh_p2wsh_script(script):
p2shscript = CScript([OP_0, sha256(script)])
return script_to_p2sh_script(p2shscript)
def bulk_vout(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()}")
# 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)
assert_equal(tx.get_vsize(), target_vsize)
def output_key_to_p2tr_script(key):
assert len(key) == 32

View File

@@ -33,11 +33,9 @@ from test_framework.messages import (
CTxInWitness,
CTxOut,
hash256,
ser_compact_size,
)
from test_framework.script import (
CScript,
OP_1,
OP_NOP,
OP_RETURN,
OP_TRUE,
@@ -45,6 +43,7 @@ from test_framework.script import (
taproot_construct,
)
from test_framework.script_util import (
bulk_vout,
key_to_p2pk_script,
key_to_p2pkh_script,
key_to_p2sh_p2wpkh_script,
@@ -121,17 +120,9 @@ class MiniWallet:
"""Pad a transaction with extra outputs until it reaches a target vsize.
returns the tx
"""
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)
assert_equal(tx.get_vsize(), target_vsize)
bulk_vout(tx, target_vsize)
def get_balance(self):
return sum(u['value'] for u in self._utxos)

View File

@@ -109,6 +109,7 @@ BASE_SCRIPTS = [
'rpc_psbt.py',
'wallet_fundrawtransaction.py',
'wallet_bumpfee.py',
'wallet_v3_txs.py',
'wallet_backup.py',
'feature_segwit.py --v2transport',
'feature_segwit.py --v1transport',

589
test/functional/wallet_v3_txs.py Executable file
View File

@@ -0,0 +1,589 @@
#!/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 how the wallet deals with TRUC transactions"""
from decimal import Decimal, getcontext
from test_framework.authproxy import JSONRPCException
from test_framework.messages import (
COIN,
CTransaction,
CTxOut,
)
from test_framework.script import (
CScript,
OP_RETURN
)
from test_framework.script_util import bulk_vout
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import (
assert_equal,
assert_greater_than,
assert_raises_rpc_error,
)
from test_framework.mempool_util import (
TRUC_MAX_VSIZE,
TRUC_CHILD_MAX_VSIZE,
)
# sweep alice and bob's wallets and clear the mempool
def cleanup(func):
def wrapper(self, *args):
try:
self.generate(self.nodes[0], 1)
func(self, *args)
finally:
self.generate(self.nodes[0], 1)
try:
self.alice.sendall([self.charlie.getnewaddress()])
except JSONRPCException as e:
assert "Total value of UTXO pool too low to pay for transaction" in e.error['message']
try:
self.bob.sendall([self.charlie.getnewaddress()])
except JSONRPCException as e:
assert "Total value of UTXO pool too low to pay for transaction" in e.error['message']
self.generate(self.nodes[0], 1)
for wallet in [self.alice, self.bob]:
balance = wallet.getbalances()["mine"]
for balance_type in ["untrusted_pending", "trusted", "immature"]:
assert_equal(balance[balance_type], 0)
assert_equal(self.alice.getrawmempool(), [])
assert_equal(self.bob.getrawmempool(), [])
return wrapper
class WalletV3Test(BitcoinTestFramework):
def skip_test_if_missing_module(self):
self.skip_if_no_wallet()
def set_test_params(self):
getcontext().prec=10
self.num_nodes = 1
self.setup_clean_chain = True
def send_tx(self, from_wallet, inputs, outputs, version):
raw_tx = from_wallet.createrawtransaction(inputs=inputs, outputs=outputs, version=version)
if inputs == []:
raw_tx = from_wallet.fundrawtransaction(raw_tx, {'include_unsafe' : True})["hex"]
raw_tx = from_wallet.signrawtransactionwithwallet(raw_tx)["hex"]
txid = from_wallet.sendrawtransaction(raw_tx)
return txid
def bulk_tx(self, tx, amount, target_vsize):
tx.vout.append(CTxOut(nValue=(amount * COIN), scriptPubKey=CScript([OP_RETURN])))
bulk_vout(tx, target_vsize)
def run_test_with_swapped_versions(self, test_func):
test_func(2, 3)
test_func(3, 2)
def run_test(self):
self.nodes[0].createwallet("alice")
self.alice = self.nodes[0].get_wallet_rpc("alice")
self.nodes[0].createwallet("bob")
self.bob = self.nodes[0].get_wallet_rpc("bob")
self.nodes[0].createwallet("charlie")
self.charlie = self.nodes[0].get_wallet_rpc("charlie")
self.generatetoaddress(self.nodes[0], 100, self.charlie.getnewaddress())
self.run_test_with_swapped_versions(self.tx_spends_unconfirmed_tx_with_wrong_version)
self.run_test_with_swapped_versions(self.va_tx_spends_confirmed_vb_tx)
self.run_test_with_swapped_versions(self.spend_inputs_with_different_versions)
self.spend_inputs_with_different_versions_default_version()
self.v3_utxos_appear_in_listunspent()
self.truc_tx_with_conflicting_sibling()
self.truc_tx_with_conflicting_sibling_change()
self.v3_tx_evicted_from_mempool_by_sibling()
self.v3_conflict_removed_from_mempool()
self.mempool_conflicts_removed_when_v3_conflict_removed()
self.max_tx_weight()
self.max_tx_child_weight()
self.user_input_weight_not_overwritten()
self.user_input_weight_not_overwritten_v3_child()
self.createpsbt_v3()
self.send_v3()
self.sendall_v3()
self.sendall_with_unconfirmed_v3()
self.walletcreatefundedpsbt_v3()
self.sendall_truc_weight_limit()
self.sendall_truc_child_weight_limit()
self.mix_non_truc_versions()
self.cant_spend_multiple_unconfirmed_truc_outputs()
@cleanup
def tx_spends_unconfirmed_tx_with_wrong_version(self, version_a, version_b):
self.log.info(f"Test unavailable funds when v{version_b} tx spends unconfirmed v{version_a} tx")
outputs = {self.bob.getnewaddress() : 2.0}
self.send_tx(self.charlie, [], outputs, version_a)
assert_equal(self.bob.getbalances()["mine"]["trusted"], 0)
assert_greater_than(self.bob.getbalances()["mine"]["untrusted_pending"], 0)
outputs = {self.alice.getnewaddress() : 1.0}
raw_tx_v2 = self.bob.createrawtransaction(inputs=[], outputs=outputs, version=version_b)
assert_raises_rpc_error(
-4,
"Insufficient funds",
self.bob.fundrawtransaction,
raw_tx_v2, {'include_unsafe': True}
)
@cleanup
def va_tx_spends_confirmed_vb_tx(self, version_a, version_b):
self.log.info(f"Test available funds when v{version_b} tx spends confirmed v{version_a} tx")
outputs = {self.bob.getnewaddress() : 2.0}
self.send_tx(self.charlie, [], outputs, version_a)
assert_equal(self.bob.getbalances()["mine"]["trusted"], 0)
assert_greater_than(self.bob.getbalances()["mine"]["untrusted_pending"], 0)
outputs = {self.alice.getnewaddress() : 1.0}
self.generate(self.nodes[0], 1)
self.send_tx(self.bob, [], outputs, version_b)
@cleanup
def v3_utxos_appear_in_listunspent(self):
self.log.info("Test that unconfirmed v3 utxos still appear in listunspent")
outputs = {self.alice.getnewaddress() : 2.0}
parent_txid = self.send_tx(self.charlie, [], outputs, 3)
assert_equal(self.alice.listunspent(minconf=0)[0]["txid"], parent_txid)
@cleanup
def truc_tx_with_conflicting_sibling(self):
self.log.info("Test v3 transaction with conflicting sibling")
# unconfirmed v3 tx to alice & bob
outputs = {self.alice.getnewaddress() : 2.0, self.bob.getnewaddress() : 2.0}
self.send_tx(self.charlie, [], outputs, 3)
# alice spends her output with a v3 transaction
alice_unspent = self.alice.listunspent(minconf=0)[0]
outputs = {self.alice.getnewaddress() : alice_unspent['amount'] - Decimal(0.00000120)}
self.send_tx(self.alice, [alice_unspent], outputs, 3)
# bob tries to spend money
outputs = {self.bob.getnewaddress() : 1.999}
bob_tx = self.bob.createrawtransaction(inputs=[], outputs=outputs, version=3)
assert_raises_rpc_error(
-4,
"Insufficient funds",
self.bob.fundrawtransaction,
bob_tx, {'include_unsafe': True}
)
@cleanup
def truc_tx_with_conflicting_sibling_change(self):
self.log.info("Test v3 transaction with conflicting sibling change")
outputs = {self.alice.getnewaddress() : 8.0}
self.send_tx(self.charlie, [], outputs, 3)
self.generate(self.nodes[0], 1)
# unconfirmed v3 tx to alice & bob
outputs = {self.alice.getnewaddress() : 2.0, self.bob.getnewaddress() : 2.0}
self.send_tx(self.alice, [], outputs, 3)
# bob spends his output with a v3 transaction
bob_unspent = self.bob.listunspent(minconf=0)[0]
outputs = {self.bob.getnewaddress() : bob_unspent['amount'] - Decimal(0.00000120)}
self.send_tx(self.bob, [bob_unspent], outputs, 3)
# alice tries to spend money
outputs = {self.alice.getnewaddress() : 1.999}
alice_tx = self.alice.createrawtransaction(inputs=[], outputs=outputs, version=3)
assert_raises_rpc_error(
-4,
"Insufficient funds",
self.alice.fundrawtransaction,
alice_tx, {'include_unsafe': True}
)
@cleanup
def spend_inputs_with_different_versions(self, version_a, version_b):
self.log.info(f"Test spending a pre-selected v{version_a} input with a v{version_b} transaction")
outputs = {self.alice.getnewaddress() : 2.0}
self.send_tx(self.charlie, [], outputs, version_a)
# alice spends her output
alice_unspent = self.alice.listunspent(minconf=0)[0]
outputs = {self.alice.getnewaddress() : alice_unspent['amount'] - Decimal(0.00000120)}
alice_tx = self.alice.createrawtransaction(inputs=[alice_unspent], outputs=outputs, version=version_b)
assert_raises_rpc_error(
-4,
f"Can't spend unconfirmed version {version_a} pre-selected input with a version {version_b} tx",
self.alice.fundrawtransaction,
alice_tx
)
@cleanup
def spend_inputs_with_different_versions_default_version(self):
self.log.info("Test spending a pre-selected v3 input with the default version of transaction")
outputs = {self.alice.getnewaddress() : 2.0}
self.send_tx(self.charlie, [], outputs, 3)
# alice spends her output
alice_unspent = self.alice.listunspent(minconf=0)[0]
outputs = {self.alice.getnewaddress() : alice_unspent['amount'] - Decimal(0.00000120)}
alice_tx = self.alice.createrawtransaction(inputs=[alice_unspent], outputs=outputs) # don't set the version here
assert_raises_rpc_error(
-4,
"Can't spend unconfirmed version 3 pre-selected input with a version 2 tx",
self.alice.fundrawtransaction,
alice_tx
)
@cleanup
def v3_tx_evicted_from_mempool_by_sibling(self):
self.log.info("Test v3 transaction evicted because of conflicting sibling")
# unconfirmed v3 tx to alice & bob
outputs = {self.alice.getnewaddress() : 2.0, self.bob.getnewaddress() : 2.0}
self.send_tx(self.charlie, [], outputs, 3)
# alice spends her output with a v3 transaction
alice_unspent = self.alice.listunspent(minconf=0)[0]
alice_fee = Decimal(0.00000120)
outputs = {self.alice.getnewaddress() : alice_unspent['amount'] - alice_fee}
alice_txid = self.send_tx(self.alice, [alice_unspent], outputs, 3)
# bob tries to spend money
bob_unspent = self.bob.listunspent(minconf=0)[0]
outputs = {self.bob.getnewaddress() : bob_unspent['amount'] - Decimal(0.00010120)}
bob_txid = self.send_tx(self.bob, [bob_unspent], outputs, 3)
assert_equal(self.alice.gettransaction(alice_txid)['mempoolconflicts'], [bob_txid])
self.log.info("Test that re-submitting Alice's transaction with a higher fee removes bob's tx as a mempool conflict")
fee_delta = Decimal(0.00030120)
outputs = {self.alice.getnewaddress() : alice_unspent['amount'] - fee_delta}
alice_txid = self.send_tx(self.alice, [alice_unspent], outputs, 3)
assert_equal(self.alice.gettransaction(alice_txid)['mempoolconflicts'], [])
@cleanup
def v3_conflict_removed_from_mempool(self):
self.log.info("Test a v3 conflict being removed")
# send a v2 output to alice and confirm it
txid = self.charlie.sendall([self.alice.getnewaddress()])["txid"]
assert_equal(self.charlie.gettransaction(txid, verbose=True)["decoded"]["version"], 2)
self.generate(self.nodes[0], 1)
# create a v3 tx to alice and bob
outputs = {self.alice.getnewaddress() : 2.0, self.bob.getnewaddress() : 2.0}
self.send_tx(self.charlie, [], outputs, 3)
alice_v2_unspent = self.alice.listunspent(minconf=1)[0]
alice_unspent = self.alice.listunspent(minconf=0, maxconf=0)[0]
# alice spends both of her outputs
outputs = {self.charlie.getnewaddress() : alice_v2_unspent['amount'] + alice_unspent['amount'] - Decimal(0.00005120)}
self.send_tx(self.alice, [alice_v2_unspent, alice_unspent], outputs, 3)
# bob can't create a transaction
outputs = {self.bob.getnewaddress() : 1.999}
bob_tx = self.bob.createrawtransaction(inputs=[], outputs=outputs, version=3)
assert_raises_rpc_error(
-4,
"Insufficient funds",
self.bob.fundrawtransaction,
bob_tx, {'include_unsafe': True}
)
# alice fee-bumps her tx so it only spends the v2 utxo
outputs = {self.charlie.getnewaddress() : alice_v2_unspent['amount'] - Decimal(0.00015120)}
self.send_tx(self.alice, [alice_v2_unspent], outputs, 2)
# bob can now create a transaction
outputs = {self.bob.getnewaddress() : 1.999}
self.send_tx(self.bob, [], outputs, 3)
@cleanup
def mempool_conflicts_removed_when_v3_conflict_removed(self):
self.log.info("Test that we remove v3 txs from mempool_conflicts correctly")
# send a v2 output to alice and confirm it
txid = self.charlie.sendall([self.alice.getnewaddress()])["txid"]
assert_equal(self.charlie.gettransaction(txid, verbose=True)["decoded"]["version"], 2)
self.generate(self.nodes[0], 1)
# create a v3 tx to alice and bob
outputs = {self.alice.getnewaddress() : 2.0, self.bob.getnewaddress() : 2.0}
self.send_tx(self.charlie, [], outputs, 3)
alice_v2_unspent = self.alice.listunspent(minconf=1)[0]
alice_unspent = self.alice.listunspent(minconf=0, maxconf=0)[0]
# bob spends his utxo
inputs=[]
outputs = {self.bob.getnewaddress() : 1.999}
bob_txid = self.send_tx(self.bob, inputs, outputs, 3)
# alice spends both of her utxos, replacing bob's tx
outputs = {self.charlie.getnewaddress() : alice_v2_unspent['amount'] + alice_unspent['amount'] - Decimal(0.00005120)}
alice_txid = self.send_tx(self.alice, [alice_v2_unspent, alice_unspent], outputs, 3)
# bob's tx now has a mempool conflict
assert_equal(self.bob.gettransaction(bob_txid)['mempoolconflicts'], [alice_txid])
# alice fee-bumps her tx so it only spends the v2 utxo
outputs = {self.charlie.getnewaddress() : alice_v2_unspent['amount'] - Decimal(0.00015120)}
self.send_tx(self.alice, [alice_v2_unspent], outputs, 2)
# bob's tx now has non conflicts and can be rebroadcast
bob_tx = self.bob.gettransaction(bob_txid)
assert_equal(bob_tx['mempoolconflicts'], [])
self.bob.sendrawtransaction(bob_tx['hex'])
@cleanup
def max_tx_weight(self):
self.log.info("Test max v3 transaction weight.")
tx = CTransaction()
tx.version = 3 # make this a truc tx
# increase tx weight almost to the max truc size
self.bulk_tx(tx, 5, TRUC_MAX_VSIZE - 100)
assert_raises_rpc_error(
-4,
"The inputs size exceeds the maximum weight. Please try sending a smaller amount or manually consolidating your wallet's UTXOs",
self.charlie.fundrawtransaction,
tx.serialize_with_witness().hex(),
{'include_unsafe' : True}
)
tx.version = 2
self.charlie.fundrawtransaction(tx.serialize_with_witness().hex())
@cleanup
def max_tx_child_weight(self):
self.log.info("Test max v3 transaction child weight.")
outputs = {self.alice.getnewaddress() : 10}
self.send_tx(self.charlie, [], outputs, 3)
tx = CTransaction()
tx.version = 3
self.bulk_tx(tx, 5, TRUC_CHILD_MAX_VSIZE - 100)
assert_raises_rpc_error(
-4,
"The inputs size exceeds the maximum weight. Please try sending a smaller amount or manually consolidating your wallet's UTXOs",
self.alice.fundrawtransaction,
tx.serialize_with_witness().hex(),
{'include_unsafe' : True}
)
self.generate(self.nodes[0], 1)
self.alice.fundrawtransaction(tx.serialize_with_witness().hex())
@cleanup
def user_input_weight_not_overwritten(self):
self.log.info("Test that the user-input tx weight is not overwritten by the truc maximum")
tx = CTransaction()
tx.version = 3
self.bulk_tx(tx, 5, int(TRUC_MAX_VSIZE/2))
assert_raises_rpc_error(
-4,
"Maximum transaction weight is less than transaction weight without inputs",
self.charlie.fundrawtransaction,
tx.serialize_with_witness().hex(),
{'include_unsafe' : True, 'max_tx_weight' : int(TRUC_MAX_VSIZE/2)}
)
@cleanup
def user_input_weight_not_overwritten_v3_child(self):
self.log.info("Test that the user-input tx weight is not overwritten by the truc child maximum")
outputs = {self.alice.getnewaddress() : 10}
self.send_tx(self.charlie, [], outputs, 3)
tx = CTransaction()
tx.version = 3
self.bulk_tx(tx, 5, int(TRUC_CHILD_MAX_VSIZE/2))
assert_raises_rpc_error(
-4,
"Maximum transaction weight is less than transaction weight without inputs",
self.alice.fundrawtransaction,
tx.serialize_with_witness().hex(),
{'include_unsafe' : True, 'max_tx_weight' : int(TRUC_CHILD_MAX_VSIZE/2)}
)
self.generate(self.nodes[0], 1)
self.alice.fundrawtransaction(tx.serialize_with_witness().hex())
@cleanup
def createpsbt_v3(self):
self.log.info("Test setting version to 3 with createpsbt")
outputs = {self.alice.getnewaddress() : 10}
psbt = self.charlie.createpsbt(inputs=[], outputs=outputs, version=3)
assert_equal(self.charlie.decodepsbt(psbt)["tx"]["version"], 3)
@cleanup
def send_v3(self):
self.log.info("Test setting version to 3 with send")
outputs = {self.alice.getnewaddress() : 10}
tx_hex = self.charlie.send(outputs=outputs, add_to_wallet=False, version=3)["hex"]
assert_equal(self.charlie.decoderawtransaction(tx_hex)["version"], 3)
@cleanup
def sendall_v3(self):
self.log.info("Test setting version to 3 with sendall")
tx_hex = self.charlie.sendall(recipients=[self.alice.getnewaddress()], version=3, add_to_wallet=False)["hex"]
assert_equal(self.charlie.decoderawtransaction(tx_hex)["version"], 3)
@cleanup
def sendall_with_unconfirmed_v3(self):
self.log.info("Test setting version to 3 with sendall + unconfirmed inputs")
outputs = {self.alice.getnewaddress(): 2.00001 for _ in range(4)}
self.send_tx(self.charlie, [], outputs, 2)
self.generate(self.nodes[0], 1)
unspents = self.alice.listunspent()
# confirmed v2 utxos
outputs = {self.alice.getnewaddress() : 2.0}
confirmed_v2 = self.send_tx(self.alice, [unspents[0]], outputs, 2)
# confirmed v3 utxos
outputs = {self.alice.getnewaddress() : 2.0}
confirmed_v3 = self.send_tx(self.alice, [unspents[1]], outputs, 3)
self.generate(self.nodes[0], 1)
# unconfirmed v2 utxos
outputs = {self.alice.getnewaddress() : 2.0}
unconfirmed_v2 = self.send_tx(self.alice, [unspents[2]], outputs, 2)
# unconfirmed v3 utxos
outputs = {self.alice.getnewaddress() : 2.0}
unconfirmed_v3 = self.send_tx(self.alice, [unspents[3]], outputs, 3)
# Test that the only unconfirmed inputs this v3 tx spends are v3
tx_hex = self.alice.sendall([self.bob.getnewaddress()], version=3, add_to_wallet=False, minconf=0)["hex"]
decoded_tx = self.alice.decoderawtransaction(tx_hex)
decoded_vin_txids = [txin["txid"] for txin in decoded_tx["vin"]]
assert_equal(decoded_tx["version"], 3)
assert confirmed_v3 in decoded_vin_txids
assert confirmed_v2 in decoded_vin_txids
assert unconfirmed_v3 in decoded_vin_txids
assert unconfirmed_v2 not in decoded_vin_txids
# Test that the only unconfirmed inputs this v2 tx spends are v2
tx_hex = self.alice.sendall([self.bob.getnewaddress()], version=2, add_to_wallet=False, minconf=0)["hex"]
decoded_tx = self.alice.decoderawtransaction(tx_hex)
decoded_vin_txids = [txin["txid"] for txin in decoded_tx["vin"]]
assert_equal(decoded_tx["version"], 2)
assert confirmed_v3 in decoded_vin_txids
assert confirmed_v2 in decoded_vin_txids
assert unconfirmed_v2 in decoded_vin_txids
assert unconfirmed_v3 not in decoded_vin_txids
@cleanup
def walletcreatefundedpsbt_v3(self):
self.log.info("Test setting version to 3 with walletcreatefundedpsbt")
outputs = {self.alice.getnewaddress() : 10}
psbt = self.charlie.walletcreatefundedpsbt(inputs=[], outputs=outputs, version=3)["psbt"]
assert_equal(self.charlie.decodepsbt(psbt)["tx"]["version"], 3)
@cleanup
def sendall_truc_weight_limit(self):
self.log.info("Test that sendall follows truc tx weight limit")
self.charlie.sendall([self.alice.getnewaddress() for _ in range(300)], add_to_wallet=False, version=2)
# check that error is only raised if version is 3
assert_raises_rpc_error(
-4,
"Transaction too large" ,
self.charlie.sendall,
[self.alice.getnewaddress() for _ in range(300)],
version=3
)
@cleanup
def sendall_truc_child_weight_limit(self):
self.log.info("Test that sendall follows spending unconfirmed truc tx weight limit")
outputs = {self.charlie.getnewaddress() : 2.0}
self.send_tx(self.charlie, [], outputs, 3)
self.charlie.sendall([self.alice.getnewaddress() for _ in range(50)], add_to_wallet=False)
assert_raises_rpc_error(
-4,
"Transaction too large" ,
self.charlie.sendall,
[self.alice.getnewaddress() for _ in range(50)],
version=3
)
@cleanup
def mix_non_truc_versions(self):
self.log.info("Test that we can mix non-truc versions when spending an unconfirmed output")
outputs = {self.bob.getnewaddress() : 2.0}
self.send_tx(self.charlie, [], outputs, 1)
assert_equal(self.bob.getbalances()["mine"]["trusted"], 0)
assert_greater_than(self.bob.getbalances()["mine"]["untrusted_pending"], 0)
outputs = {self.alice.getnewaddress() : 1.0}
raw_tx_v2 = self.bob.createrawtransaction(inputs=[], outputs=outputs, version=2)
# does not throw an error
self.bob.fundrawtransaction(raw_tx_v2, {'include_unsafe': True})["hex"]
@cleanup
def cant_spend_multiple_unconfirmed_truc_outputs(self):
self.log.info("Test that we can't spend multiple unconfirmed truc outputs")
outputs = {self.alice.getnewaddress(): 2.00001}
self.send_tx(self.charlie, [], outputs, 3)
self.send_tx(self.charlie, [], outputs, 3)
assert_equal(len(self.alice.listunspent(minconf=0)), 2)
outputs = {self.bob.getnewaddress() : 3.0}
raw_tx = self.alice.createrawtransaction(inputs=[], outputs=outputs, version=3)
assert_raises_rpc_error(
-4,
"Insufficient funds",
self.alice.fundrawtransaction,
raw_tx,
{'include_unsafe' : True}
)
if __name__ == '__main__':
WalletV3Test(__file__).main()