From 973c390df5f73ddc1df54ce9d3708c5105cc6f14 Mon Sep 17 00:00:00 2001 From: dan_s Date: Thu, 30 Apr 2026 14:57:37 -0500 Subject: [PATCH] fix(history): keep wallet-created sends visible Replay cached outgoing viewtransaction entries during transaction refresh so shielded sends created from the wallet remain in the History tab after send tracking is cleared. Keep incomplete tracked sends retryable, preserve cached send timestamp/confirmation metadata, and emit a send placeholder from gettransaction metadata when viewtransaction enrichment is not yet available. Add regression coverage for cached sends, retryable empty entries, placeholder sends, and send txid cleanup behavior. --- src/services/network_refresh_service.cpp | 150 ++++++++++++++++++++--- src/services/network_refresh_service.h | 2 + tests/test_phase4.cpp | 75 +++++++++++- 3 files changed, 209 insertions(+), 18 deletions(-) diff --git a/src/services/network_refresh_service.cpp b/src/services/network_refresh_service.cpp index 24d537d..2421cb6 100644 --- a/src/services/network_refresh_service.cpp +++ b/src/services/network_refresh_service.cpp @@ -48,6 +48,76 @@ void applyBalancesFromUnspent(std::vector& addresses, const json& u } } +bool hasTransactionType(const std::vector& transactions, + const std::string& txid, + const std::string& type) +{ + for (const auto& transaction : transactions) { + if (transaction.txid == txid && transaction.type == type) return true; + } + return false; +} + +bool tryFetchRawTransaction(NetworkRefreshService::RefreshRpcGateway& rpc, + const std::string& txid, + json& rawTransaction) +{ + try { + rawTransaction = rpc.call("gettransaction", json::array({txid})); + return !rawTransaction.is_null(); + } catch (...) { + return false; + } +} + +void applyRawTransactionMetadata(TransactionInfo& info, + const json& rawTransaction, + bool includeSendDetails) +{ + if (rawTransaction.is_null()) return; + + if (auto value = readOptional(rawTransaction, "time")) info.timestamp = *value; + else if (auto value = readOptional(rawTransaction, "timereceived")) info.timestamp = *value; + if (auto value = readOptional(rawTransaction, "confirmations")) info.confirmations = *value; + + if (!includeSendDetails) return; + + if (auto value = readOptional(rawTransaction, "amount")) info.amount = *value; + + if (!rawTransaction.contains("details") || !rawTransaction["details"].is_array()) return; + for (const auto& detail : rawTransaction["details"]) { + auto category = readOptional(detail, "category"); + if (!category || *category != "send") continue; + + if (auto value = readOptional(detail, "amount")) info.amount = *value; + if (auto value = readOptional(detail, "address")) info.address = *value; + break; + } +} + +void applyRawTransactionMetadata(NetworkRefreshService::TransactionViewCacheEntry& entry, + const json& rawTransaction) +{ + if (rawTransaction.is_null()) return; + + if (auto value = readOptional(rawTransaction, "time")) entry.timestamp = *value; + else if (auto value = readOptional(rawTransaction, "timereceived")) entry.timestamp = *value; + if (auto value = readOptional(rawTransaction, "confirmations")) entry.confirmations = *value; +} + +void appendTrackedSendPlaceholder(std::vector& transactions, + const std::string& txid, + const json* rawTransaction) +{ + if (hasTransactionType(transactions, txid, "send")) return; + + TransactionInfo info; + info.txid = txid; + info.type = "send"; + if (rawTransaction) applyRawTransactionMetadata(info, *rawTransaction, true); + transactions.push_back(std::move(info)); +} + } // namespace NetworkRefreshService::ConnectionInfoResult NetworkRefreshService::parseConnectionInfoResult(const json& info) @@ -577,13 +647,19 @@ void NetworkRefreshService::appendViewTransactionOutputs(std::vector 0; auto cached = snapshot.viewTxCache.find(txid); if (cached != snapshot.viewTxCache.end()) { - appendViewTransactionOutputs(result.transactions, txid, cached->second); - continue; + if (!trackedSend || !cached->second.outgoing_outputs.empty()) { + appendViewTransactionOutputs(result.transactions, txid, cached->second); + continue; + } } + if (!trackedSend && snapshot.fullyEnrichedTxids.count(txid)) continue; + if (viewTxCount >= maxViewTransactionsPerCycle) break; ++viewTxCount; try { json viewTransaction = rpc.call("z_viewtransaction", json::array({txid})); - if (viewTransaction.is_null() || !viewTransaction.is_object()) continue; + if (viewTransaction.is_null() || !viewTransaction.is_object()) { + if (trackedSend) { + json rawTransaction; + bool hasRawTransaction = tryFetchRawTransaction(rpc, txid, rawTransaction); + appendTrackedSendPlaceholder(result.transactions, + txid, + hasRawTransaction ? &rawTransaction : nullptr); + } + continue; + } auto entry = parseViewTransactionCacheEntry(viewTransaction); appendViewTransactionOutputs(result.transactions, txid, entry); - for (auto& info : result.transactions) { - if (info.txid != txid || info.timestamp != 0) continue; - - try { - json rawTransaction = rpc.call("gettransaction", json::array({txid})); - if (!rawTransaction.is_null()) { - if (auto value = readOptional(rawTransaction, "time")) info.timestamp = *value; - if (auto value = readOptional(rawTransaction, "confirmations")) info.confirmations = *value; + json rawTransaction; + bool hasRawTransaction = false; + bool needsRawTransaction = false; + for (const auto& info : result.transactions) { + if (info.txid == txid && info.timestamp == 0) { + needsRawTransaction = true; + break; + } + } + if (needsRawTransaction) { + hasRawTransaction = tryFetchRawTransaction(rpc, txid, rawTransaction); + if (hasRawTransaction) { + applyRawTransactionMetadata(entry, rawTransaction); + for (auto& info : result.transactions) { + if (info.txid == txid && info.timestamp == 0) { + applyRawTransactionMetadata(info, rawTransaction, false); + } } - } catch (...) {} - break; + } } - result.newViewTxEntries[txid] = std::move(entry); + if (trackedSend && !hasTransactionType(result.transactions, txid, "send")) { + if (!hasRawTransaction) hasRawTransaction = tryFetchRawTransaction(rpc, txid, rawTransaction); + appendTrackedSendPlaceholder(result.transactions, + txid, + hasRawTransaction ? &rawTransaction : nullptr); + } + + if (!trackedSend || !entry.outgoing_outputs.empty()) { + result.newViewTxEntries[txid] = std::move(entry); + } } catch (const std::exception& e) { (void)e; + if (trackedSend) { + json rawTransaction; + bool hasRawTransaction = tryFetchRawTransaction(rpc, txid, rawTransaction); + appendTrackedSendPlaceholder(result.transactions, + txid, + hasRawTransaction ? &rawTransaction : nullptr); + } } } @@ -817,8 +934,9 @@ void NetworkRefreshService::applyTransactionRefreshResult(WalletState& state, cacheUpdate.lastTxBlockHeight = result.blockHeight; for (auto& [txid, entry] : result.newViewTxEntries) { + bool hasOutgoingOutputs = !entry.outgoing_outputs.empty(); cacheUpdate.viewTxCache[txid] = std::move(entry); - cacheUpdate.sendTxids.erase(txid); + if (hasOutgoingOutputs) cacheUpdate.sendTxids.erase(txid); } cacheUpdate.confirmedTxCache.clear(); diff --git a/src/services/network_refresh_service.h b/src/services/network_refresh_service.h index b3303f2..62cead8 100644 --- a/src/services/network_refresh_service.h +++ b/src/services/network_refresh_service.h @@ -148,6 +148,8 @@ public: struct TransactionViewCacheEntry { std::string from_address; + std::int64_t timestamp = 0; + int confirmations = 0; struct Output { std::string address; double value = 0.0; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index f9fd41e..8723e1d 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -1000,12 +1000,81 @@ void testNetworkRefreshRpcCollectors() EXPECT_EQ(transactionResult.blockHeight, 321); EXPECT_EQ(transactionResult.newViewTxEntries.size(), static_cast(1)); EXPECT_EQ(transactionResult.newViewTxEntries.count("pending-send"), static_cast(1)); + EXPECT_EQ(transactionResult.newViewTxEntries.at("pending-send").timestamp, static_cast(500)); + EXPECT_EQ(transactionResult.newViewTxEntries.at("pending-send").confirmations, 1); EXPECT_EQ(transactionResult.transactions.size(), static_cast(4)); EXPECT_EQ(transactionResult.transactions.front().txid, std::string("pending-send")); EXPECT_EQ(transactionResult.transactions.front().type, std::string("send")); EXPECT_NEAR(transactionResult.transactions.front().amount, -0.40, 0.00000001); EXPECT_EQ(transactionResult.transactions.front().timestamp, static_cast(500)); EXPECT_EQ(transactionResult.transactions[1].txid, std::string("transparent-a")); + + Refresh::TransactionRefreshSnapshot cachedOnlySnapshot; + auto cachedOnlyEntry = cachedEntry; + cachedOnlyEntry.timestamp = 450; + cachedOnlyEntry.confirmations = 6; + cachedOnlySnapshot.viewTxCache["cached-only-send"] = cachedOnlyEntry; + cachedOnlySnapshot.fullyEnrichedTxids = {"cached-only-send"}; + MockRefreshRpc cachedOnlyRpc; + cachedOnlyRpc.addResponse("listtransactions", json::array()); + auto cachedOnlyResult = Refresh::collectTransactionRefreshResult(cachedOnlyRpc, cachedOnlySnapshot, 322, 4); + EXPECT_TRUE(cachedOnlyRpc.methodNames() == std::vector({"listtransactions"})); + EXPECT_EQ(cachedOnlyResult.transactions.size(), static_cast(1)); + EXPECT_EQ(cachedOnlyResult.transactions[0].txid, std::string("cached-only-send")); + EXPECT_EQ(cachedOnlyResult.transactions[0].type, std::string("send")); + EXPECT_NEAR(cachedOnlyResult.transactions[0].amount, -0.20, 0.00000001); + EXPECT_EQ(cachedOnlyResult.transactions[0].timestamp, static_cast(450)); + EXPECT_EQ(cachedOnlyResult.transactions[0].confirmations, 6); + + Refresh::TransactionRefreshSnapshot retrySnapshot; + retrySnapshot.sendTxids = {"retry-send"}; + retrySnapshot.viewTxCache["retry-send"] = Refresh::TransactionViewCacheEntry{}; + retrySnapshot.fullyEnrichedTxids = {"retry-send"}; + MockRefreshRpc retryRpc; + retryRpc.addResponse("listtransactions", json::array()); + retryRpc.addResponse("z_viewtransaction", json{ + {"spends", json::array({json{{"address", "zs-retry-from"}}})}, + {"outputs", json::array({ + json{{"outgoing", true}, {"address", "zs-retry-dest"}, {"value", 0.55}} + })} + }); + retryRpc.addResponse("gettransaction", json{{"time", 650}, {"confirmations", 2}}); + auto retryResult = Refresh::collectTransactionRefreshResult(retryRpc, retrySnapshot, 323, 4); + EXPECT_TRUE(retryRpc.methodNames() == std::vector({ + "listtransactions", "z_viewtransaction", "gettransaction" + })); + EXPECT_EQ(retryResult.transactions.size(), static_cast(1)); + EXPECT_EQ(retryResult.transactions[0].txid, std::string("retry-send")); + EXPECT_NEAR(retryResult.transactions[0].amount, -0.55, 0.00000001); + EXPECT_EQ(retryResult.newViewTxEntries.count("retry-send"), static_cast(1)); + EXPECT_EQ(retryResult.newViewTxEntries.at("retry-send").timestamp, static_cast(650)); + EXPECT_EQ(retryResult.newViewTxEntries.at("retry-send").confirmations, 2); + + Refresh::TransactionRefreshSnapshot placeholderSnapshot; + placeholderSnapshot.sendTxids = {"placeholder-send"}; + MockRefreshRpc placeholderRpc; + placeholderRpc.addResponse("listtransactions", json::array()); + placeholderRpc.addResponse("z_viewtransaction", json{{"spends", json::array()}, {"outputs", json::array()}}); + placeholderRpc.addResponse("gettransaction", json{ + {"time", 700}, + {"confirmations", 0}, + {"amount", -0.33}, + {"details", json::array({ + json{{"category", "send"}, {"address", "zs-placeholder-dest"}, {"amount", -0.33}} + })} + }); + auto placeholderResult = Refresh::collectTransactionRefreshResult(placeholderRpc, placeholderSnapshot, 324, 4); + EXPECT_TRUE(placeholderRpc.methodNames() == std::vector({ + "listtransactions", "z_viewtransaction", "gettransaction" + })); + EXPECT_EQ(placeholderResult.transactions.size(), static_cast(1)); + EXPECT_EQ(placeholderResult.transactions[0].txid, std::string("placeholder-send")); + EXPECT_EQ(placeholderResult.transactions[0].type, std::string("send")); + EXPECT_NEAR(placeholderResult.transactions[0].amount, -0.33, 0.00000001); + EXPECT_EQ(placeholderResult.transactions[0].timestamp, static_cast(700)); + EXPECT_EQ(placeholderResult.transactions[0].confirmations, 0); + EXPECT_EQ(placeholderResult.transactions[0].address, std::string("zs-placeholder-dest")); + EXPECT_EQ(placeholderResult.newViewTxEntries.count("placeholder-send"), static_cast(0)); } void testNetworkRefreshResultModels() @@ -1198,9 +1267,10 @@ void testNetworkRefreshResultModels() pendingTx.timestamp = 1001; transactionResult.transactions = {pendingTx, confirmedTx}; transactionResult.newViewTxEntries["shielded-send"] = viewEntry; + transactionResult.newViewTxEntries["pending-empty"] = Refresh::TransactionViewCacheEntry{}; Refresh::TransactionViewCache viewCache; - std::unordered_set sendTxids{"shielded-send", "pending"}; + std::unordered_set sendTxids{"shielded-send", "pending", "pending-empty"}; std::vector confirmedCache; std::unordered_set confirmedIds; int confirmedBlock = -1; @@ -1217,9 +1287,10 @@ void testNetworkRefreshResultModels() EXPECT_EQ(state.transactions.size(), static_cast(2)); EXPECT_EQ(state.last_tx_update, static_cast(4567)); EXPECT_EQ(lastTxBlock, 222); - EXPECT_EQ(viewCache.size(), static_cast(1)); + EXPECT_EQ(viewCache.size(), static_cast(2)); EXPECT_EQ(sendTxids.count("shielded-send"), static_cast(0)); EXPECT_EQ(sendTxids.count("pending"), static_cast(1)); + EXPECT_EQ(sendTxids.count("pending-empty"), static_cast(1)); EXPECT_EQ(confirmedCache.size(), static_cast(1)); EXPECT_EQ(confirmedCache[0].txid, std::string("confirmed")); EXPECT_EQ(confirmedIds.count("confirmed"), static_cast(1));