wallet: Add separate balance info for non-mempool wallet txs
What changed, and why it matters
This commit changes how Bitcoin Core wallets report balances for coins that are spent by transactions no longer in the memory pool. It adds a new 'nonmempool' balance category so users can see these amounts separately, rather than having them silently affect other balance figures. This is a transparency/accounting improvement, not a fix for a code execution or theft vulnerability.
No security action required. Treat as a normal feature/UX improvement. Wallet users and downstream RPC consumers should be aware that getbalances now returns an additional 'nonmempool' field.
Security signals we found
New balance reporting category for non-mempool wallet transactions
No changes to consensus, mempool acceptance, or wallet spending logic
Test updates confirm this is an accounting/visibility change
RPC help text explicitly notes nonmempool balance is an over-estimate
Evidence from the diff
The patch introduces a new SpendType classification (UNSPENT, CONFIRMED, MEMPOOL, NONMEMPOOL) via CWallet::HowSpent() and exposes a new m_mine_nonmempool balance bucket. GetBalance() now optionally subtracts credits for outputs spent by wallet transactions that are not confirmed, not in the mempool, and not abandoned/conflicted. The getbalances RPC includes a new ‘nonmempool’ field. Tests are updated to expect this additional balance entry. The change is informational and does not alter transaction validation, signing, or coin selection behavior.
Changed components
src/wallet/receive.cppsrc/wallet/receive.hsrc/wallet/rpc/coins.cppsrc/wallet/wallet.cppsrc/wallet/wallet.hsrc/interfaces/wallet.hsrc/wallet/interfaces.cppInspect captured patch +83 / −25
diff --git a/src/interfaces/wallet.h b/src/interfaces/wallet.h
index 118f9a2e..33264805 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 7fa0d8a1..055e1840 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 8832ddb6..44fea391 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 5bc0545b..14f0a9bd 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<CTxDestination, CAmount> GetAddressBalances(const CWallet& wallet);
std::set<std::set<CTxDestination>> 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 c76d5e3c..63e400bf 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 d08d6782..37e9ef13 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<TxSpends::const_iterator, TxSpends::const_iterator> 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 b341ac6d..e114f01f 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 f5650b87..2aa79c95 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 8c83f42e..62f900e1 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 b16a2f83..a6562be9 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 89989fbf..a55f4943 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 f13b5a8c..bd02010f 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 db9f1483..79d8ad66 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(), [])
Why this scored 19/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.