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
+96
View File
@@ -9302,6 +9302,101 @@ void testWalletIndex()
#endif // !_WIN32
}
// Tracked sends that only paid the wallet itself (shield, merge/consolidate, chat note split) show no
// outgoing output in z_viewtransaction. They used to stay tracked for the whole session, forcing a full
// history refresh past the scan throttle on every tab; once mined or conflicted they are now retired.
void testTrackedSendRetirement()
{
using Refresh = dragonx::services::NetworkRefreshService;
using json = nlohmann::json;
const json ownOnlyView = json{
{"spends", json::array({json{{"address", "zs-own"}}})},
{"outputs", json::array({json{{"outgoing", false}, {"address", "zs-own"}, {"value", 1.0}}})}};
// Mined: retired and cached.
Refresh::TransactionRefreshSnapshot minedSnapshot;
minedSnapshot.sendTxids = {"self-mined"};
MockRefreshRpc minedRpc;
minedRpc.addResponse("listtransactions", json::array());
minedRpc.addResponse("z_viewtransaction", ownOnlyView);
minedRpc.addResponse("gettransaction", json{{"time", 800}, {"confirmations", 3}, {"amount", 0.0}});
auto mined = Refresh::collectTransactionRefreshResult(minedRpc, minedSnapshot, 400, 4);
EXPECT_EQ(mined.resolvedSendTxids.count("self-mined"), static_cast<size_t>(1));
EXPECT_EQ(mined.newViewTxEntries.count("self-mined"), static_cast<size_t>(1));
// Conflicted (negative confirmations): retired too.
Refresh::TransactionRefreshSnapshot conflictSnapshot;
conflictSnapshot.sendTxids = {"self-conflicted"};
MockRefreshRpc conflictRpc;
conflictRpc.addResponse("listtransactions", json::array());
conflictRpc.addResponse("z_viewtransaction", ownOnlyView);
conflictRpc.addResponse("gettransaction", json{{"time", 801}, {"confirmations", -1}});
auto conflicted = Refresh::collectTransactionRefreshResult(conflictRpc, conflictSnapshot, 400, 4);
EXPECT_EQ(conflicted.resolvedSendTxids.count("self-conflicted"), static_cast<size_t>(1));
// Unconfirmed: stays tracked and uncached, so the next cycle retries it.
Refresh::TransactionRefreshSnapshot pendingSnapshot;
pendingSnapshot.sendTxids = {"self-pending"};
MockRefreshRpc pendingRpc;
pendingRpc.addResponse("listtransactions", json::array());
pendingRpc.addResponse("z_viewtransaction", ownOnlyView);
pendingRpc.addResponse("gettransaction", json{{"time", 802}, {"confirmations", 0}});
auto pending = Refresh::collectTransactionRefreshResult(pendingRpc, pendingSnapshot, 400, 4);
EXPECT_TRUE(pending.resolvedSendTxids.empty());
EXPECT_EQ(pending.newViewTxEntries.count("self-pending"), static_cast<size_t>(0));
// Applying the mined result drops only the retired id.
dragonx::WalletState state;
Refresh::TransactionViewCache viewCache;
std::unordered_set<std::string> sendTxids{"self-mined", "self-pending"};
std::vector<dragonx::TransactionInfo> confirmedCache;
std::unordered_set<std::string> confirmedIds;
int confirmedBlock = -1;
int lastTxBlock = -1;
Refresh::TransactionCacheUpdate cacheUpdate{viewCache, sendTxids, confirmedCache, confirmedIds,
confirmedBlock, lastTxBlock};
Refresh::applyTransactionRefreshResult(state, cacheUpdate, std::move(mined), 999);
EXPECT_EQ(sendTxids.count("self-mined"), static_cast<size_t>(0));
EXPECT_EQ(sendTxids.count("self-pending"), static_cast<size_t>(1));
EXPECT_EQ(viewCache.count("self-mined"), static_cast<size_t>(1));
// A spent z_viewtransaction budget skips further RPCs but still appends cached rows that sort
// after the cutoff (the loop used to stop there and drop them from History).
Refresh::TransactionRefreshSnapshot capSnapshot;
Refresh::TransactionViewCacheEntry cachedSend;
cachedSend.from_address = "zs-from";
cachedSend.outgoing_outputs.push_back({"zs-dest", 0.5, ""});
capSnapshot.viewTxCache["z-cached-send"] = cachedSend;
MockRefreshRpc capRpc;
capRpc.addResponse("listtransactions", json::array({
json{{"txid", "a-recv"}, {"category", "receive"}, {"amount", 1.0}, {"time", 100},
{"confirmations", 3}, {"address", "R-one"}},
json{{"txid", "b-recv"}, {"category", "receive"}, {"amount", 2.0}, {"time", 101},
{"confirmations", 3}, {"address", "R-one"}}
}));
capRpc.addResponse("z_viewtransaction", json{{"spends", json::array()}, {"outputs", json::array()}});
auto capped = Refresh::collectTransactionRefreshResult(capRpc, capSnapshot, 400, 1);
EXPECT_TRUE(capRpc.methodNames() == std::vector<std::string>({"listtransactions", "z_viewtransaction"}));
EXPECT_EQ(capRpc.calls[1].params, json::array({"a-recv"}));
EXPECT_TRUE(std::any_of(capped.transactions.begin(), capped.transactions.end(),
[](const dragonx::TransactionInfo& t) { return t.txid == "z-cached-send"; }));
// Tracked sends get the budget first, whatever their txid sorts as.
Refresh::TransactionRefreshSnapshot orderSnapshot;
orderSnapshot.sendTxids = {"zz-tracked"};
MockRefreshRpc orderRpc;
orderRpc.addResponse("listtransactions", json::array({
json{{"txid", "a-recv"}, {"category", "receive"}, {"amount", 1.0}, {"time", 100},
{"confirmations", 3}, {"address", "R-one"}}
}));
orderRpc.addResponse("z_viewtransaction", json{
{"spends", json::array({json{{"address", "zs-from"}}})},
{"outputs", json::array({json{{"outgoing", true}, {"address", "zs-peer"}, {"value", 0.25}}})}});
orderRpc.addResponse("gettransaction", json{{"time", 900}, {"confirmations", 0}});
Refresh::collectTransactionRefreshResult(orderRpc, orderSnapshot, 400, 1);
EXPECT_EQ(orderRpc.calls[1].params, json::array({"zz-tracked"}));
}
// The displayed-balance roll must never overshoot, must land on the target after an idle-length
// frame, and must survive Reduce Motion and non-finite values (it used to flip between two wrong
// values at dt*speed == 2 and diverge to inf/NaN under Reduce Motion's speed of 999).
@@ -9360,6 +9455,7 @@ int main()
testParseDaemonRescanOutput();
testNetworkRefreshRpcCollectors();
testNetworkRefreshResultModels();
testTrackedSendRetirement();
testBalanceDisplayLerp();
testOperationStatusPollParsing();
testSecureVaultScope();