diff --git a/src/interfaces/wallet.h b/src/interfaces/wallet.h index 118f9a2e96d..33264805ea1 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -370,12 +370,13 @@ struct WalletBalances CAmount unconfirmed_balance = 0; CAmount immature_balance = 0; CAmount used_balance = 0; + CAmount nonmempool_balance = 0; bool balanceChanged(const WalletBalances& prev) const { return balance != prev.balance || unconfirmed_balance != prev.unconfirmed_balance || immature_balance != prev.immature_balance || - used_balance != prev.used_balance; + used_balance != prev.used_balance || nonmempool_balance != prev.nonmempool_balance; } }; diff --git a/src/wallet/interfaces.cpp b/src/wallet/interfaces.cpp index 7fa0d8a13b9..055e1840c8c 100644 --- a/src/wallet/interfaces.cpp +++ b/src/wallet/interfaces.cpp @@ -389,6 +389,7 @@ public: result.unconfirmed_balance = bal.m_mine_untrusted_pending; result.immature_balance = bal.m_mine_immature; result.used_balance = bal.m_mine_used; + result.nonmempool_balance = bal.m_mine_nonmempool; return result; } bool tryGetBalances(WalletBalances& balances, uint256& block_hash) override diff --git a/src/wallet/receive.cpp b/src/wallet/receive.cpp index 8832ddb66ce..44fea391bb9 100644 --- a/src/wallet/receive.cpp +++ b/src/wallet/receive.cpp @@ -242,7 +242,7 @@ bool CachedTxIsTrusted(const CWallet& wallet, const CWalletTx& wtx) return CachedTxIsTrusted(wallet, wtx, trusted_parents); } -Balance GetBalance(const CWallet& wallet, const int min_depth, bool avoid_reuse) +Balance GetBalance(const CWallet& wallet, const int min_depth, bool avoid_reuse, bool include_nonmempool) { Balance ret; bool allow_used_addresses = !avoid_reuse || !wallet.IsWalletFlagSet(WALLET_FLAG_AVOID_REUSE); @@ -255,7 +255,17 @@ Balance GetBalance(const CWallet& wallet, const int min_depth, bool avoid_reuse) const bool is_trusted{CachedTxIsTrusted(wallet, wtx, trusted_parents)}; const int tx_depth{wallet.GetTxDepthInMainChain(wtx)}; - if (!wallet.IsSpent(outpoint)) { + bool nonmempool_spent = false; + switch (wallet.HowSpent(outpoint)) { + case CWallet::SpendType::CONFIRMED: + case CWallet::SpendType::MEMPOOL: + // treat as spent; ignore + break; + case CWallet::SpendType::NONMEMPOOL: + if (!include_nonmempool) break; + nonmempool_spent = true; + [[fallthrough]]; + case CWallet::SpendType::UNSPENT: CAmount* bucket = nullptr; // Set the amounts in the return object @@ -274,6 +284,9 @@ Balance GetBalance(const CWallet& wallet, const int min_depth, bool avoid_reuse) bucket = &ret.m_mine_used; } *bucket += credit_mine; + if (nonmempool_spent) { + ret.m_mine_nonmempool -= credit_mine; + } } } } diff --git a/src/wallet/receive.h b/src/wallet/receive.h index 5bc0545bebe..14f0a9bdf27 100644 --- a/src/wallet/receive.h +++ b/src/wallet/receive.h @@ -48,8 +48,9 @@ struct Balance { CAmount m_mine_untrusted_pending{0}; //!< Untrusted, but in mempool (pending) CAmount m_mine_immature{0}; //!< Immature coinbases in the main chain CAmount m_mine_used{0}; //!< Trusted/untrusted/immature funds in utxos that have already been spent from (only populated if AVOID REUSE wallet flag is set) + CAmount m_mine_nonmempool{0}; //!< Coins spent by wallet txs that are not in the mempool }; -Balance GetBalance(const CWallet& wallet, int min_depth = 0, bool avoid_reuse = true); +Balance GetBalance(const CWallet& wallet, int min_depth = 0, bool avoid_reuse = true, bool include_nonmempool = false); std::map GetAddressBalances(const CWallet& wallet); std::set> GetAddressGroupings(const CWallet& wallet) EXCLUSIVE_LOCKS_REQUIRED(wallet.cs_wallet); diff --git a/src/wallet/rpc/coins.cpp b/src/wallet/rpc/coins.cpp index c76d5e3c7fc..63e400bfc6d 100644 --- a/src/wallet/rpc/coins.cpp +++ b/src/wallet/rpc/coins.cpp @@ -413,6 +413,7 @@ RPCHelpMan getbalances() {RPCResult::Type::STR_AMOUNT, "trusted", "trusted balance (outputs created by the wallet or confirmed outputs)"}, {RPCResult::Type::STR_AMOUNT, "untrusted_pending", "untrusted pending balance (outputs created by others that are in the mempool)"}, {RPCResult::Type::STR_AMOUNT, "immature", "balance from immature coinbase outputs"}, + {RPCResult::Type::STR_AMOUNT, "nonmempool", "sum of coins that are spent by transactions not in the mempool (usually an over-estimate due to not accounting for change or spends that conflict with each other)"}, {RPCResult::Type::STR_AMOUNT, "used", /*optional=*/true, "(only present if avoid_reuse is set) balance from coins sent to addresses that were previously spent from (potentially privacy violating)"}, }}, RESULT_LAST_PROCESSED_BLOCK, @@ -433,7 +434,7 @@ RPCHelpMan getbalances() LOCK(wallet.cs_wallet); - const auto bal = GetBalance(wallet, /*min_depth=*/0, /*avoid_reuse=*/true); + const auto bal = GetBalance(wallet, /*min_depth=*/0, /*avoid_reuse=*/true, /*include_nonmempool=*/true); UniValue balances{UniValue::VOBJ}; { @@ -441,6 +442,7 @@ RPCHelpMan getbalances() balances_mine.pushKV("trusted", ValueFromAmount(bal.m_mine_trusted)); balances_mine.pushKV("untrusted_pending", ValueFromAmount(bal.m_mine_untrusted_pending)); balances_mine.pushKV("immature", ValueFromAmount(bal.m_mine_immature)); + balances_mine.pushKV("nonmempool", ValueFromAmount(bal.m_mine_nonmempool)); if (wallet.IsWalletFlagSet(WALLET_FLAG_AVOID_REUSE)) { balances_mine.pushKV("used", ValueFromAmount(bal.m_mine_used)); } diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index d08d6782c1b..37e9ef13ca2 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -738,6 +738,29 @@ bool CWallet::IsSpent(const COutPoint& outpoint) const return false; } +CWallet::SpendType CWallet::HowSpent(const COutPoint& outpoint) const +{ + SpendType st{SpendType::UNSPENT}; + + std::pair range; + range = mapTxSpends.equal_range(outpoint); + + for (TxSpends::const_iterator it = range.first; it != range.second; ++it) { + const Txid& txid = it->second; + const auto mit = mapWallet.find(txid); + if (mit != mapWallet.end()) { + const auto& wtx = mit->second; + if (wtx.isConfirmed()) return SpendType::CONFIRMED; + if (wtx.InMempool()) { + st = SpendType::MEMPOOL; + } else if (!wtx.isAbandoned() && !wtx.isBlockConflicted() && !wtx.isMempoolConflicted()) { + if (st == SpendType::UNSPENT) st = SpendType::NONMEMPOOL; + } + } + } + return st; +} + void CWallet::AddToSpends(const COutPoint& outpoint, const Txid& txid) { mapTxSpends.insert(std::make_pair(outpoint, txid)); diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index b341ac6da2d..e114f01f2e4 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -556,6 +556,13 @@ public: int GetTxBlocksToMaturity(const CWalletTx& wtx) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); bool IsTxImmatureCoinBase(const CWalletTx& wtx) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + enum class SpendType { + UNSPENT, + CONFIRMED, + MEMPOOL, + NONMEMPOOL, + }; + SpendType HowSpent(const COutPoint& outpoint) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); bool IsSpent(const COutPoint& outpoint) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); // Whether this or any known scriptPubKey with the same single key has been spent. diff --git a/test/functional/wallet_abandonconflict.py b/test/functional/wallet_abandonconflict.py index f5650b87f01..2aa79c95bf7 100755 --- a/test/functional/wallet_abandonconflict.py +++ b/test/functional/wallet_abandonconflict.py @@ -115,10 +115,9 @@ class AbandonConflictTest(BitcoinTestFramework): # inputs are still spent, but change not received newbalance = alice.getbalance() assert_equal(newbalance, balance - signed3_change) - # Unconfirmed received funds that are not in mempool, also shouldn't show - # up in unconfirmed balance + # Unconfirmed received funds that are not in mempool balances = alice.getbalances()['mine'] - assert_equal(balances['untrusted_pending'] + balances['trusted'], newbalance) + assert_equal(balances['untrusted_pending'] + balances['trusted'] + balances['nonmempool'], newbalance) # Also shouldn't show up in listunspent assert not txABC2 in [utxo["txid"] for utxo in alice.listunspent(0)] balance = newbalance diff --git a/test/functional/wallet_balance.py b/test/functional/wallet_balance.py index 8c83f42ecfd..62f900e1348 100755 --- a/test/functional/wallet_balance.py +++ b/test/functional/wallet_balance.py @@ -145,10 +145,12 @@ class WalletTest(BitcoinTestFramework): # getbalances expected_balances_0 = {'mine': {'immature': Decimal('0E-8'), 'trusted': Decimal('9.99'), # change from node 0's send - 'untrusted_pending': Decimal('60.0')}} + 'untrusted_pending': Decimal('60.0'), + 'nonmempool': Decimal('0.0')}} expected_balances_1 = {'mine': {'immature': Decimal('0E-8'), 'trusted': Decimal('0E-8'), # node 1's send had an unsafe input - 'untrusted_pending': Decimal('30.0') - fee_node_1}} # Doesn't include output of node 0's send since it was spent + 'untrusted_pending': Decimal('30.0') - fee_node_1, # Doesn't include output of node 0's send since it was spent + 'nonmempool': Decimal('0.0')}} balances_0 = self.nodes[0].getbalances() balances_1 = self.nodes[1].getbalances() # remove lastprocessedblock keys (they will be tested later) diff --git a/test/functional/wallet_conflicts.py b/test/functional/wallet_conflicts.py index b16a2f83d2a..a6562be9360 100755 --- a/test/functional/wallet_conflicts.py +++ b/test/functional/wallet_conflicts.py @@ -304,8 +304,9 @@ class TxConflicts(BitcoinTestFramework): bob.sendrawtransaction(tx1_conflict_conflict) # kick tx1_conflict out of the mempool bob.sendrawtransaction(raw_tx1) #re-broadcast tx1 because it is no longer conflicted - # Now bob has no pending funds because tx1 and tx2 are spent by tx3, which hasn't been re-broadcast yet - assert_equal(bob.getbalances()["mine"]["untrusted_pending"], 0) + # Now bob has pending funds because tx1 and tx2 are spent by tx3, which hasn't been re-broadcast yet + bob_bal = bob.getbalances()["mine"] + assert_equal(bob_bal["untrusted_pending"], -bob_bal["nonmempool"]) bob.sendrawtransaction(raw_tx3) assert_equal(len(bob.getrawmempool()), 4) # The mempool contains: tx1, tx2, tx1_conflict_conflict, tx3 diff --git a/test/functional/wallet_migration.py b/test/functional/wallet_migration.py index 89989fbf81b..a55f4943a0a 100755 --- a/test/functional/wallet_migration.py +++ b/test/functional/wallet_migration.py @@ -8,6 +8,7 @@ import random import shutil import struct import time +from decimal import Decimal from test_framework.address import ( key_to_p2pkh, @@ -522,6 +523,7 @@ class WalletMigrationTest(BitcoinTestFramework): self.generatetodescriptor(self.master_node, 1, desc) bals = wallet.getbalances() + bals["mine"]["nonmempool"] = Decimal('0.0') _, wallet = self.migrate_and_get_rpc("pkcb") @@ -537,6 +539,7 @@ class WalletMigrationTest(BitcoinTestFramework): txid = default.sendtoaddress(addr, 1) self.generate(self.master_node, 1) bals = wallet.getbalances() + bals["mine"]["nonmempool"] = Decimal('0.0') # Use self.migrate_and_get_rpc to test this error to get everything copied over to the master node assert_raises_rpc_error(-4, "Error: Wallet decryption failed, the wallet passphrase was not provided or was incorrect", self.migrate_and_get_rpc, "encrypted") @@ -577,6 +580,7 @@ class WalletMigrationTest(BitcoinTestFramework): txid = default.sendtoaddress(addr, 1) self.generate(self.master_node, 1) bals = wallet.getbalances() + bals["mine"]["nonmempool"] = Decimal('0.0') wallet.unloadwallet() @@ -616,6 +620,7 @@ class WalletMigrationTest(BitcoinTestFramework): txid = default.sendtoaddress(addr, 1) self.generate(self.master_node, 1) bals = wallet.getbalances() + bals["mine"]["nonmempool"] = Decimal('0.0') migrate_res, wallet = self.migrate_and_get_rpc(relative_name) @@ -636,6 +641,7 @@ class WalletMigrationTest(BitcoinTestFramework): self.old_node.restorewallet("relative_restored", migrate_res['backup_path']) wallet = self.old_node.get_wallet_rpc("relative_restored") assert wallet.gettransaction(txid) + del bals["mine"]["nonmempool"] assert_equal(bals, wallet.getbalances()) info = wallet.getwalletinfo() @@ -652,6 +658,7 @@ class WalletMigrationTest(BitcoinTestFramework): txid = default.sendtoaddress(addr, 1) self.generate(self.master_node, 1) bals = wallet.getbalances() + bals["mine"]["nonmempool"] = Decimal('0.0') _, wallet = self.migrate_and_get_rpc(wallet_path) @@ -1423,14 +1430,14 @@ class WalletMigrationTest(BitcoinTestFramework): _, wallet = self.migrate_and_get_rpc("miniscript") # The miniscript with all keys should be in the migrated wallet - assert_equal(wallet.getbalances()["mine"], {"trusted": 0.75, "untrusted_pending": 0, "immature": 0}) + assert_equal(wallet.getbalances()["mine"], {"trusted": 0.75, "untrusted_pending": 0, "immature": 0, "nonmempool": 0}) assert_equal(wallet.getaddressinfo(all_keys_addr)["ismine"], True) assert_equal(wallet.getaddressinfo(some_keys_addr)["ismine"], False) # The miniscript with some keys should be in the watchonly wallet assert "miniscript_watchonly" in self.master_node.listwallets() watchonly = self.master_node.get_wallet_rpc("miniscript_watchonly") - assert_equal(watchonly.getbalances()["mine"], {"trusted": 1, "untrusted_pending": 0, "immature": 0}) + assert_equal(watchonly.getbalances()["mine"], {"trusted": 1, "untrusted_pending": 0, "immature": 0, "nonmempool": 0}) assert_equal(watchonly.getaddressinfo(some_keys_addr)["ismine"], True) assert_equal(watchonly.getaddressinfo(all_keys_addr)["ismine"], False) @@ -1479,7 +1486,7 @@ class WalletMigrationTest(BitcoinTestFramework): res, wallet = self.migrate_and_get_rpc("taproot") # The rawtr should be migrated - assert_equal(wallet.getbalances()["mine"], {"trusted": 0.5, "untrusted_pending": 0, "immature": 0}) + assert_equal(wallet.getbalances()["mine"], {"trusted": 0.5, "untrusted_pending": 0, "immature": 0, "nonmempool": 0}) assert_equal(wallet.getaddressinfo(rawtr_addr)["ismine"], True) assert_equal(wallet.getaddressinfo(tr_addr)["ismine"], False) assert_equal(wallet.getaddressinfo(tr_script_addr)["ismine"], False) @@ -1487,7 +1494,7 @@ class WalletMigrationTest(BitcoinTestFramework): # The tr() with some keys should be in the watchonly wallet assert "taproot_watchonly" in self.master_node.listwallets() watchonly = self.master_node.get_wallet_rpc("taproot_watchonly") - assert_equal(watchonly.getbalances()["mine"], {"trusted": 5, "untrusted_pending": 0, "immature": 0}) + assert_equal(watchonly.getbalances()["mine"], {"trusted": 5, "untrusted_pending": 0, "immature": 0, "nonmempool": 0}) assert_equal(watchonly.getaddressinfo(rawtr_addr)["ismine"], False) assert_equal(watchonly.getaddressinfo(tr_addr)["ismine"], True) assert_equal(watchonly.getaddressinfo(tr_script_addr)["ismine"], True) diff --git a/test/functional/wallet_orphanedreward.py b/test/functional/wallet_orphanedreward.py index f13b5a8c1b8..bd02010fa7c 100755 --- a/test/functional/wallet_orphanedreward.py +++ b/test/functional/wallet_orphanedreward.py @@ -46,6 +46,7 @@ class OrphanedBlockRewardTest(BitcoinTestFramework): "trusted": 10, "untrusted_pending": 0, "immature": 0, + "nonmempool": 0, }) # And the unconfirmed tx to be abandoned assert_equal(self.nodes[1].gettransaction(txid)["details"][0]["abandoned"], True) diff --git a/test/functional/wallet_v3_txs.py b/test/functional/wallet_v3_txs.py index db9f1483aba..79d8ad66738 100755 --- a/test/functional/wallet_v3_txs.py +++ b/test/functional/wallet_v3_txs.py @@ -39,19 +39,19 @@ def cleanup(func): 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'] + for wallet in [self.alice, self.bob]: + txs = set(tx["txid"] for tx in wallet.listtransactions("*", 1000) if tx["confirmations"] == 0 and not tx["abandoned"]) + for tx in txs: + wallet.abandontransaction(tx) + try: + wallet.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"]: + for balance_type in ["untrusted_pending", "trusted", "immature", "nonmempool"]: assert_equal(balance[balance_type], 0) assert_equal(self.alice.getrawmempool(), [])