Skip to content

Commit f0c4807

Browse files
committed
Merge bitcoin/bitcoin#26560: wallet: bugfix, invalid CoinsResult cached total amount
7362f8e refactor: make CoinsResult total amounts members private (furszy) 3282fad wallet: add assert to SelectionResult::Merge for safety (S3RK) c4e3b7d wallet: SelectCoins, return early if wallet's UTXOs cannot cover the target (furszy) cac2725 test: bugfix, coinselector_test, use 'CoinsResult::Erase/Add' instead of direct member access (furszy) cf79384 test: Coin Selection, duplicated preset inputs selection (furszy) 341ba7f test: wallet, coverage for CoinsResult::Erase function (furszy) f930aef wallet: bugfix, 'CoinsResult::Erase' is erasing only one output of the set (furszy) Pull request description: This comes with #26559. Solving few bugs inside the wallet's transaction creation process and adding test coverage for them. Plus, making use of the `CoinsResult::total_amount` cached value inside the Coin Selection process to return early if we don't have enough funds to cover the target amount. ### Bugs 1) The `CoinsResult::Erase` method removes only one output from the available coins vector (there is a [loop break](https://github.com/bitcoin/bitcoin/blob/c1061be14a515b0ed4f4d646fcd0378c62e6ded3/src/wallet/spend.cpp#L112) that should have never been there) and not all the preset inputs. Which on master is not a problem, because since [#25685](bitcoin/bitcoin#25685) we are no longer using the method. But, it's a bug on v24 (check [#26559](bitcoin/bitcoin#26559)). This method it's being fixed and not removed because I'm later using it to solve another bug inside this PR. 2) As we update the total cached amount of the `CoinsResult` object inside `AvailableCoins` and we don't use such function inside the coin selection tests (we manually load up the `CoinsResult` object), there is a discrepancy between the outputs that we add/erase and the total amount cached value. ### Improvements * This makes use of the `CoinsResult` total amount field to early return with an "Insufficient funds" error inside Coin Selection if the tx target amount is greater than the sum of all the wallet available coins plus the preset inputs amounts (we don't need to perform the entire coin selection process if we already know that there aren't enough funds inside our wallet). ### Test Coverage 1) Adds test coverage for the duplicated preset input selection bug that we have in v24. Where the wallet invalidly selects the preset inputs twice during the Coin Selection process. Which ends up with a "good" Coin Selection result that does not cover the total tx target amount. Which, alone, crashes the wallet due an insane fee. But.. to make it worst, adding the subtract fee from output functionality to this mix ends up with the wallet by-passing the "insane" fee assertion, decreasing the output amount to fulfill the insane fee, and.. sadly, broadcasting the tx to the network. 2) Adds test coverage for the `CoinsResult::Erase` method. ------------------------------------ TO DO: * [ ] Update [#26559 ](bitcoin/bitcoin#26559) description. ACKs for top commit: achow101: ACK 7362f8e glozow: ACK 7362f8e, I assume there will be a followup PR to add coin selection sanity checks and we can discuss the best way to do that there. josibake: ACK [7362f8e](bitcoin/bitcoin@7362f8e) Tree-SHA512: 37a6828ea10d8d36c8d5873ceede7c8bef72ae4c34bef21721fa9dad83ad6dba93711c3170a26ab6e05bdbc267bb17433da08ccb83b82956d05fb16090328cba
2 parents 38cbf43 + 7362f8e commit f0c4807

File tree

7 files changed

+181
-18
lines changed

7 files changed

+181
-18
lines changed

src/wallet/coinselection.cpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,12 +452,16 @@ void SelectionResult::AddInputs(const std::set<COutput>& inputs, bool subtract_f
452452

453453
void SelectionResult::Merge(const SelectionResult& other)
454454
{
455+
// Obtain the expected selected inputs count after the merge (for now, duplicates are not allowed)
456+
const size_t expected_count = m_selected_inputs.size() + other.m_selected_inputs.size();
457+
455458
m_target += other.m_target;
456459
m_use_effective |= other.m_use_effective;
457460
if (m_algo == SelectionAlgorithm::MANUAL) {
458461
m_algo = other.m_algo;
459462
}
460463
util::insert(m_selected_inputs, other.m_selected_inputs);
464+
assert(m_selected_inputs.size() == expected_count);
461465
}
462466

463467
const std::set<COutput>& SelectionResult::GetInputSet() const

src/wallet/coinselection.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ struct COutput {
110110
assert(effective_value.has_value());
111111
return effective_value.value();
112112
}
113+
114+
bool HasEffectiveValue() const { return effective_value.has_value(); }
113115
};
114116

115117
/** Parameters for one iteration of Coin Selection. */
@@ -314,6 +316,12 @@ struct SelectionResult
314316
void ComputeAndSetWaste(const CAmount min_viable_change, const CAmount change_cost, const CAmount change_fee);
315317
[[nodiscard]] CAmount GetWaste() const;
316318

319+
/**
320+
* Combines the @param[in] other selection result into 'this' selection result.
321+
*
322+
* Important note:
323+
* There must be no shared 'COutput' among the two selection results being combined.
324+
*/
317325
void Merge(const SelectionResult& other);
318326

319327
/** Get m_selected_inputs */

src/wallet/spend.cpp

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -102,15 +102,19 @@ void CoinsResult::Clear() {
102102
coins.clear();
103103
}
104104

105-
void CoinsResult::Erase(std::set<COutPoint>& preset_coins)
105+
void CoinsResult::Erase(const std::unordered_set<COutPoint, SaltedOutpointHasher>& coins_to_remove)
106106
{
107-
for (auto& it : coins) {
108-
auto& vec = it.second;
109-
auto i = std::find_if(vec.begin(), vec.end(), [&](const COutput &c) { return preset_coins.count(c.outpoint);});
110-
if (i != vec.end()) {
111-
vec.erase(i);
112-
break;
113-
}
107+
for (auto& [type, vec] : coins) {
108+
auto remove_it = std::remove_if(vec.begin(), vec.end(), [&](const COutput& coin) {
109+
// remove it if it's on the set
110+
if (coins_to_remove.count(coin.outpoint) == 0) return false;
111+
112+
// update cached amounts
113+
total_amount -= coin.txout.nValue;
114+
if (coin.HasEffectiveValue()) total_effective_amount = *total_effective_amount - coin.GetEffectiveValue();
115+
return true;
116+
});
117+
vec.erase(remove_it, vec.end());
114118
}
115119
}
116120

@@ -124,6 +128,11 @@ void CoinsResult::Shuffle(FastRandomContext& rng_fast)
124128
void CoinsResult::Add(OutputType type, const COutput& out)
125129
{
126130
coins[type].emplace_back(out);
131+
total_amount += out.txout.nValue;
132+
if (out.HasEffectiveValue()) {
133+
total_effective_amount = total_effective_amount.has_value() ?
134+
*total_effective_amount + out.GetEffectiveValue() : out.GetEffectiveValue();
135+
}
127136
}
128137

129138
static OutputType GetOutputType(TxoutType type, bool is_from_p2sh)
@@ -321,11 +330,9 @@ CoinsResult AvailableCoins(const CWallet& wallet,
321330
result.Add(GetOutputType(type, is_from_p2sh),
322331
COutput(outpoint, output, nDepth, input_bytes, spendable, solvable, safeTx, wtx.GetTxTime(), tx_from_me, feerate));
323332

324-
// Cache total amount as we go
325-
result.total_amount += output.nValue;
326333
// Checks the sum amount of all UTXO's.
327334
if (params.min_sum_amount != MAX_MONEY) {
328-
if (result.total_amount >= params.min_sum_amount) {
335+
if (result.GetTotalAmount() >= params.min_sum_amount) {
329336
return result;
330337
}
331338
}
@@ -349,7 +356,7 @@ CoinsResult AvailableCoinsListUnspent(const CWallet& wallet, const CCoinControl*
349356
CAmount GetAvailableBalance(const CWallet& wallet, const CCoinControl* coinControl)
350357
{
351358
LOCK(wallet.cs_wallet);
352-
return AvailableCoins(wallet, coinControl).total_amount;
359+
return AvailableCoins(wallet, coinControl).GetTotalAmount();
353360
}
354361

355362
const CTxOut& FindNonChangeParentOutput(const CWallet& wallet, const CTransaction& tx, int output)
@@ -577,6 +584,14 @@ std::optional<SelectionResult> SelectCoins(const CWallet& wallet, CoinsResult& a
577584
return result;
578585
}
579586

587+
// Return early if we cannot cover the target with the wallet's UTXO.
588+
// We use the total effective value if we are not subtracting fee from outputs and 'available_coins' contains the data.
589+
CAmount available_coins_total_amount = coin_selection_params.m_subtract_fee_outputs ? available_coins.GetTotalAmount() :
590+
(available_coins.GetEffectiveTotalAmount().has_value() ? *available_coins.GetEffectiveTotalAmount() : 0);
591+
if (selection_target > available_coins_total_amount) {
592+
return std::nullopt; // Insufficient funds
593+
}
594+
580595
// Start wallet Coin Selection procedure
581596
auto op_selection_result = AutomaticCoinSelection(wallet, available_coins, selection_target, coin_control, coin_selection_params);
582597
if (!op_selection_result) return op_selection_result;

src/wallet/spend.h

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,18 @@ struct CoinsResult {
4747
* i.e., methods can work with individual OutputType vectors or on the entire object */
4848
size_t Size() const;
4949
void Clear();
50-
void Erase(std::set<COutPoint>& preset_coins);
50+
void Erase(const std::unordered_set<COutPoint, SaltedOutpointHasher>& coins_to_remove);
5151
void Shuffle(FastRandomContext& rng_fast);
5252
void Add(OutputType type, const COutput& out);
5353

54-
/** Sum of all available coins */
54+
CAmount GetTotalAmount() { return total_amount; }
55+
std::optional<CAmount> GetEffectiveTotalAmount() {return total_effective_amount; }
56+
57+
private:
58+
/** Sum of all available coins raw value */
5559
CAmount total_amount{0};
60+
/** Sum of all available coins effective value (each output value minus fees required to spend it) */
61+
std::optional<CAmount> total_effective_amount{0};
5662
};
5763

5864
struct CoinFilterParams {

src/wallet/test/coinselector_tests.cpp

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ static void add_coin(CoinsResult& available_coins, CWallet& wallet, const CAmoun
8383
assert(ret.second);
8484
CWalletTx& wtx = (*ret.first).second;
8585
const auto& txout = wtx.tx->vout.at(nInput);
86-
available_coins.coins[OutputType::BECH32].emplace_back(COutPoint(wtx.GetHash(), nInput), txout, nAge, CalculateMaximumSignedInputSize(txout, &wallet, /*coin_control=*/nullptr), /*spendable=*/ true, /*solvable=*/ true, /*safe=*/ true, wtx.GetTxTime(), fIsFromMe, feerate);
86+
available_coins.Add(OutputType::BECH32, {COutPoint(wtx.GetHash(), nInput), txout, nAge, CalculateMaximumSignedInputSize(txout, &wallet, /*coin_control=*/nullptr), /*spendable=*/ true, /*solvable=*/ true, /*safe=*/ true, wtx.GetTxTime(), fIsFromMe, feerate});
8787
}
8888

8989
/** Check if SelectionResult a is equivalent to SelectionResult b.
@@ -342,7 +342,7 @@ BOOST_AUTO_TEST_CASE(bnb_search_test)
342342
coin_control.Select(select_coin.outpoint);
343343
PreSelectedInputs selected_input;
344344
selected_input.Insert(select_coin, coin_selection_params_bnb.m_subtract_fee_outputs);
345-
available_coins.coins[OutputType::BECH32].erase(available_coins.coins[OutputType::BECH32].begin());
345+
available_coins.Erase({available_coins.coins[OutputType::BECH32].begin()->outpoint});
346346
coin_selection_params_bnb.m_effective_feerate = CFeeRate(0);
347347
const auto result10 = SelectCoins(*wallet, available_coins, selected_input, 10 * CENT, coin_control, coin_selection_params_bnb);
348348
BOOST_CHECK(result10);
@@ -402,7 +402,7 @@ BOOST_AUTO_TEST_CASE(bnb_search_test)
402402
coin_control.Select(select_coin.outpoint);
403403
PreSelectedInputs selected_input;
404404
selected_input.Insert(select_coin, coin_selection_params_bnb.m_subtract_fee_outputs);
405-
available_coins.coins[OutputType::BECH32].erase(++available_coins.coins[OutputType::BECH32].begin());
405+
available_coins.Erase({(++available_coins.coins[OutputType::BECH32].begin())->outpoint});
406406
const auto result13 = SelectCoins(*wallet, available_coins, selected_input, 10 * CENT, coin_control, coin_selection_params_bnb);
407407
BOOST_CHECK(EquivalentResult(expected_result, *result13));
408408
}
@@ -974,11 +974,51 @@ BOOST_AUTO_TEST_CASE(SelectCoins_effective_value_test)
974974
cc.SelectExternal(output.outpoint, output.txout);
975975

976976
const auto preset_inputs = *Assert(FetchSelectedInputs(*wallet, cc, cs_params));
977-
available_coins.coins[OutputType::BECH32].erase(available_coins.coins[OutputType::BECH32].begin());
977+
available_coins.Erase({available_coins.coins[OutputType::BECH32].begin()->outpoint});
978978

979979
const auto result = SelectCoins(*wallet, available_coins, preset_inputs, target, cc, cs_params);
980980
BOOST_CHECK(!result);
981981
}
982982

983+
BOOST_FIXTURE_TEST_CASE(wallet_coinsresult_test, BasicTestingSetup)
984+
{
985+
// Test case to verify CoinsResult object sanity.
986+
CoinsResult available_coins;
987+
{
988+
std::unique_ptr<CWallet> dummyWallet = std::make_unique<CWallet>(m_node.chain.get(), "dummy", m_args, CreateMockWalletDatabase());
989+
BOOST_CHECK_EQUAL(dummyWallet->LoadWallet(), DBErrors::LOAD_OK);
990+
LOCK(dummyWallet->cs_wallet);
991+
dummyWallet->SetWalletFlag(WALLET_FLAG_DESCRIPTORS);
992+
dummyWallet->SetupDescriptorScriptPubKeyMans();
993+
994+
// Add some coins to 'available_coins'
995+
for (int i=0; i<10; i++) {
996+
add_coin(available_coins, *dummyWallet, 1 * COIN);
997+
}
998+
}
999+
1000+
{
1001+
// First test case, check that 'CoinsResult::Erase' function works as expected.
1002+
// By trying to erase two elements from the 'available_coins' object.
1003+
std::unordered_set<COutPoint, SaltedOutpointHasher> outs_to_remove;
1004+
const auto& coins = available_coins.All();
1005+
for (int i = 0; i < 2; i++) {
1006+
outs_to_remove.emplace(coins[i].outpoint);
1007+
}
1008+
available_coins.Erase(outs_to_remove);
1009+
1010+
// Check that the elements were actually removed.
1011+
const auto& updated_coins = available_coins.All();
1012+
for (const auto& out: outs_to_remove) {
1013+
auto it = std::find_if(updated_coins.begin(), updated_coins.end(), [&out](const COutput &coin) {
1014+
return coin.outpoint == out;
1015+
});
1016+
BOOST_CHECK(it == updated_coins.end());
1017+
}
1018+
// And verify that no extra element were removed
1019+
BOOST_CHECK_EQUAL(available_coins.Size(), 8);
1020+
}
1021+
}
1022+
9831023
BOOST_AUTO_TEST_SUITE_END()
9841024
} // namespace wallet

src/wallet/test/spend_tests.cpp

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,5 +112,50 @@ BOOST_FIXTURE_TEST_CASE(FillInputToWeightTest, BasicTestingSetup)
112112
// Note: We don't test the next boundary because of memory allocation constraints.
113113
}
114114

115+
BOOST_FIXTURE_TEST_CASE(wallet_duplicated_preset_inputs_test, TestChain100Setup)
116+
{
117+
// Verify that the wallet's Coin Selection process does not include pre-selected inputs twice in a transaction.
118+
119+
// Add 4 spendable UTXO, 50 BTC each, to the wallet (total balance 200 BTC)
120+
for (int i = 0; i < 4; i++) CreateAndProcessBlock({}, GetScriptForRawPubKey(coinbaseKey.GetPubKey()));
121+
auto wallet = CreateSyncedWallet(*m_node.chain, WITH_LOCK(Assert(m_node.chainman)->GetMutex(), return m_node.chainman->ActiveChain()), m_args, coinbaseKey);
122+
123+
LOCK(wallet->cs_wallet);
124+
auto available_coins = AvailableCoins(*wallet);
125+
std::vector<COutput> coins = available_coins.All();
126+
// Preselect the first 3 UTXO (150 BTC total)
127+
std::set<COutPoint> preset_inputs = {coins[0].outpoint, coins[1].outpoint, coins[2].outpoint};
128+
129+
// Try to create a tx that spends more than what preset inputs + wallet selected inputs are covering for.
130+
// The wallet can cover up to 200 BTC, and the tx target is 299 BTC.
131+
std::vector<CRecipient> recipients = {{GetScriptForDestination(*Assert(wallet->GetNewDestination(OutputType::BECH32, "dummy"))),
132+
/*nAmount=*/299 * COIN, /*fSubtractFeeFromAmount=*/true}};
133+
CCoinControl coin_control;
134+
coin_control.m_allow_other_inputs = true;
135+
for (const auto& outpoint : preset_inputs) {
136+
coin_control.Select(outpoint);
137+
}
138+
139+
// Attempt to send 299 BTC from a wallet that only has 200 BTC. The wallet should exclude
140+
// the preset inputs from the pool of available coins, realize that there is not enough
141+
// money to fund the 299 BTC payment, and fail with "Insufficient funds".
142+
//
143+
// Even with SFFO, the wallet can only afford to send 200 BTC.
144+
// If the wallet does not properly exclude preset inputs from the pool of available coins
145+
// prior to coin selection, it may create a transaction that does not fund the full payment
146+
// amount or, through SFFO, incorrectly reduce the recipient's amount by the difference
147+
// between the original target and the wrongly counted inputs (in this case 99 BTC)
148+
// so that the recipient's amount is no longer equal to the user's selected target of 299 BTC.
149+
150+
// First case, use 'subtract_fee_from_outputs=true'
151+
util::Result<CreatedTransactionResult> res_tx = CreateTransaction(*wallet, recipients, /*change_pos*/-1, coin_control);
152+
BOOST_CHECK(!res_tx.has_value());
153+
154+
// Second case, don't use 'subtract_fee_from_outputs'.
155+
recipients[0].fSubtractFeeFromAmount = false;
156+
res_tx = CreateTransaction(*wallet, recipients, /*change_pos*/-1, coin_control);
157+
BOOST_CHECK(!res_tx.has_value());
158+
}
159+
115160
BOOST_AUTO_TEST_SUITE_END()
116161
} // namespace wallet

test/functional/rpc_fundrawtransaction.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ def run_test(self):
110110
self.generate(self.nodes[0], 121)
111111

112112
self.test_add_inputs_default_value()
113+
self.test_preset_inputs_selection()
113114
self.test_weight_calculation()
114115
self.test_change_position()
115116
self.test_simple()
@@ -1203,6 +1204,50 @@ def test_add_inputs_default_value(self):
12031204

12041205
self.nodes[2].unloadwallet("test_preset_inputs")
12051206

1207+
def test_preset_inputs_selection(self):
1208+
self.log.info('Test wallet preset inputs are not double-counted or reused in coin selection')
1209+
1210+
# Create and fund the wallet with 4 UTXO of 5 BTC each (20 BTC total)
1211+
self.nodes[2].createwallet("test_preset_inputs_selection")
1212+
wallet = self.nodes[2].get_wallet_rpc("test_preset_inputs_selection")
1213+
outputs = {}
1214+
for _ in range(4):
1215+
outputs[wallet.getnewaddress(address_type="bech32")] = 5
1216+
self.nodes[0].sendmany("", outputs)
1217+
self.generate(self.nodes[0], 1)
1218+
1219+
# Select the preset inputs
1220+
coins = wallet.listunspent()
1221+
preset_inputs = [coins[0], coins[1], coins[2]]
1222+
1223+
# Now let's create the tx creation options
1224+
options = {
1225+
"inputs": preset_inputs,
1226+
"add_inputs": True, # automatically add coins from the wallet to fulfill the target
1227+
"subtract_fee_from_outputs": [0], # deduct fee from first output
1228+
"add_to_wallet": False
1229+
}
1230+
1231+
# Attempt to send 29 BTC from a wallet that only has 20 BTC. The wallet should exclude
1232+
# the preset inputs from the pool of available coins, realize that there is not enough
1233+
# money to fund the 29 BTC payment, and fail with "Insufficient funds".
1234+
#
1235+
# Even with SFFO, the wallet can only afford to send 20 BTC.
1236+
# If the wallet does not properly exclude preset inputs from the pool of available coins
1237+
# prior to coin selection, it may create a transaction that does not fund the full payment
1238+
# amount or, through SFFO, incorrectly reduce the recipient's amount by the difference
1239+
# between the original target and the wrongly counted inputs (in this case 9 BTC)
1240+
# so that the recipient's amount is no longer equal to the user's selected target of 29 BTC.
1241+
1242+
# First case, use 'subtract_fee_from_outputs = true'
1243+
assert_raises_rpc_error(-4, "Insufficient funds", wallet.send, outputs=[{wallet.getnewaddress(address_type="bech32"): 29}], options=options)
1244+
1245+
# Second case, don't use 'subtract_fee_from_outputs'
1246+
del options["subtract_fee_from_outputs"]
1247+
assert_raises_rpc_error(-4, "Insufficient funds", wallet.send, outputs=[{wallet.getnewaddress(address_type="bech32"): 29}], options=options)
1248+
1249+
self.nodes[2].unloadwallet("test_preset_inputs_selection")
1250+
12061251
def test_weight_calculation(self):
12071252
self.log.info("Test weight calculation with external inputs")
12081253

0 commit comments

Comments
 (0)