[wallet, rpc]: add max_tx_weight to tx funding options

This allows a transaction's weight to be bound under a certain
weight if possible and desired. This can be beneficial for future
RBF attempts, or whenever a more restricted spend topology is
desired.

Co-authored-by: Greg Sanders <gsanders87@gmail.com>
This commit is contained in:
ismaelsadeeq 2024-02-29 12:43:52 +01:00
parent b6fc5043c1
commit 734076c6de
9 changed files with 119 additions and 17 deletions

View file

@ -146,6 +146,7 @@ static const CRPCConvertParam vRPCConvertParams[] =
{ "fundrawtransaction", 1, "conf_target"},
{ "fundrawtransaction", 1, "replaceable"},
{ "fundrawtransaction", 1, "solving_data"},
{ "fundrawtransaction", 1, "max_tx_weight"},
{ "fundrawtransaction", 2, "iswitness" },
{ "walletcreatefundedpsbt", 0, "inputs" },
{ "walletcreatefundedpsbt", 1, "outputs" },
@ -164,6 +165,7 @@ static const CRPCConvertParam vRPCConvertParams[] =
{ "walletcreatefundedpsbt", 3, "conf_target"},
{ "walletcreatefundedpsbt", 3, "replaceable"},
{ "walletcreatefundedpsbt", 3, "solving_data"},
{ "walletcreatefundedpsbt", 3, "max_tx_weight"},
{ "walletcreatefundedpsbt", 4, "bip32derivs" },
{ "walletprocesspsbt", 1, "sign" },
{ "walletprocesspsbt", 3, "bip32derivs" },
@ -208,6 +210,7 @@ static const CRPCConvertParam vRPCConvertParams[] =
{ "send", 4, "conf_target"},
{ "send", 4, "replaceable"},
{ "send", 4, "solving_data"},
{ "send", 4, "max_tx_weight"},
{ "sendall", 0, "recipients" },
{ "sendall", 1, "conf_target" },
{ "sendall", 3, "fee_rate"},

View file

@ -115,6 +115,8 @@ public:
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};
CCoinControl();

View file

@ -597,11 +597,12 @@ util::Result<SelectionResult> SelectCoinsSRD(const std::vector<OutputGroup>& utx
* nTargetValue, with indices corresponding to groups. If the ith
* entry is true, that means the ith group in groups was selected.
* param@[out] nBest Total amount of subset chosen that is closest to nTargetValue.
* paramp[in] max_selection_weight The maximum allowed weight for a selection result to be valid.
* param@[in] iterations Maximum number of tries.
*/
static void ApproximateBestSubset(FastRandomContext& insecure_rand, const std::vector<OutputGroup>& groups,
const CAmount& nTotalLower, const CAmount& nTargetValue,
std::vector<char>& vfBest, CAmount& nBest, int iterations = 1000)
std::vector<char>& vfBest, CAmount& nBest, int max_selection_weight, int iterations = 1000)
{
std::vector<char> vfIncluded;
@ -613,6 +614,7 @@ static void ApproximateBestSubset(FastRandomContext& insecure_rand, const std::v
{
vfIncluded.assign(groups.size(), false);
CAmount nTotal = 0;
int selected_coins_weight{0};
bool fReachedTarget = false;
for (int nPass = 0; nPass < 2 && !fReachedTarget; nPass++)
{
@ -627,9 +629,9 @@ static void ApproximateBestSubset(FastRandomContext& insecure_rand, const std::v
if (nPass == 0 ? insecure_rand.randbool() : !vfIncluded[i])
{
nTotal += groups[i].GetSelectionAmount();
selected_coins_weight += groups[i].m_weight;
vfIncluded[i] = true;
if (nTotal >= nTargetValue)
{
if (nTotal >= nTargetValue && selected_coins_weight <= max_selection_weight) {
fReachedTarget = true;
// If the total is between nTargetValue and nBest, it's our new best
// approximation.
@ -639,6 +641,7 @@ static void ApproximateBestSubset(FastRandomContext& insecure_rand, const std::v
vfBest = vfIncluded;
}
nTotal -= groups[i].GetSelectionAmount();
selected_coins_weight -= groups[i].m_weight;
vfIncluded[i] = false;
}
}
@ -652,6 +655,7 @@ util::Result<SelectionResult> KnapsackSolver(std::vector<OutputGroup>& groups, c
{
SelectionResult result(nTargetValue, SelectionAlgorithm::KNAPSACK);
bool max_weight_exceeded{false};
// List of values less than target
std::optional<OutputGroup> lowest_larger;
// Groups with selection amount smaller than the target and any change we might produce.
@ -662,6 +666,10 @@ util::Result<SelectionResult> KnapsackSolver(std::vector<OutputGroup>& groups, c
Shuffle(groups.begin(), groups.end(), rng);
for (const OutputGroup& group : groups) {
if (group.m_weight > max_selection_weight) {
max_weight_exceeded = true;
continue;
}
if (group.GetSelectionAmount() == nTargetValue) {
result.AddInput(group);
return result;
@ -677,11 +685,18 @@ util::Result<SelectionResult> KnapsackSolver(std::vector<OutputGroup>& groups, c
for (const auto& group : applicable_groups) {
result.AddInput(group);
}
return result;
if (result.GetWeight() <= max_selection_weight) return result;
else max_weight_exceeded = true;
// Try something else
result.Clear();
}
if (nTotalLower < nTargetValue) {
if (!lowest_larger) return util::Error();
if (!lowest_larger) {
if (max_weight_exceeded) return ErrorMaxWeightExceeded();
return util::Error();
}
result.AddInput(*lowest_larger);
return result;
}
@ -691,9 +706,9 @@ util::Result<SelectionResult> KnapsackSolver(std::vector<OutputGroup>& groups, c
std::vector<char> vfBest;
CAmount nBest;
ApproximateBestSubset(rng, applicable_groups, nTotalLower, nTargetValue, vfBest, nBest);
ApproximateBestSubset(rng, applicable_groups, nTotalLower, nTargetValue, vfBest, nBest, max_selection_weight);
if (nBest != nTargetValue && nTotalLower >= nTargetValue + change_target) {
ApproximateBestSubset(rng, applicable_groups, nTotalLower, nTargetValue + change_target, vfBest, nBest);
ApproximateBestSubset(rng, applicable_groups, nTotalLower, nTargetValue + change_target, vfBest, nBest, max_selection_weight);
}
// If we have a bigger coin and (either the stochastic approximation didn't find a good solution,
@ -728,7 +743,7 @@ util::Result<SelectionResult> KnapsackSolver(std::vector<OutputGroup>& groups, c
LogPrint(BCLog::SELECTCOINS, "%stotal %s\n", log_message, FormatMoney(nBest));
}
}
Assume(result.GetWeight() <= max_selection_weight);
return result;
}

View file

@ -174,10 +174,13 @@ struct CoinSelectionParams {
* 1) Received from other wallets, 2) replacing other txs, 3) that have been replaced.
*/
bool m_include_unsafe_inputs = false;
/** The maximum weight for this transaction. */
std::optional<int> m_max_tx_weight{std::nullopt};
CoinSelectionParams(FastRandomContext& rng_fast, int change_output_size, int change_spend_size,
CAmount min_change_target, CFeeRate effective_feerate,
CFeeRate long_term_feerate, CFeeRate discard_feerate, int tx_noinputs_size, bool avoid_partial)
CFeeRate long_term_feerate, CFeeRate discard_feerate, int tx_noinputs_size, bool avoid_partial,
std::optional<int> max_tx_weight = std::nullopt)
: rng_fast{rng_fast},
change_output_size(change_output_size),
change_spend_size(change_spend_size),
@ -186,7 +189,8 @@ struct CoinSelectionParams {
m_long_term_feerate(long_term_feerate),
m_discard_feerate(discard_feerate),
tx_noinputs_size(tx_noinputs_size),
m_avoid_partial_spends(avoid_partial)
m_avoid_partial_spends(avoid_partial),
m_max_tx_weight(max_tx_weight)
{
}
CoinSelectionParams(FastRandomContext& rng_fast)

View file

@ -542,6 +542,7 @@ CreatedTransactionResult FundTransaction(CWallet& wallet, const CMutableTransact
{"minconf", UniValueType(UniValue::VNUM)},
{"maxconf", UniValueType(UniValue::VNUM)},
{"input_weights", UniValueType(UniValue::VARR)},
{"max_tx_weight", UniValueType(UniValue::VNUM)},
},
true, true);
@ -701,6 +702,10 @@ CreatedTransactionResult FundTransaction(CWallet& wallet, const CMutableTransact
}
}
if (options.exists("max_tx_weight")) {
coinControl.m_max_tx_weight = options["max_tx_weight"].getInt<int>();
}
if (recipients.empty())
throw JSONRPCError(RPC_INVALID_PARAMETER, "TX must have at least one output");
@ -786,6 +791,8 @@ RPCHelpMan fundrawtransaction()
},
},
},
{"max_tx_weight", RPCArg::Type::NUM, RPCArg::Default{MAX_STANDARD_TX_WEIGHT}, "The maximum acceptable transaction weight.\n"
"Transaction building will fail if this can not be satisfied."},
},
FundTxDoc()),
RPCArgOptions{
@ -1240,6 +1247,8 @@ RPCHelpMan send()
{"vout_index", RPCArg::Type::NUM, RPCArg::Optional::OMITTED, "The zero-based output index, before a change output is added."},
},
},
{"max_tx_weight", RPCArg::Type::NUM, RPCArg::Default{MAX_STANDARD_TX_WEIGHT}, "The maximum acceptable transaction weight.\n"
"Transaction building will fail if this can not be satisfied."},
},
FundTxDoc()),
RPCArgOptions{.oneline_description="options"}},
@ -1287,6 +1296,9 @@ RPCHelpMan send()
// 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
@ -1697,6 +1709,8 @@ RPCHelpMan walletcreatefundedpsbt()
{"vout_index", RPCArg::Type::NUM, RPCArg::Optional::OMITTED, "The zero-based output index, before a change output is added."},
},
},
{"max_tx_weight", RPCArg::Type::NUM, RPCArg::Default{MAX_STANDARD_TX_WEIGHT}, "The maximum acceptable transaction weight.\n"
"Transaction building will fail if this can not be satisfied."},
},
FundTxDoc()),
RPCArgOptions{.oneline_description="options"}},

View file

@ -696,7 +696,12 @@ util::Result<SelectionResult> ChooseSelectionResult(interfaces::Chain& chain, co
};
// Maximum allowed weight for selected coins.
int max_selection_weight = MAX_STANDARD_TX_WEIGHT - (coin_selection_params.tx_noinputs_size * WITNESS_SCALE_FACTOR);
int max_transaction_weight = coin_selection_params.m_max_tx_weight.value_or(MAX_STANDARD_TX_WEIGHT);
int tx_weight_no_input = coin_selection_params.tx_noinputs_size * WITNESS_SCALE_FACTOR;
int max_selection_weight = max_transaction_weight - tx_weight_no_input;
if (max_selection_weight <= 0) {
return util::Error{_("Maximum transaction weight is less than transaction weight without inputs")};
}
// SFFO frequently causes issues in the context of changeless input sets: skip BnB when SFFO is active
if (!coin_selection_params.m_subtract_fee_outputs) {
@ -706,7 +711,11 @@ util::Result<SelectionResult> ChooseSelectionResult(interfaces::Chain& chain, co
}
// Deduct change weight because remaining Coin Selection algorithms can create change output
max_selection_weight -= (coin_selection_params.change_output_size * WITNESS_SCALE_FACTOR);
int change_outputs_weight = coin_selection_params.change_output_size * WITNESS_SCALE_FACTOR;
max_selection_weight -= change_outputs_weight;
if (max_selection_weight < 0 && results.empty()) {
return util::Error{_("Maximum transaction weight is too low, can not accommodate change output")};
}
// The knapsack solver has some legacy behavior where it will spend dust outputs. We retain this behavior, so don't filter for positive only here.
if (auto knapsack_result{KnapsackSolver(groups.mixed_group, nTargetValue, coin_selection_params.m_min_change_target, coin_selection_params.rng_fast, max_selection_weight)}) {
@ -801,7 +810,7 @@ util::Result<SelectionResult> SelectCoins(const CWallet& wallet, CoinsResult& av
coin_selection_params.m_change_fee);
// Verify we haven't exceeded the maximum allowed weight
int max_inputs_weight = MAX_STANDARD_TX_WEIGHT - (coin_selection_params.tx_noinputs_size * WITNESS_SCALE_FACTOR);
int max_inputs_weight = coin_selection_params.m_max_tx_weight.value_or(MAX_STANDARD_TX_WEIGHT) - (coin_selection_params.tx_noinputs_size * WITNESS_SCALE_FACTOR);
if (op_selection_result->GetWeight() > max_inputs_weight) {
return util::Error{_("The combination of the pre-selected inputs and the wallet automatic inputs selection exceeds the transaction maximum weight. "
"Please try sending a smaller amount or manually consolidating your wallet's UTXOs")};
@ -1002,7 +1011,11 @@ static util::Result<CreatedTransactionResult> CreateTransactionInternal(
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);
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)};
}
// Set the long term feerate estimate to the wallet's consolidate feerate
coin_selection_params.m_long_term_feerate = wallet.m_consolidate_feerate;

View file

@ -256,29 +256,34 @@ FUZZ_TARGET(coinselection)
(void)group.EligibleForSpending(filter);
}
int max_selection_weight = fuzzed_data_provider.ConsumeIntegralInRange<int>(0, std::numeric_limits<int>::max());
// Run coinselection algorithms
auto result_bnb = coin_params.m_subtract_fee_outputs ? util::Error{Untranslated("BnB disabled when SFFO is enabled")} :
SelectCoinsBnB(group_pos, target, coin_params.m_cost_of_change, MAX_STANDARD_TX_WEIGHT);
SelectCoinsBnB(group_pos, target, coin_params.m_cost_of_change, max_selection_weight);
if (result_bnb) {
assert(result_bnb->GetChange(coin_params.min_viable_change, coin_params.m_change_fee) == 0);
assert(result_bnb->GetSelectedValue() >= target);
assert(result_bnb->GetWeight() <= max_selection_weight);
(void)result_bnb->GetShuffledInputVector();
(void)result_bnb->GetInputSet();
}
auto result_srd = SelectCoinsSRD(group_pos, target, coin_params.m_change_fee, fast_random_context, MAX_STANDARD_TX_WEIGHT);
auto result_srd = SelectCoinsSRD(group_pos, target, coin_params.m_change_fee, fast_random_context, max_selection_weight);
if (result_srd) {
assert(result_srd->GetSelectedValue() >= target);
assert(result_srd->GetChange(CHANGE_LOWER, coin_params.m_change_fee) > 0); // Demonstrate that SRD creates change of at least CHANGE_LOWER
assert(result_srd->GetWeight() <= max_selection_weight);
result_srd->RecalculateWaste(coin_params.min_viable_change, coin_params.m_cost_of_change, coin_params.m_change_fee);
(void)result_srd->GetShuffledInputVector();
(void)result_srd->GetInputSet();
}
CAmount change_target{GenerateChangeTarget(target, coin_params.m_change_fee, fast_random_context)};
auto result_knapsack = KnapsackSolver(group_all, target, change_target, fast_random_context, MAX_STANDARD_TX_WEIGHT);
auto result_knapsack = KnapsackSolver(group_all, target, change_target, fast_random_context, max_selection_weight);
if (result_knapsack) {
assert(result_knapsack->GetSelectedValue() >= target);
assert(result_knapsack->GetWeight() <= max_selection_weight);
result_knapsack->RecalculateWaste(coin_params.min_viable_change, coin_params.m_cost_of_change, coin_params.m_change_fee);
(void)result_knapsack->GetShuffledInputVector();
(void)result_knapsack->GetInputSet();

View file

@ -8,6 +8,9 @@ from decimal import Decimal
from itertools import product
from random import randbytes
from test_framework.blocktools import (
MAX_STANDARD_TX_WEIGHT,
)
from test_framework.descriptors import descsum_create
from test_framework.key import H_POINT
from test_framework.messages import (
@ -16,6 +19,7 @@ from test_framework.messages import (
CTxIn,
CTxOut,
MAX_BIP125_RBF_SEQUENCE,
WITNESS_SCALE_FACTOR,
)
from test_framework.psbt import (
PSBT,
@ -30,6 +34,7 @@ from test_framework.psbt import (
PSBT_OUT_TAP_TREE,
)
from test_framework.script import CScript, OP_TRUE
from test_framework.script_util import MIN_STANDARD_TX_NONWITNESS_SIZE
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import (
assert_approx,
@ -186,6 +191,46 @@ class PSBTTest(BitcoinTestFramework):
# Create and fund a raw tx for sending 10 BTC
psbtx1 = self.nodes[0].walletcreatefundedpsbt([], {self.nodes[2].getnewaddress():10})['psbt']
self.log.info("Test for invalid maximum transaction weights")
dest_arg = [{self.nodes[0].getnewaddress(): 1}]
min_tx_weight = MIN_STANDARD_TX_NONWITNESS_SIZE * WITNESS_SCALE_FACTOR
assert_raises_rpc_error(-4, f"Maximum transaction weight must be between {min_tx_weight} and {MAX_STANDARD_TX_WEIGHT}", self.nodes[0].walletcreatefundedpsbt, [], dest_arg, 0, {"max_tx_weight": -1})
assert_raises_rpc_error(-4, f"Maximum transaction weight must be between {min_tx_weight} and {MAX_STANDARD_TX_WEIGHT}", self.nodes[0].walletcreatefundedpsbt, [], dest_arg, 0, {"max_tx_weight": 0})
assert_raises_rpc_error(-4, f"Maximum transaction weight must be between {min_tx_weight} and {MAX_STANDARD_TX_WEIGHT}", self.nodes[0].walletcreatefundedpsbt, [], dest_arg, 0, {"max_tx_weight": MAX_STANDARD_TX_WEIGHT + 1})
# Base transaction vsize: version (4) + locktime (4) + input count (1) + witness overhead (1) = 10 vbytes
base_tx_vsize = 10
# One P2WPKH output vsize: outpoint (31 vbytes)
p2wpkh_output_vsize = 31
# 1 vbyte for output count
output_count = 1
tx_weight_without_inputs = (base_tx_vsize + output_count + p2wpkh_output_vsize) * WITNESS_SCALE_FACTOR
# min_tx_weight is greater than transaction weight without inputs
assert_greater_than(min_tx_weight, tx_weight_without_inputs)
# In order to test for when the passed max weight is less than the transaction weight without inputs
# Define destination with two outputs.
dest_arg_large = [{self.nodes[0].getnewaddress(): 1}, {self.nodes[0].getnewaddress(): 1}]
large_tx_vsize_without_inputs = base_tx_vsize + output_count + (p2wpkh_output_vsize * 2)
large_tx_weight_without_inputs = large_tx_vsize_without_inputs * WITNESS_SCALE_FACTOR
assert_greater_than(large_tx_weight_without_inputs, min_tx_weight)
# Test for max_tx_weight less than Transaction weight without inputs
assert_raises_rpc_error(-4, "Maximum transaction weight is less than transaction weight without inputs", self.nodes[0].walletcreatefundedpsbt, [], dest_arg_large, 0, {"max_tx_weight": min_tx_weight})
assert_raises_rpc_error(-4, "Maximum transaction weight is less than transaction weight without inputs", self.nodes[0].walletcreatefundedpsbt, [], dest_arg_large, 0, {"max_tx_weight": large_tx_weight_without_inputs})
# Test for max_tx_weight just enough to include inputs but not change output
assert_raises_rpc_error(-4, "Maximum transaction weight is too low, can not accommodate change output", self.nodes[0].walletcreatefundedpsbt, [], dest_arg_large, 0, {"max_tx_weight": (large_tx_vsize_without_inputs + 1) * WITNESS_SCALE_FACTOR})
self.log.info("Test that a funded PSBT is always faithful to max_tx_weight option")
large_tx_vsize_with_change = large_tx_vsize_without_inputs + p2wpkh_output_vsize
# It's enough but won't accommodate selected input size
assert_raises_rpc_error(-4, "The inputs size exceeds the maximum weight", self.nodes[0].walletcreatefundedpsbt, [], dest_arg_large, 0, {"max_tx_weight": (large_tx_vsize_with_change) * WITNESS_SCALE_FACTOR})
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"]
# ensure the transaction's weight is below the specified max_tx_weight.
assert_greater_than_or_equal(max_tx_weight_sufficient, weight)
# If inputs are specified, do not automatically add more:
utxo1 = self.nodes[0].listunspent()[0]
assert_raises_rpc_error(-4, "The preselected coins total amount does not cover the transaction target. "

View file

@ -48,6 +48,7 @@ from .util import assert_equal
MAX_BLOCK_SIGOPS = 20000
MAX_BLOCK_SIGOPS_WEIGHT = MAX_BLOCK_SIGOPS * WITNESS_SCALE_FACTOR
MAX_STANDARD_TX_WEIGHT = 400000
# Genesis block time (regtest)
TIME_GENESIS_BLOCK = 1296688602