fix(wallet): retire tracked sends that only paid the wallet itself

A send stays tracked until z_viewtransaction shows an outgoing output.
Shields, merges/consolidations and chat note splits pay only the wallet
itself, so they never showed one. They stayed tracked for the whole
session and forced a full history refresh past the scan throttle on
every tab.

- A tracked send whose view has no outgoing output is retired once it
  is mined (1+ confirmations) or conflicted.
- Tracked sends are enriched first, and reaching the per-cycle view cap
  no longer ends the loop, so the cap can't starve them.
- Backstop: a tracked send still unsettled 20 blocks after it was added
  (expired, dropped from the mempool) stops being tracked.
- Tracked sends are cleared on disconnect: they belong to that
  connection's wallet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 19:52:09 -05:00
co-authored by Claude Opus 5.5
parent 1cb03332e7
commit 43fc0090d4
6 changed files with 168 additions and 3 deletions
+1
View File
@@ -1311,6 +1311,7 @@ void App::update()
successfulOpids.insert(opid);
markPendingSendTransactionSucceeded(opid, txid);
send_txids_.insert(txid);
send_txid_heights_.emplace(txid, state_.sync.blocks);
invokeSendResultCallback(opid, true, txid);
}
+4
View File
@@ -1507,6 +1507,10 @@ private:
// even when they don't appear in listtransactions or
// z_listreceivedbyaddress.
std::unordered_set<std::string> send_txids_;
// Tip height when each tracked txid was added (TTL backstop, see pruneTrackedSendTxids()).
std::unordered_map<std::string, int> send_txid_heights_;
static constexpr int kTrackedSendTtlBlocks = 20;
void pruneTrackedSendTxids();
// First-run wizard state
WizardPhase wizard_phase_ = WizardPhase::None;
+29
View File
@@ -811,6 +811,10 @@ void App::onDisconnected(const std::string& reason)
// Clear RPC result caches
viewtx_cache_.clear();
// Tracked sends belong to this connection's wallet: kept across a switch/adopt they made
// z_viewtransaction fail on the next wallet and left phantom pending "send" rows in its History.
send_txids_.clear();
send_txid_heights_.clear();
confirmed_tx_cache_.clear();
confirmed_tx_ids_.clear();
confirmed_cache_block_ = -1;
@@ -2161,6 +2165,7 @@ void App::refreshTransactionData()
}
NetworkRefreshService::applyTransactionRefreshResult(
state_, cacheUpdate, std::move(result), std::time(nullptr));
pruneTrackedSendTxids();
shielded_history_scan_heights_ = std::move(shieldedScanHeights);
storeTransactionHistoryCacheIfAvailable();
shielded_history_scan_cursor_ = nextShieldedScanStartIndex;
@@ -2175,6 +2180,30 @@ void App::refreshTransactionData()
network_refresh_.resetTxAge();
}
// Backstop for tracked sends that never settle (expired or dropped from the mempool, an RPC error, a
// txid the wallet can't view): stop tracking after kTrackedSendTtlBlocks, since every tracked id keeps
// forcing full history refreshes past the scan throttle. Anything that does confirm later is still
// found and enriched by the untracked history sweep (z_listsentbyaddress).
void App::pruneTrackedSendTxids()
{
const int tip = state_.sync.blocks;
for (auto it = send_txid_heights_.begin(); it != send_txid_heights_.end();) {
if (!send_txids_.count(it->first)) { // already resolved by a refresh
it = send_txid_heights_.erase(it);
continue;
}
if (it->second <= 0 && tip > 0) it->second = tip; // added before the tip was known
if (tip > 0 && it->second > 0 && tip - it->second > kTrackedSendTtlBlocks) {
DEBUG_LOGF("[tx] stop tracking send %s: unsettled after %d blocks\n",
it->first.c_str(), tip - it->second);
send_txids_.erase(it->first);
it = send_txid_heights_.erase(it);
continue;
}
++it;
}
}
void App::refreshRecentTransactionData()
{
if (!worker_ || !rpc_ || !state_.connected) return;
+34 -3
View File
@@ -1094,8 +1094,21 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
if (!cachedView.outgoing_outputs.empty()) knownTxids.insert(txid);
}
int viewTxCount = 0;
// Tracked sends go first so they get the per-cycle z_viewtransaction budget ahead of routine
// enrichment, and a spent budget only skips further RPCs: cached rows later in the order are still
// appended (stopping the loop at the cap dropped them from History).
std::vector<const std::string*> enrichOrder;
enrichOrder.reserve(knownTxids.size());
for (const auto& txid : knownTxids) {
if (snapshot.sendTxids.count(txid)) enrichOrder.push_back(&txid);
}
for (const auto& txid : knownTxids) {
if (!snapshot.sendTxids.count(txid)) enrichOrder.push_back(&txid);
}
int viewTxCount = 0;
for (const std::string* txidPtr : enrichOrder) {
const std::string& txid = *txidPtr;
bool trackedSend = snapshot.sendTxids.count(txid) > 0;
auto cached = snapshot.viewTxCache.find(txid);
@@ -1112,7 +1125,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
if (!trackedSend && snapshot.fullyEnrichedTxids.count(txid)) continue;
if (viewTxCount >= maxViewTransactionsPerCycle) break;
if (viewTxCount >= maxViewTransactionsPerCycle) continue;
++viewTxCount;
try {
@@ -1163,6 +1176,24 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
if (!trackedSend || !entry.outgoing_outputs.empty()) {
result.newViewTxEntries[txid] = std::move(entry);
} else {
// A tracked op that only paid the wallet itself never shows an outgoing output, so the
// check above kept it tracked (and forcing history refreshes past the scan throttle on
// every tab) for the rest of the session. While it's unconfirmed the empty result stays
// retryable; once it is mined, or conflicted, the view result is final: cache it and
// stop tracking. Confirmations come from this txid's rows (listtransactions, or the
// gettransaction-backed placeholder) and any raw metadata applied to the entry.
bool mined = entry.confirmations >= 1;
bool conflicted = entry.confirmations < 0;
for (const auto& info : result.transactions) {
if (info.txid != txid) continue;
if (info.confirmations >= 1) mined = true;
else if (info.confirmations < 0) conflicted = true;
}
if (mined || conflicted) {
result.newViewTxEntries[txid] = std::move(entry);
result.resolvedSendTxids.insert(txid);
}
}
} catch (const std::exception& e) {
(void)e;
@@ -1512,7 +1543,7 @@ void NetworkRefreshService::applyTransactionRefreshResult(WalletState& state,
for (auto& [txid, entry] : result.newViewTxEntries) {
bool hasOutgoingOutputs = !entry.outgoing_outputs.empty();
cacheUpdate.viewTxCache[txid] = std::move(entry);
if (hasOutgoingOutputs) cacheUpdate.sendTxids.erase(txid);
if (hasOutgoingOutputs || result.resolvedSendTxids.count(txid)) cacheUpdate.sendTxids.erase(txid);
}
cacheUpdate.confirmedTxCache.clear();
+4
View File
@@ -222,6 +222,10 @@ public:
std::vector<chat::HushChatTransactionMetadata> hushChatMetadata;
int blockHeight = -1;
TransactionViewCache newViewTxEntries;
// Tracked sends whose view result is final although it shows no outgoing output: the op only
// paid the wallet itself (shield, merge/consolidate, auto-shield, chat note split, a transfer
// between own addresses) and is now mined or conflicted. Applying the result stops tracking them.
std::unordered_set<std::string> resolvedSendTxids;
std::size_t nextShieldedScanStartIndex = 0;
std::size_t shieldedAddressesScanned = 0;
std::size_t shieldedAddressCount = 0;