Skip to content

Commit c831e10

Browse files
committed
Merge #14582: wallet: always do avoid partial spends if fees are within a specified range
7f13dfb test: test the implicit avoid partial spends functionality (Karl-Johan Alm) b82067b wallet: try -avoidpartialspends mode and use its result if fees are below threshold (Karl-Johan Alm) Pull request description: The `-avoidpartialspends` feature is normally disabled, as it may affect the optimal fee for payments. This PR introduces a new parameter `-maxapsfee` (max avoid partial spends fee) which acts on the following values: * -1: disable partial spend avoidance completely (do not even try it) * 0: only do partial spend avoidance if fees are the same or better as the regular coin selection * 1..∞: use APS variant if the absolute fee difference is less than or equal to the max APS fee For values other than -1, the code will now try partial spend avoidance once, and if that gives a value within the accepted range, it will use that. Example: -maxapsfee=0.00001000 means the wallet will do regular coin select, APS coin select, and then pick AKS iff the absolute fee difference is <= 1000 satoshi. Edit: updated this to reflect the fact we are now using a max fee. ACKs for top commit: fjahr: tested ACK 7f13dfb achow101: ACK 7f13dfb jonatack: ACK 7f13dfb, code review, debug build, verified the test fails with `AssertionError: not(2 == 1)` for the number of vouts when `-maxapsfee=0.0001` is changed to 0, and verified the new logging with an added assertion. meshcollider: Code review ACK 7f13dfb Tree-SHA512: 475929df57f6191bb4e36bfbcad5a280a64bb0ecd8767b76cb2e44e2301235d0eb294a3f2fac5bbf15d35d7ecfba47acb2285feadb883c9ce31c08377e3afb3c
2 parents ffad348 + 7f13dfb commit c831e10

File tree

5 files changed

+119
-3
lines changed

5 files changed

+119
-3
lines changed

src/dummywallet.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ void DummyWalletInit::AddWalletOptions(ArgsManager& argsman) const
3535
"-discardfee=<amt>",
3636
"-fallbackfee=<amt>",
3737
"-keypool=<n>",
38+
"-maxapsfee=<n>",
3839
"-maxtxfee=<amt>",
3940
"-mintxfee=<amt>",
4041
"-paytxfee=<amt>",

src/wallet/init.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ void WalletInit::AddWalletOptions(ArgsManager& argsman) const
4949
argsman.AddArg("-fallbackfee=<amt>", strprintf("A fee rate (in %s/kB) that will be used when fee estimation has insufficient data. 0 to entirely disable the fallbackfee feature. (default: %s)",
5050
CURRENCY_UNIT, FormatMoney(DEFAULT_FALLBACK_FEE)), ArgsManager::ALLOW_ANY, OptionsCategory::WALLET);
5151
argsman.AddArg("-keypool=<n>", strprintf("Set key pool size to <n> (default: %u). Warning: Smaller sizes may increase the risk of losing funds when restoring from an old backup, if none of the addresses in the original keypool have been used.", DEFAULT_KEYPOOL_SIZE), ArgsManager::ALLOW_ANY, OptionsCategory::WALLET);
52+
argsman.AddArg("-maxapsfee=<n>", strprintf("Spend up to this amount in additional (absolute) fees (in %s) if it allows the use of partial spend avoidance (default: %s)", CURRENCY_UNIT, FormatMoney(DEFAULT_MAX_AVOIDPARTIALSPEND_FEE)), ArgsManager::ALLOW_ANY, OptionsCategory::WALLET);
5253
argsman.AddArg("-maxtxfee=<amt>", strprintf("Maximum total fees (in %s) to use in a single wallet transaction; setting this too low may abort large transactions (default: %s)",
5354
CURRENCY_UNIT, FormatMoney(DEFAULT_TRANSACTION_MAXFEE)), ArgsManager::ALLOW_ANY, OptionsCategory::DEBUG_TEST);
5455
argsman.AddArg("-mintxfee=<amt>", strprintf("Fees (in %s/kB) smaller than this are considered zero fee for transaction creation (default: %s)",

src/wallet/wallet.cpp

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2677,7 +2677,14 @@ OutputType CWallet::TransactionChangeType(const Optional<OutputType>& change_typ
26772677
return m_default_address_type;
26782678
}
26792679

2680-
bool CWallet::CreateTransaction(const std::vector<CRecipient>& vecSend, CTransactionRef& tx, CAmount& nFeeRet, int& nChangePosInOut, bilingual_str& error, const CCoinControl& coin_control, bool sign)
2680+
bool CWallet::CreateTransactionInternal(
2681+
const std::vector<CRecipient>& vecSend,
2682+
CTransactionRef& tx,
2683+
CAmount& nFeeRet,
2684+
int& nChangePosInOut,
2685+
bilingual_str& error,
2686+
const CCoinControl& coin_control,
2687+
bool sign)
26812688
{
26822689
CAmount nValue = 0;
26832690
const OutputType change_type = TransactionChangeType(coin_control.m_change_type ? *coin_control.m_change_type : m_default_change_type, vecSend);
@@ -3032,6 +3039,39 @@ bool CWallet::CreateTransaction(const std::vector<CRecipient>& vecSend, CTransac
30323039
return true;
30333040
}
30343041

3042+
bool CWallet::CreateTransaction(
3043+
const std::vector<CRecipient>& vecSend,
3044+
CTransactionRef& tx,
3045+
CAmount& nFeeRet,
3046+
int& nChangePosInOut,
3047+
bilingual_str& error,
3048+
const CCoinControl& coin_control,
3049+
bool sign)
3050+
{
3051+
int nChangePosIn = nChangePosInOut;
3052+
CTransactionRef tx2 = tx;
3053+
bool res = CreateTransactionInternal(vecSend, tx, nFeeRet, nChangePosInOut, error, coin_control, sign);
3054+
// try with avoidpartialspends unless it's enabled already
3055+
if (res && nFeeRet > 0 /* 0 means non-functional fee rate estimation */ && m_max_aps_fee > -1 && !coin_control.m_avoid_partial_spends) {
3056+
CCoinControl tmp_cc = coin_control;
3057+
tmp_cc.m_avoid_partial_spends = true;
3058+
CAmount nFeeRet2;
3059+
int nChangePosInOut2 = nChangePosIn;
3060+
bilingual_str error2; // fired and forgotten; if an error occurs, we discard the results
3061+
if (CreateTransactionInternal(vecSend, tx2, nFeeRet2, nChangePosInOut2, error2, tmp_cc, sign)) {
3062+
// if fee of this alternative one is within the range of the max fee, we use this one
3063+
const bool use_aps = nFeeRet2 <= nFeeRet + m_max_aps_fee;
3064+
WalletLogPrintf("Fee non-grouped = %lld, grouped = %lld, using %s\n", nFeeRet, nFeeRet2, use_aps ? "grouped" : "non-grouped");
3065+
if (use_aps) {
3066+
tx = tx2;
3067+
nFeeRet = nFeeRet2;
3068+
nChangePosInOut = nChangePosInOut2;
3069+
}
3070+
}
3071+
}
3072+
return res;
3073+
}
3074+
30353075
void CWallet::CommitTransaction(CTransactionRef tx, mapValue_t mapValue, std::vector<std::pair<std::string, std::string>> orderForm)
30363076
{
30373077
LOCK(cs_wallet);
@@ -3849,6 +3889,21 @@ std::shared_ptr<CWallet> CWallet::CreateWalletFromFile(interfaces::Chain& chain,
38493889
walletInstance->m_min_fee = CFeeRate(n);
38503890
}
38513891

3892+
if (gArgs.IsArgSet("-maxapsfee")) {
3893+
CAmount n = 0;
3894+
if (gArgs.GetArg("-maxapsfee", "") == "-1") {
3895+
n = -1;
3896+
} else if (!ParseMoney(gArgs.GetArg("-maxapsfee", ""), n)) {
3897+
error = AmountErrMsg("maxapsfee", gArgs.GetArg("-maxapsfee", ""));
3898+
return nullptr;
3899+
}
3900+
if (n > HIGH_APS_FEE) {
3901+
warnings.push_back(AmountHighWarn("-maxapsfee") + Untranslated(" ") +
3902+
_("This is the maximum transaction fee you pay to prioritize partial spend avoidance over regular coin selection."));
3903+
}
3904+
walletInstance->m_max_aps_fee = n;
3905+
}
3906+
38523907
if (gArgs.IsArgSet("-fallbackfee")) {
38533908
CAmount nFeePerK = 0;
38543909
if (!ParseMoney(gArgs.GetArg("-fallbackfee", ""), nFeePerK)) {

src/wallet/wallet.h

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,16 @@ static const CAmount DEFAULT_FALLBACK_FEE = 0;
7272
static const CAmount DEFAULT_DISCARD_FEE = 10000;
7373
//! -mintxfee default
7474
static const CAmount DEFAULT_TRANSACTION_MINFEE = 1000;
75+
/**
76+
* maximum fee increase allowed to do partial spend avoidance, even for nodes with this feature disabled by default
77+
*
78+
* A value of -1 disables this feature completely.
79+
* A value of 0 (current default) means to attempt to do partial spend avoidance, and use its results if the fees remain *unchanged*
80+
* A value > 0 means to do partial spend avoidance if the fee difference against a regular coin selection instance is in the range [0..value].
81+
*/
82+
static const CAmount DEFAULT_MAX_AVOIDPARTIALSPEND_FEE = 0;
83+
//! discourage APS fee higher than this amount
84+
constexpr CAmount HIGH_APS_FEE{COIN / 10000};
7585
//! minimum recommended increment for BIP 125 replacement txs
7686
static const CAmount WALLET_INCREMENTAL_RELAY_FEE = 5000;
7787
//! Default for -spendzeroconfchange
@@ -719,6 +729,8 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati
719729
// ScriptPubKeyMan::GetID. In many cases it will be the hash of an internal structure
720730
std::map<uint256, std::unique_ptr<ScriptPubKeyMan>> m_spk_managers;
721731

732+
bool CreateTransactionInternal(const std::vector<CRecipient>& vecSend, CTransactionRef& tx, CAmount& nFeeRet, int& nChangePosInOut, bilingual_str& error, const CCoinControl& coin_control, bool sign);
733+
722734
public:
723735
/*
724736
* Main wallet lock.
@@ -1008,6 +1020,7 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati
10081020
*/
10091021
CFeeRate m_fallback_fee{DEFAULT_FALLBACK_FEE};
10101022
CFeeRate m_discard_rate{DEFAULT_DISCARD_FEE};
1023+
CAmount m_max_aps_fee{DEFAULT_MAX_AVOIDPARTIALSPEND_FEE}; //!< note: this is absolute fee, not fee rate
10111024
OutputType m_default_address_type{DEFAULT_ADDRESS_TYPE};
10121025
/**
10131026
* Default output type for change outputs. When unset, automatically choose type

test/functional/wallet_groups.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
class WalletGroupTest(BitcoinTestFramework):
1616
def set_test_params(self):
1717
self.setup_clean_chain = True
18-
self.num_nodes = 3
19-
self.extra_args = [[], [], ['-avoidpartialspends']]
18+
self.num_nodes = 4
19+
self.extra_args = [[], [], ['-avoidpartialspends'], ["-maxapsfee=0.0001"]]
2020
self.rpc_timeout = 480
2121

2222
def skip_test_if_missing_module(self):
@@ -64,6 +64,52 @@ def run_test(self):
6464
assert_approx(v[0], 0.2)
6565
assert_approx(v[1], 1.3, 0.0001)
6666

67+
# Test 'avoid partial if warranted, even if disabled'
68+
self.sync_all()
69+
self.nodes[0].generate(1)
70+
# Nodes 1-2 now have confirmed UTXOs (letters denote destinations):
71+
# Node #1: Node #2:
72+
# - A 1.0 - D0 1.0
73+
# - B0 1.0 - D1 0.5
74+
# - B1 0.5 - E0 1.0
75+
# - C0 1.0 - E1 0.5
76+
# - C1 0.5 - F ~1.3
77+
# - D ~0.3
78+
assert_approx(self.nodes[1].getbalance(), 4.3, 0.0001)
79+
assert_approx(self.nodes[2].getbalance(), 4.3, 0.0001)
80+
# Sending 1.4 btc should pick one 1.0 + one more. For node #1,
81+
# this could be (A / B0 / C0) + (B1 / C1 / D). We ensure that it is
82+
# B0 + B1 or C0 + C1, because this avoids partial spends while not being
83+
# detrimental to transaction cost
84+
txid3 = self.nodes[1].sendtoaddress(self.nodes[0].getnewaddress(), 1.4)
85+
tx3 = self.nodes[1].getrawtransaction(txid3, True)
86+
# tx3 should have 2 inputs and 2 outputs
87+
assert_equal(2, len(tx3["vin"]))
88+
assert_equal(2, len(tx3["vout"]))
89+
# the accumulated value should be 1.5, so the outputs should be
90+
# ~0.1 and 1.4 and should come from the same destination
91+
values = [vout["value"] for vout in tx3["vout"]]
92+
values.sort()
93+
assert_approx(values[0], 0.1, 0.0001)
94+
assert_approx(values[1], 1.4)
95+
96+
input_txids = [vin["txid"] for vin in tx3["vin"]]
97+
input_addrs = [self.nodes[1].gettransaction(txid)['details'][0]['address'] for txid in input_txids]
98+
assert_equal(input_addrs[0], input_addrs[1])
99+
# Node 2 enforces avoidpartialspends so needs no checking here
100+
101+
# Test wallet option maxapsfee with Node 3
102+
addr_aps = self.nodes[3].getnewaddress()
103+
self.nodes[0].sendtoaddress(addr_aps, 1.0)
104+
self.nodes[0].sendtoaddress(addr_aps, 1.0)
105+
self.nodes[0].generate(1)
106+
txid4 = self.nodes[3].sendtoaddress(self.nodes[0].getnewaddress(), 0.1)
107+
tx4 = self.nodes[3].getrawtransaction(txid4, True)
108+
# tx4 should have 2 inputs and 2 outputs although one output would
109+
# have been enough and the transaction caused higher fees
110+
assert_equal(2, len(tx4["vin"]))
111+
assert_equal(2, len(tx4["vout"]))
112+
67113
# Empty out node2's wallet
68114
self.nodes[2].sendtoaddress(address=self.nodes[0].getnewaddress(), amount=self.nodes[2].getbalance(), subtractfeefromamount=True)
69115
self.sync_all()

0 commit comments

Comments
 (0)