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.
This commit is contained in:
@@ -48,6 +48,76 @@ void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& u
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool hasTransactionType(const std::vector<TransactionInfo>& 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<std::int64_t>(rawTransaction, "time")) info.timestamp = *value;
|
||||||
|
else if (auto value = readOptional<std::int64_t>(rawTransaction, "timereceived")) info.timestamp = *value;
|
||||||
|
if (auto value = readOptional<int>(rawTransaction, "confirmations")) info.confirmations = *value;
|
||||||
|
|
||||||
|
if (!includeSendDetails) return;
|
||||||
|
|
||||||
|
if (auto value = readOptional<double>(rawTransaction, "amount")) info.amount = *value;
|
||||||
|
|
||||||
|
if (!rawTransaction.contains("details") || !rawTransaction["details"].is_array()) return;
|
||||||
|
for (const auto& detail : rawTransaction["details"]) {
|
||||||
|
auto category = readOptional<std::string>(detail, "category");
|
||||||
|
if (!category || *category != "send") continue;
|
||||||
|
|
||||||
|
if (auto value = readOptional<double>(detail, "amount")) info.amount = *value;
|
||||||
|
if (auto value = readOptional<std::string>(detail, "address")) info.address = *value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void applyRawTransactionMetadata(NetworkRefreshService::TransactionViewCacheEntry& entry,
|
||||||
|
const json& rawTransaction)
|
||||||
|
{
|
||||||
|
if (rawTransaction.is_null()) return;
|
||||||
|
|
||||||
|
if (auto value = readOptional<std::int64_t>(rawTransaction, "time")) entry.timestamp = *value;
|
||||||
|
else if (auto value = readOptional<std::int64_t>(rawTransaction, "timereceived")) entry.timestamp = *value;
|
||||||
|
if (auto value = readOptional<int>(rawTransaction, "confirmations")) entry.confirmations = *value;
|
||||||
|
}
|
||||||
|
|
||||||
|
void appendTrackedSendPlaceholder(std::vector<TransactionInfo>& 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
|
} // namespace
|
||||||
|
|
||||||
NetworkRefreshService::ConnectionInfoResult NetworkRefreshService::parseConnectionInfoResult(const json& info)
|
NetworkRefreshService::ConnectionInfoResult NetworkRefreshService::parseConnectionInfoResult(const json& info)
|
||||||
@@ -577,13 +647,19 @@ void NetworkRefreshService::appendViewTransactionOutputs(std::vector<Transaction
|
|||||||
info.amount = -out.value;
|
info.amount = -out.value;
|
||||||
info.memo = out.memo;
|
info.memo = out.memo;
|
||||||
info.from_address = entry.from_address;
|
info.from_address = entry.from_address;
|
||||||
|
bool foundExistingMetadata = false;
|
||||||
for (const auto& existing : transactions) {
|
for (const auto& existing : transactions) {
|
||||||
if (existing.txid == txid) {
|
if (existing.txid == txid) {
|
||||||
info.confirmations = existing.confirmations;
|
info.confirmations = existing.confirmations;
|
||||||
info.timestamp = existing.timestamp;
|
info.timestamp = existing.timestamp;
|
||||||
|
foundExistingMetadata = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!foundExistingMetadata) {
|
||||||
|
info.confirmations = entry.confirmations;
|
||||||
|
info.timestamp = entry.timestamp;
|
||||||
|
}
|
||||||
transactions.push_back(std::move(info));
|
transactions.push_back(std::move(info));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -628,42 +704,83 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
|||||||
knownTxids.insert(txid);
|
knownTxids.insert(txid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const auto& [txid, cachedView] : snapshot.viewTxCache) {
|
||||||
|
if (!cachedView.outgoing_outputs.empty()) knownTxids.insert(txid);
|
||||||
|
}
|
||||||
|
|
||||||
int viewTxCount = 0;
|
int viewTxCount = 0;
|
||||||
for (const auto& txid : knownTxids) {
|
for (const auto& txid : knownTxids) {
|
||||||
if (snapshot.fullyEnrichedTxids.count(txid)) continue;
|
bool trackedSend = snapshot.sendTxids.count(txid) > 0;
|
||||||
|
|
||||||
auto cached = snapshot.viewTxCache.find(txid);
|
auto cached = snapshot.viewTxCache.find(txid);
|
||||||
if (cached != snapshot.viewTxCache.end()) {
|
if (cached != snapshot.viewTxCache.end()) {
|
||||||
appendViewTransactionOutputs(result.transactions, txid, cached->second);
|
if (!trackedSend || !cached->second.outgoing_outputs.empty()) {
|
||||||
continue;
|
appendViewTransactionOutputs(result.transactions, txid, cached->second);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!trackedSend && snapshot.fullyEnrichedTxids.count(txid)) continue;
|
||||||
|
|
||||||
if (viewTxCount >= maxViewTransactionsPerCycle) break;
|
if (viewTxCount >= maxViewTransactionsPerCycle) break;
|
||||||
++viewTxCount;
|
++viewTxCount;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
json viewTransaction = rpc.call("z_viewtransaction", json::array({txid}));
|
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);
|
auto entry = parseViewTransactionCacheEntry(viewTransaction);
|
||||||
appendViewTransactionOutputs(result.transactions, txid, entry);
|
appendViewTransactionOutputs(result.transactions, txid, entry);
|
||||||
|
|
||||||
for (auto& info : result.transactions) {
|
json rawTransaction;
|
||||||
if (info.txid != txid || info.timestamp != 0) continue;
|
bool hasRawTransaction = false;
|
||||||
|
bool needsRawTransaction = false;
|
||||||
try {
|
for (const auto& info : result.transactions) {
|
||||||
json rawTransaction = rpc.call("gettransaction", json::array({txid}));
|
if (info.txid == txid && info.timestamp == 0) {
|
||||||
if (!rawTransaction.is_null()) {
|
needsRawTransaction = true;
|
||||||
if (auto value = readOptional<std::int64_t>(rawTransaction, "time")) info.timestamp = *value;
|
break;
|
||||||
if (auto value = readOptional<int>(rawTransaction, "confirmations")) info.confirmations = *value;
|
}
|
||||||
|
}
|
||||||
|
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) {
|
} catch (const std::exception& e) {
|
||||||
(void)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;
|
cacheUpdate.lastTxBlockHeight = result.blockHeight;
|
||||||
|
|
||||||
for (auto& [txid, entry] : result.newViewTxEntries) {
|
for (auto& [txid, entry] : result.newViewTxEntries) {
|
||||||
|
bool hasOutgoingOutputs = !entry.outgoing_outputs.empty();
|
||||||
cacheUpdate.viewTxCache[txid] = std::move(entry);
|
cacheUpdate.viewTxCache[txid] = std::move(entry);
|
||||||
cacheUpdate.sendTxids.erase(txid);
|
if (hasOutgoingOutputs) cacheUpdate.sendTxids.erase(txid);
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheUpdate.confirmedTxCache.clear();
|
cacheUpdate.confirmedTxCache.clear();
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ public:
|
|||||||
|
|
||||||
struct TransactionViewCacheEntry {
|
struct TransactionViewCacheEntry {
|
||||||
std::string from_address;
|
std::string from_address;
|
||||||
|
std::int64_t timestamp = 0;
|
||||||
|
int confirmations = 0;
|
||||||
struct Output {
|
struct Output {
|
||||||
std::string address;
|
std::string address;
|
||||||
double value = 0.0;
|
double value = 0.0;
|
||||||
|
|||||||
@@ -1000,12 +1000,81 @@ void testNetworkRefreshRpcCollectors()
|
|||||||
EXPECT_EQ(transactionResult.blockHeight, 321);
|
EXPECT_EQ(transactionResult.blockHeight, 321);
|
||||||
EXPECT_EQ(transactionResult.newViewTxEntries.size(), static_cast<size_t>(1));
|
EXPECT_EQ(transactionResult.newViewTxEntries.size(), static_cast<size_t>(1));
|
||||||
EXPECT_EQ(transactionResult.newViewTxEntries.count("pending-send"), static_cast<size_t>(1));
|
EXPECT_EQ(transactionResult.newViewTxEntries.count("pending-send"), static_cast<size_t>(1));
|
||||||
|
EXPECT_EQ(transactionResult.newViewTxEntries.at("pending-send").timestamp, static_cast<int64_t>(500));
|
||||||
|
EXPECT_EQ(transactionResult.newViewTxEntries.at("pending-send").confirmations, 1);
|
||||||
EXPECT_EQ(transactionResult.transactions.size(), static_cast<size_t>(4));
|
EXPECT_EQ(transactionResult.transactions.size(), static_cast<size_t>(4));
|
||||||
EXPECT_EQ(transactionResult.transactions.front().txid, std::string("pending-send"));
|
EXPECT_EQ(transactionResult.transactions.front().txid, std::string("pending-send"));
|
||||||
EXPECT_EQ(transactionResult.transactions.front().type, std::string("send"));
|
EXPECT_EQ(transactionResult.transactions.front().type, std::string("send"));
|
||||||
EXPECT_NEAR(transactionResult.transactions.front().amount, -0.40, 0.00000001);
|
EXPECT_NEAR(transactionResult.transactions.front().amount, -0.40, 0.00000001);
|
||||||
EXPECT_EQ(transactionResult.transactions.front().timestamp, static_cast<int64_t>(500));
|
EXPECT_EQ(transactionResult.transactions.front().timestamp, static_cast<int64_t>(500));
|
||||||
EXPECT_EQ(transactionResult.transactions[1].txid, std::string("transparent-a"));
|
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<std::string>({"listtransactions"}));
|
||||||
|
EXPECT_EQ(cachedOnlyResult.transactions.size(), static_cast<size_t>(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<int64_t>(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<std::string>({
|
||||||
|
"listtransactions", "z_viewtransaction", "gettransaction"
|
||||||
|
}));
|
||||||
|
EXPECT_EQ(retryResult.transactions.size(), static_cast<size_t>(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<size_t>(1));
|
||||||
|
EXPECT_EQ(retryResult.newViewTxEntries.at("retry-send").timestamp, static_cast<int64_t>(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<std::string>({
|
||||||
|
"listtransactions", "z_viewtransaction", "gettransaction"
|
||||||
|
}));
|
||||||
|
EXPECT_EQ(placeholderResult.transactions.size(), static_cast<size_t>(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<int64_t>(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<size_t>(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
void testNetworkRefreshResultModels()
|
void testNetworkRefreshResultModels()
|
||||||
@@ -1198,9 +1267,10 @@ void testNetworkRefreshResultModels()
|
|||||||
pendingTx.timestamp = 1001;
|
pendingTx.timestamp = 1001;
|
||||||
transactionResult.transactions = {pendingTx, confirmedTx};
|
transactionResult.transactions = {pendingTx, confirmedTx};
|
||||||
transactionResult.newViewTxEntries["shielded-send"] = viewEntry;
|
transactionResult.newViewTxEntries["shielded-send"] = viewEntry;
|
||||||
|
transactionResult.newViewTxEntries["pending-empty"] = Refresh::TransactionViewCacheEntry{};
|
||||||
|
|
||||||
Refresh::TransactionViewCache viewCache;
|
Refresh::TransactionViewCache viewCache;
|
||||||
std::unordered_set<std::string> sendTxids{"shielded-send", "pending"};
|
std::unordered_set<std::string> sendTxids{"shielded-send", "pending", "pending-empty"};
|
||||||
std::vector<dragonx::TransactionInfo> confirmedCache;
|
std::vector<dragonx::TransactionInfo> confirmedCache;
|
||||||
std::unordered_set<std::string> confirmedIds;
|
std::unordered_set<std::string> confirmedIds;
|
||||||
int confirmedBlock = -1;
|
int confirmedBlock = -1;
|
||||||
@@ -1217,9 +1287,10 @@ void testNetworkRefreshResultModels()
|
|||||||
EXPECT_EQ(state.transactions.size(), static_cast<size_t>(2));
|
EXPECT_EQ(state.transactions.size(), static_cast<size_t>(2));
|
||||||
EXPECT_EQ(state.last_tx_update, static_cast<int64_t>(4567));
|
EXPECT_EQ(state.last_tx_update, static_cast<int64_t>(4567));
|
||||||
EXPECT_EQ(lastTxBlock, 222);
|
EXPECT_EQ(lastTxBlock, 222);
|
||||||
EXPECT_EQ(viewCache.size(), static_cast<size_t>(1));
|
EXPECT_EQ(viewCache.size(), static_cast<size_t>(2));
|
||||||
EXPECT_EQ(sendTxids.count("shielded-send"), static_cast<size_t>(0));
|
EXPECT_EQ(sendTxids.count("shielded-send"), static_cast<size_t>(0));
|
||||||
EXPECT_EQ(sendTxids.count("pending"), static_cast<size_t>(1));
|
EXPECT_EQ(sendTxids.count("pending"), static_cast<size_t>(1));
|
||||||
|
EXPECT_EQ(sendTxids.count("pending-empty"), static_cast<size_t>(1));
|
||||||
EXPECT_EQ(confirmedCache.size(), static_cast<size_t>(1));
|
EXPECT_EQ(confirmedCache.size(), static_cast<size_t>(1));
|
||||||
EXPECT_EQ(confirmedCache[0].txid, std::string("confirmed"));
|
EXPECT_EQ(confirmedCache[0].txid, std::string("confirmed"));
|
||||||
EXPECT_EQ(confirmedIds.count("confirmed"), static_cast<size_t>(1));
|
EXPECT_EQ(confirmedIds.count("confirmed"), static_cast<size_t>(1));
|
||||||
|
|||||||
Reference in New Issue
Block a user