Skip to content

Commit bdda137

Browse files
committed
Merge #16766: wallet: Make IsTrusted scan parents recursively
4671fc3 Expand on wallet_balance.py comment from https://github.com/bitcoin/bitcoin/pull/16766\#issuecomment-527563982 (Jeremy Rubin) 91f3073 Update release notes to mention changes to IsTrusted and impact on wallet (Jeremy Rubin) 8f174ef Systematize style of IsTrusted single line if (Jeremy Rubin) b49dcbe update variable naming conventions for IsTrusted (Jeremy Rubin) 5ffe0d1 Update comment in test/functional/wallet_balance.py (Jeremy Rubin) a550c58 Update wallet_balance.py test to reflect new behavior (Jeremy Rubin) 5dd7da4 Reuse trustedParents in looped calls to IsTrusted (Jeremy Rubin) 595f09d Cache tx Trust per-call to avoid DoS (Jeremy Rubin) dce032c Make IsTrusted scan parents recursively (Jeremy Rubin) Pull request description: This slightly modifies the behavior of IsTrusted to recursively check the parents of a transaction. Otherwise, it's possible that a parent is not IsTrusted but a child is. If a parent is not trusted, then a child should not be either. This recursive scan can be a little expensive, so ~it might be beneficial to have a way of caching IsTrusted state, but this is a little complex because various conditions can change between calls to IsTrusted (e.g., re-org).~ I added a cache which works per call/across calls, but does not store the results semi-permanently. Which reduces DoS risk of this change. There is no risk of untrusted parents causing a resource exploitation, as we immediately return once that is detected. This is a change that came up as a bug-fix esque change while working on OP_SECURETHEBAG. You can see the branch where this change is important here: https://github.com/bitcoin/bitcoin/compare/master...JeremyRubin:stb-with-rpc?expand=1. Essentially, without this change, we can be tricked into accepting an OP_SECURETHEBAG output because we don't properly check the parents. As this was a change which, on its own, was not dependent on OP_SECURETHEBAG, I broke it out as I felt the change stands on its own by fixing a long standing wallet bug. The test wallet_balance.py has been corrected to meet the new behavior. The below comment, reproduced, explains what the issue is and the edge cases that can arise before this change. # Before `test_balance()`, we have had two nodes with a balance of 50 # each and then we: # # 1) Sent 40 from node A to node B with fee 0.01 # 2) Sent 60 from node B to node A with fee 0.01 # # Then we check the balances: # # 1) As is # 2) With transaction 2 from above with 2x the fee # # Prior to #16766, in this situation, the node would immediately report # a balance of 30 on node B as unconfirmed and trusted. # # After #16766, we show that balance as unconfirmed. # # The balance is indeed "trusted" and "confirmed" insofar as removing # the mempool transactions would return at least that much money. But # the algorithm after #16766 marks it as unconfirmed because the 'taint' # tracking of transaction trust for summing balances doesn't consider # which inputs belong to a user. In this case, the change output in # question could be "destroyed" by replace the 1st transaction above. # # The post #16766 behavior is correct; we shouldn't be treating those # funds as confirmed. If you want to rely on that specific UTXO existing # which has given you that balance, you cannot, as a third party # spending the other input would destroy that unconfirmed. # # For example, if the test transactions were: # # 1) Sent 40 from node A to node B with fee 0.01 # 2) Sent 10 from node B to node A with fee 0.01 # # Then our node would report a confirmed balance of 40 + 50 - 10 = 80 # BTC, which is more than would be available if transaction 1 were # replaced. The release notes have been updated to note the new behavior. ACKs for top commit: ariard: Code Review ACK 4671fc3, maybe extend DoS protection in a follow-up PR. fjahr: Code review ACK 4671fc3 ryanofsky: Code review ACK 4671fc3. Changes since last review: 2 new commits adding suggested release note and python test comment, also a clean rebase with no changes to the earlier commits. The PR description is more comprehensive now, too. Looks good! promag: Code review ACK 4671fc3. Tree-SHA512: 6b183ff425304fef49724290053514cb2770f4a2350dcb83660ef24af5c54f7c4c2c345b0f62bba60eb2d2f70625ee61a7fab76a7f491bb5a84be5c4cc86b92f
2 parents bfc4c89 + 4671fc3 commit bdda137

File tree

4 files changed

+71
-23
lines changed

4 files changed

+71
-23
lines changed

doc/release-notes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ Wallet
8585
------
8686

8787
- The wallet now by default uses bech32 addresses when using RPC, and creates native segwit change outputs.
88+
- The way that output trust was computed has been fixed in #16766, which impacts confirmed/unconfirmed balance status and coin selection.
8889

8990
Low-level changes
9091
=================

src/wallet/wallet.cpp

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1849,33 +1849,38 @@ bool CWalletTx::InMempool() const
18491849
}
18501850

18511851
bool CWalletTx::IsTrusted(interfaces::Chain::Lock& locked_chain) const
1852+
{
1853+
std::set<uint256> s;
1854+
return IsTrusted(locked_chain, s);
1855+
}
1856+
1857+
bool CWalletTx::IsTrusted(interfaces::Chain::Lock& locked_chain, std::set<uint256>& trusted_parents) const
18521858
{
18531859
// Quick answer in most cases
1854-
if (!locked_chain.checkFinalTx(*tx)) {
1855-
return false;
1856-
}
1860+
if (!locked_chain.checkFinalTx(*tx)) return false;
18571861
int nDepth = GetDepthInMainChain(locked_chain);
1858-
if (nDepth >= 1)
1859-
return true;
1860-
if (nDepth < 0)
1861-
return false;
1862-
if (!pwallet->m_spend_zero_conf_change || !IsFromMe(ISMINE_ALL)) // using wtx's cached debit
1863-
return false;
1862+
if (nDepth >= 1) return true;
1863+
if (nDepth < 0) return false;
1864+
// using wtx's cached debit
1865+
if (!pwallet->m_spend_zero_conf_change || !IsFromMe(ISMINE_ALL)) return false;
18641866

18651867
// Don't trust unconfirmed transactions from us unless they are in the mempool.
1866-
if (!InMempool())
1867-
return false;
1868+
if (!InMempool()) return false;
18681869

18691870
// Trusted if all inputs are from us and are in the mempool:
18701871
for (const CTxIn& txin : tx->vin)
18711872
{
18721873
// Transactions not sent by us: not trusted
18731874
const CWalletTx* parent = pwallet->GetWalletTx(txin.prevout.hash);
1874-
if (parent == nullptr)
1875-
return false;
1875+
if (parent == nullptr) return false;
18761876
const CTxOut& parentOut = parent->tx->vout[txin.prevout.n];
1877-
if (pwallet->IsMine(parentOut) != ISMINE_SPENDABLE)
1878-
return false;
1877+
// Check that this specific input being spent is trusted
1878+
if (pwallet->IsMine(parentOut) != ISMINE_SPENDABLE) return false;
1879+
// If we've already trusted this parent, continue
1880+
if (trusted_parents.count(parent->GetHash())) continue;
1881+
// Recurse to check that the parent is also trusted
1882+
if (!parent->IsTrusted(locked_chain, trusted_parents)) return false;
1883+
trusted_parents.insert(parent->GetHash());
18791884
}
18801885
return true;
18811886
}
@@ -1961,10 +1966,11 @@ CWallet::Balance CWallet::GetBalance(const int min_depth, bool avoid_reuse) cons
19611966
{
19621967
auto locked_chain = chain().lock();
19631968
LOCK(cs_wallet);
1969+
std::set<uint256> trusted_parents;
19641970
for (const auto& entry : mapWallet)
19651971
{
19661972
const CWalletTx& wtx = entry.second;
1967-
const bool is_trusted{wtx.IsTrusted(*locked_chain)};
1973+
const bool is_trusted{wtx.IsTrusted(*locked_chain, trusted_parents)};
19681974
const int tx_depth{wtx.GetDepthInMainChain(*locked_chain)};
19691975
const CAmount tx_credit_mine{wtx.GetAvailableCredit(*locked_chain, /* fUseCache */ true, ISMINE_SPENDABLE | reuse_filter)};
19701976
const CAmount tx_credit_watchonly{wtx.GetAvailableCredit(*locked_chain, /* fUseCache */ true, ISMINE_WATCH_ONLY | reuse_filter)};
@@ -2011,6 +2017,7 @@ void CWallet::AvailableCoins(interfaces::Chain::Lock& locked_chain, std::vector<
20112017
const int min_depth = {coinControl ? coinControl->m_min_depth : DEFAULT_MIN_DEPTH};
20122018
const int max_depth = {coinControl ? coinControl->m_max_depth : DEFAULT_MAX_DEPTH};
20132019

2020+
std::set<uint256> trusted_parents;
20142021
for (const auto& entry : mapWallet)
20152022
{
20162023
const uint256& wtxid = entry.first;
@@ -2032,7 +2039,7 @@ void CWallet::AvailableCoins(interfaces::Chain::Lock& locked_chain, std::vector<
20322039
if (nDepth == 0 && !wtx.InMempool())
20332040
continue;
20342041

2035-
bool safeTx = wtx.IsTrusted(locked_chain);
2042+
bool safeTx = wtx.IsTrusted(locked_chain, trusted_parents);
20362043

20372044
// We should not consider coins from transactions that are replacing
20382045
// other transactions.
@@ -3094,11 +3101,12 @@ std::map<CTxDestination, CAmount> CWallet::GetAddressBalances(interfaces::Chain:
30943101

30953102
{
30963103
LOCK(cs_wallet);
3104+
std::set<uint256> trusted_parents;
30973105
for (const auto& walletEntry : mapWallet)
30983106
{
30993107
const CWalletTx& wtx = walletEntry.second;
31003108

3101-
if (!wtx.IsTrusted(locked_chain))
3109+
if (!wtx.IsTrusted(locked_chain, trusted_parents))
31023110
continue;
31033111

31043112
if (wtx.IsImmatureCoinBase(locked_chain))

src/wallet/wallet.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,7 @@ class CWalletTx
476476

477477
bool InMempool() const;
478478
bool IsTrusted(interfaces::Chain::Lock& locked_chain) const;
479+
bool IsTrusted(interfaces::Chain::Lock& locked_chain, std::set<uint256>& trusted_parents) const;
479480

480481
int64_t GetTxTime() const;
481482

test/functional/wallet_balance.py

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,13 +109,51 @@ def run_test(self):
109109

110110
self.log.info("Test getbalance and getunconfirmedbalance with unconfirmed inputs")
111111

112+
# Before `test_balance()`, we have had two nodes with a balance of 50
113+
# each and then we:
114+
#
115+
# 1) Sent 40 from node A to node B with fee 0.01
116+
# 2) Sent 60 from node B to node A with fee 0.01
117+
#
118+
# Then we check the balances:
119+
#
120+
# 1) As is
121+
# 2) With transaction 2 from above with 2x the fee
122+
#
123+
# Prior to #16766, in this situation, the node would immediately report
124+
# a balance of 30 on node B as unconfirmed and trusted.
125+
#
126+
# After #16766, we show that balance as unconfirmed.
127+
#
128+
# The balance is indeed "trusted" and "confirmed" insofar as removing
129+
# the mempool transactions would return at least that much money. But
130+
# the algorithm after #16766 marks it as unconfirmed because the 'taint'
131+
# tracking of transaction trust for summing balances doesn't consider
132+
# which inputs belong to a user. In this case, the change output in
133+
# question could be "destroyed" by replace the 1st transaction above.
134+
#
135+
# The post #16766 behavior is correct; we shouldn't be treating those
136+
# funds as confirmed. If you want to rely on that specific UTXO existing
137+
# which has given you that balance, you cannot, as a third party
138+
# spending the other input would destroy that unconfirmed.
139+
#
140+
# For example, if the test transactions were:
141+
#
142+
# 1) Sent 40 from node A to node B with fee 0.01
143+
# 2) Sent 10 from node B to node A with fee 0.01
144+
#
145+
# Then our node would report a confirmed balance of 40 + 50 - 10 = 80
146+
# BTC, which is more than would be available if transaction 1 were
147+
# replaced.
148+
149+
112150
def test_balances(*, fee_node_1=0):
113151
# getbalance without any arguments includes unconfirmed transactions, but not untrusted transactions
114152
assert_equal(self.nodes[0].getbalance(), Decimal('9.99')) # change from node 0's send
115-
assert_equal(self.nodes[1].getbalance(), Decimal('30') - fee_node_1) # change from node 1's send
153+
assert_equal(self.nodes[1].getbalance(), Decimal('0')) # node 1's send had an unsafe input
116154
# Same with minconf=0
117155
assert_equal(self.nodes[0].getbalance(minconf=0), Decimal('9.99'))
118-
assert_equal(self.nodes[1].getbalance(minconf=0), Decimal('30') - fee_node_1)
156+
assert_equal(self.nodes[1].getbalance(minconf=0), Decimal('0'))
119157
# getbalance with a minconf incorrectly excludes coins that have been spent more recently than the minconf blocks ago
120158
# TODO: fix getbalance tracking of coin spentness depth
121159
assert_equal(self.nodes[0].getbalance(minconf=1), Decimal('0'))
@@ -125,9 +163,9 @@ def test_balances(*, fee_node_1=0):
125163
assert_equal(self.nodes[0].getbalances()['mine']['untrusted_pending'], Decimal('60'))
126164
assert_equal(self.nodes[0].getwalletinfo()["unconfirmed_balance"], Decimal('60'))
127165

128-
assert_equal(self.nodes[1].getunconfirmedbalance(), Decimal('0')) # Doesn't include output of node 0's send since it was spent
129-
assert_equal(self.nodes[1].getbalances()['mine']['untrusted_pending'], Decimal('0'))
130-
assert_equal(self.nodes[1].getwalletinfo()["unconfirmed_balance"], Decimal('0'))
166+
assert_equal(self.nodes[1].getunconfirmedbalance(), Decimal('30') - fee_node_1) # Doesn't include output of node 0's send since it was spent
167+
assert_equal(self.nodes[1].getbalances()['mine']['untrusted_pending'], Decimal('30') - fee_node_1)
168+
assert_equal(self.nodes[1].getwalletinfo()["unconfirmed_balance"], Decimal('30') - fee_node_1)
131169

132170
test_balances(fee_node_1=Decimal('0.01'))
133171

0 commit comments

Comments
 (0)