feat(wallet): persist history and surface pending sends

Add an encrypted SQLite transaction history cache with cached tip metadata and
per-address shielded scan progress so startup and full refreshes avoid
re-scanning every z-address while still invalidating on wallet/address/rescan
changes.

Improve wallet history loading by paging transparent transactions, preserving
cached shielded and sent rows, keeping recent/unconfirmed activity visible, and
classifying mining-address receives. Show z_sendmany opid sends immediately in
History and Overview, pin pending rows through refreshes, and apply optimistic
address/balance debits until opids resolve.

Add timestamped RPC console tracing by source/method without logging params or
results, reduce redundant refresh/RPC calls, and cache Explorer recent block
summaries in SQLite.

Expand focused tests for transaction cache encryption, scan-progress
persistence/invalidation, history preservation, operation-status parsing,
pending send visibility, and Explorer/RPC refresh behavior.
This commit is contained in:
dan_s
2026-05-05 03:22:14 -05:00
parent 973c390df5
commit 229373e937
43 changed files with 3732 additions and 702 deletions

View File

@@ -1,4 +1,5 @@
#include "daemon/daemon_controller.h"
#include "data/transaction_history_cache.h"
#include "daemon/lifecycle_adapters.h"
#include "data/wallet_state.h"
#include "rpc/connection.h"
@@ -8,6 +9,7 @@
#include "services/wallet_security_controller.h"
#include "services/wallet_security_workflow.h"
#include "services/wallet_security_workflow_executor.h"
#include "ui/explorer/explorer_block_cache.h"
#include "ui/windows/balance_address_list.h"
#include "ui/windows/balance_recent_tx.h"
#include "ui/windows/console_input_model.h"
@@ -485,13 +487,23 @@ void testRefreshScheduler()
scheduler.markDue(Timer::Peers);
EXPECT_TRUE(scheduler.consumeDue(Timer::Peers));
scheduler.applyPage(dragonx::ui::NavPage::Console);
EXPECT_NEAR(scheduler.intervals().core, 10.0, 0.0001);
EXPECT_NEAR(scheduler.intervals().transactions, 30.0, 0.0001);
EXPECT_NEAR(scheduler.intervals().addresses, 30.0, 0.0001);
EXPECT_NEAR(scheduler.intervals().peers, 0.0, 0.0001);
EXPECT_FALSE(scheduler.isDue(Timer::Price));
scheduler.markDue(Timer::Price);
EXPECT_TRUE(scheduler.consumeDue(Timer::Price));
EXPECT_FALSE(scheduler.shouldRefreshTransactions(100, 100, false, false));
EXPECT_FALSE(scheduler.shouldRefreshTransactions(100, 100, false));
EXPECT_TRUE(scheduler.shouldRefreshTransactions(-1, 100, false));
EXPECT_TRUE(scheduler.shouldRefreshTransactions(99, 100, false));
EXPECT_TRUE(scheduler.shouldRefreshTransactions(100, 100, true));
scheduler.tick(RefreshScheduler::kTxMaxAge);
EXPECT_TRUE(scheduler.shouldRefreshTransactions(100, 100, false, false));
EXPECT_FALSE(scheduler.shouldRefreshTransactions(100, 100, false));
EXPECT_TRUE(scheduler.isDue(Timer::TxAge));
}
void testNetworkRefreshService()
@@ -808,6 +820,15 @@ void testNetworkRefreshRpcCollectors()
EXPECT_TRUE(prefetch.encryption.encrypted);
EXPECT_EQ(prefetch.encryption.unlockedUntil, static_cast<int64_t>(99));
MockRefreshRpc connectionReuseRpc;
connectionReuseRpc.addResponse("getwalletinfo", json{{"unlocked_until", 0}});
auto reusedInfo = Refresh::parseConnectionInfoResult(json{{"version", 120002}, {"blocks", 241}});
auto reusedConnection = Refresh::collectConnectionInitResult(connectionReuseRpc, reusedInfo);
EXPECT_TRUE(connectionReuseRpc.methodNames() == std::vector<std::string>({"getwalletinfo"}));
EXPECT_TRUE(reusedConnection.info.ok);
EXPECT_EQ(*reusedConnection.info.daemonVersion, 120002);
EXPECT_TRUE(reusedConnection.encryption.ok);
MockRefreshRpc coreRpc;
coreRpc.addResponse("z_gettotalbalance", json{
{"private", "3.00000000"},
@@ -817,6 +838,7 @@ void testNetworkRefreshRpcCollectors()
coreRpc.addResponse("getblockchaininfo", json{
{"blocks", 150},
{"headers", 155},
{"bestblockhash", "core-best-150"},
{"verificationprogress", 0.80},
{"longestchain", 160},
{"notarized", 145}
@@ -831,6 +853,7 @@ void testNetworkRefreshRpcCollectors()
EXPECT_TRUE(core.blockchainOk);
EXPECT_NEAR(*core.totalBalance, 4.25, 0.00000001);
EXPECT_EQ(*core.blocks, 150);
EXPECT_EQ(*core.bestBlockHash, std::string("core-best-150"));
EXPECT_EQ(*core.longestChain, 160);
MockRefreshRpc coreFallbackRpc;
@@ -918,6 +941,14 @@ void testNetworkRefreshRpcCollectors()
EXPECT_TRUE(miningPartial.miningOk);
EXPECT_FALSE(*miningPartial.generate);
MockRefreshRpc miningSlowOnlyRpc;
miningSlowOnlyRpc.addResponse("getmininginfo", json{{"generate", false}, {"networkhashps", 222.0}});
auto miningSlowOnly = Refresh::collectMiningRefreshResult(miningSlowOnlyRpc, 7.0, true, false);
EXPECT_TRUE(miningSlowOnlyRpc.methodNames() == std::vector<std::string>({"getmininginfo"}));
EXPECT_FALSE(miningSlowOnly.localHashrate.has_value());
EXPECT_TRUE(miningSlowOnly.miningOk);
EXPECT_NEAR(*miningSlowOnly.networkHashrate, 222.0, 0.00000001);
MockRefreshRpc addressRpc;
addressRpc.addResponse("z_listaddresses", json::array({"zs-one", "zs-two"}));
addressRpc.addResponse("z_validateaddress", json{{"ismine", true}});
@@ -961,6 +992,23 @@ void testNetworkRefreshRpcCollectors()
EXPECT_TRUE(fallbackAddresses.shieldedAddresses[0].has_spending_key);
EXPECT_NEAR(fallbackAddresses.shieldedAddresses[0].balance, 4.75, 0.00000001);
dragonx::WalletState cachedAddressState;
cachedAddressState.z_addresses.push_back({"zs-cached", 0.0, "shielded", false});
auto addressSnapshot = Refresh::buildAddressRefreshSnapshot(cachedAddressState);
MockRefreshRpc cachedAddressRpc;
cachedAddressRpc.addResponse("z_listaddresses", json::array({"zs-cached", "zs-new"}));
cachedAddressRpc.addResponse("z_validateaddress", json{{"ismine", true}});
cachedAddressRpc.addResponse("z_listunspent", json::array());
cachedAddressRpc.addResponse("getaddressesbyaccount", json::array());
cachedAddressRpc.addResponse("listunspent", json::array());
auto cachedAddresses = Refresh::collectAddressRefreshResult(cachedAddressRpc, addressSnapshot);
EXPECT_TRUE(cachedAddressRpc.methodNames() == std::vector<std::string>({
"z_listaddresses", "z_validateaddress", "z_listunspent",
"getaddressesbyaccount", "listunspent"
}));
EXPECT_FALSE(cachedAddresses.shieldedAddresses[0].has_spending_key);
EXPECT_TRUE(cachedAddresses.shieldedAddresses[1].has_spending_key);
Refresh::TransactionRefreshSnapshot snapshot;
snapshot.shieldedAddresses = {"zs-one", "zs-two"};
snapshot.sendTxids = {"cached-send", "pending-send"};
@@ -1075,6 +1123,227 @@ void testNetworkRefreshRpcCollectors()
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));
Refresh::TransactionRefreshSnapshot missingAddressesSnapshot;
missingAddressesSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
"shielded-fallback", "receive", 1.25, 150, 2, "zs-one", "", "memo"
});
MockRefreshRpc missingAddressesRpc;
missingAddressesRpc.addResponse("listtransactions", json::array());
auto missingAddressesResult = Refresh::collectTransactionRefreshResult(
missingAddressesRpc, missingAddressesSnapshot, 325, 4);
EXPECT_TRUE(missingAddressesRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
EXPECT_EQ(missingAddressesResult.transactions.size(), static_cast<size_t>(1));
EXPECT_EQ(missingAddressesResult.transactions[0].txid, std::string("shielded-fallback"));
EXPECT_EQ(missingAddressesResult.transactions[0].type, std::string("receive"));
Refresh::TransactionRefreshSnapshot pendingOpidSnapshot;
pendingOpidSnapshot.pendingOpids = {"opid-visible-send"};
pendingOpidSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
"opid-visible-send", "send", -4.25, 170, 0, "R-destination", "R-source", ""
});
MockRefreshRpc pendingOpidRpc;
pendingOpidRpc.addResponse("listtransactions", json::array());
auto pendingOpidResult = Refresh::collectTransactionRefreshResult(
pendingOpidRpc, pendingOpidSnapshot, 326, 4);
EXPECT_TRUE(pendingOpidRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
EXPECT_EQ(pendingOpidResult.transactions.size(), static_cast<size_t>(1));
EXPECT_EQ(pendingOpidResult.transactions[0].txid, std::string("opid-visible-send"));
EXPECT_EQ(pendingOpidResult.transactions[0].type, std::string("send"));
EXPECT_NEAR(pendingOpidResult.transactions[0].amount, -4.25, 0.00000001);
Refresh::TransactionRefreshSnapshot partialFailureSnapshot;
partialFailureSnapshot.shieldedAddresses = {"zs-one"};
partialFailureSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
"old-receive", "receive", 2.50, 140, 8, "zs-one", "", "old memo"
});
MockRefreshRpc partialFailureRpc;
partialFailureRpc.addResponse("listtransactions", json::array({
json{{"txid", "transparent-b"}, {"category", "receive"}, {"amount", 0.75},
{"time", 160}, {"confirmations", 2}, {"address", "R-two"}}
}));
partialFailureRpc.addFailure("z_listreceivedbyaddress", "temporary receive failure");
auto partialFailureResult = Refresh::collectTransactionRefreshResult(
partialFailureRpc, partialFailureSnapshot, 326, 4);
EXPECT_EQ(partialFailureResult.transactions.size(), static_cast<size_t>(2));
EXPECT_EQ(partialFailureResult.transactions[0].txid, std::string("transparent-b"));
EXPECT_EQ(partialFailureResult.transactions[1].txid, std::string("old-receive"));
Refresh::TransactionRefreshSnapshot pagedSnapshot;
MockRefreshRpc pagedRpc;
json firstPage = json::array();
for (int i = 0; i < 1000; ++i) {
firstPage.push_back(json{{"txid", "paged-" + std::to_string(i)}, {"category", "receive"},
{"amount", 0.01}, {"time", i}, {"confirmations", 10},
{"address", "R-page"}});
}
pagedRpc.addResponse("listtransactions", firstPage);
pagedRpc.addResponse("listtransactions", json::array({
json{{"txid", "paged-1000"}, {"category", "receive"}, {"amount", 0.02},
{"time", 2000}, {"confirmations", 11}, {"address", "R-page"}}
}));
auto pagedResult = Refresh::collectTransactionRefreshResult(pagedRpc, pagedSnapshot, 327, 0);
EXPECT_TRUE(pagedRpc.methodNames() == std::vector<std::string>({"listtransactions", "listtransactions"}));
EXPECT_EQ(pagedRpc.calls[0].params, json::array({"", 1000, 0}));
EXPECT_EQ(pagedRpc.calls[1].params, json::array({"", 1000, 1000}));
EXPECT_EQ(pagedResult.transactions.size(), static_cast<size_t>(1001));
EXPECT_EQ(pagedResult.transactions[0].txid, std::string("paged-1000"));
Refresh::TransactionRefreshSnapshot recentSnapshot;
dragonx::TransactionInfo previousShielded;
previousShielded.txid = "shielded-old";
previousShielded.type = "receive";
previousShielded.address = "zs-one";
previousShielded.amount = 3.0;
previousShielded.timestamp = 10;
dragonx::TransactionInfo previousTransparent;
previousTransparent.txid = "recent-one";
previousTransparent.type = "receive";
previousTransparent.address = "R-one";
previousTransparent.amount = 1.0;
previousTransparent.timestamp = 20;
recentSnapshot.previousTransactions = {previousShielded, previousTransparent};
MockRefreshRpc recentRpc;
recentRpc.addResponse("listtransactions", json::array({
json{{"txid", "recent-one"}, {"category", "receive"}, {"address", "R-one"}, {"amount", 2.0}, {"time", 30}, {"confirmations", 0}},
json{{"txid", "recent-two"}, {"category", "send"}, {"address", "R-two"}, {"amount", -0.5}, {"time", 40}, {"confirmations", 0}}
}));
auto recent = Refresh::collectRecentTransactionRefreshResult(recentRpc, recentSnapshot, 123);
EXPECT_TRUE(recentRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
EXPECT_EQ(recentRpc.calls[0].params, json::array({"", 100, 0}));
EXPECT_EQ(recent.blockHeight, 123);
EXPECT_EQ(recent.transactions.size(), static_cast<size_t>(3));
EXPECT_EQ(recent.transactions[0].txid, std::string("recent-two"));
EXPECT_EQ(recent.transactions[1].txid, std::string("recent-one"));
EXPECT_NEAR(recent.transactions[1].amount, 2.0, 0.00000001);
EXPECT_EQ(recent.transactions[2].txid, std::string("shielded-old"));
Refresh::TransactionRefreshSnapshot recentShieldedProbeSnapshot;
recentShieldedProbeSnapshot.shieldedAddresses = {"zs-probe-a", "zs-probe-b"};
recentShieldedProbeSnapshot.shieldedScanStartIndex = 1;
recentShieldedProbeSnapshot.maxShieldedReceiveScans = 1;
recentShieldedProbeSnapshot.shieldedScanHeights = {{"zs-probe-a", 600}, {"zs-probe-b", 600}};
MockRefreshRpc recentShieldedProbeRpc;
recentShieldedProbeRpc.addResponse("listtransactions", json::array());
recentShieldedProbeRpc.addResponse("z_listreceivedbyaddress", json::array({
json{{"txid", "same-tip-shielded"}, {"amount", 1.75}, {"confirmations", 0}, {"time", 170}}
}));
auto recentShieldedProbe = Refresh::collectRecentTransactionRefreshResult(
recentShieldedProbeRpc, recentShieldedProbeSnapshot, 600);
EXPECT_TRUE(recentShieldedProbeRpc.methodNames() == std::vector<std::string>({
"listtransactions", "z_listreceivedbyaddress"
}));
EXPECT_EQ(recentShieldedProbeRpc.calls[1].params, json::array({"zs-probe-b", 0}));
EXPECT_EQ(recentShieldedProbe.nextShieldedScanStartIndex, static_cast<size_t>(0));
EXPECT_EQ(recentShieldedProbe.shieldedAddressesScanned, static_cast<size_t>(1));
EXPECT_EQ(recentShieldedProbe.shieldedScanHeights.at("zs-probe-b"), 600);
EXPECT_EQ(recentShieldedProbe.transactions.size(), static_cast<size_t>(1));
EXPECT_EQ(recentShieldedProbe.transactions[0].txid, std::string("same-tip-shielded"));
EXPECT_EQ(recentShieldedProbe.transactions[0].confirmations, 0);
Refresh::TransactionRefreshSnapshot partialShieldedSnapshot;
partialShieldedSnapshot.shieldedAddresses = {"zs-zero", "zs-one", "zs-two"};
partialShieldedSnapshot.maxShieldedReceiveScans = 2;
partialShieldedSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
"old-zs-two", "receive", 1.0, 90, 10, "zs-two", "", "memo"
});
MockRefreshRpc partialShieldedRpc;
partialShieldedRpc.addResponse("listtransactions", json::array());
partialShieldedRpc.addResponse("z_listreceivedbyaddress", json::array({
json{{"txid", "new-zs-zero"}, {"amount", 0.5}, {"confirmations", 1}, {"time", 100}}
}));
partialShieldedRpc.addResponse("z_listreceivedbyaddress", json::array());
auto partialShielded = Refresh::collectTransactionRefreshResult(
partialShieldedRpc, partialShieldedSnapshot, 400, 0);
EXPECT_TRUE(partialShieldedRpc.methodNames() == std::vector<std::string>({
"listtransactions", "z_listreceivedbyaddress", "z_listreceivedbyaddress"
}));
EXPECT_EQ(partialShieldedRpc.calls[1].params, json::array({"zs-zero", 0}));
EXPECT_EQ(partialShieldedRpc.calls[2].params, json::array({"zs-one", 0}));
EXPECT_FALSE(partialShielded.shieldedScanComplete);
EXPECT_EQ(partialShielded.nextShieldedScanStartIndex, static_cast<size_t>(2));
EXPECT_EQ(partialShielded.shieldedAddressesScanned, static_cast<size_t>(2));
EXPECT_EQ(partialShielded.transactions.size(), static_cast<size_t>(2));
EXPECT_EQ(partialShielded.transactions[0].txid, std::string("new-zs-zero"));
EXPECT_EQ(partialShielded.transactions[1].txid, std::string("old-zs-two"));
partialShieldedSnapshot.shieldedScanStartIndex = partialShielded.nextShieldedScanStartIndex;
partialShieldedSnapshot.shieldedScanHeights = partialShielded.shieldedScanHeights;
MockRefreshRpc finalShieldedRpc;
finalShieldedRpc.addResponse("listtransactions", json::array());
finalShieldedRpc.addResponse("z_listreceivedbyaddress", json::array());
auto finalShielded = Refresh::collectTransactionRefreshResult(
finalShieldedRpc, partialShieldedSnapshot, 400, 0);
EXPECT_TRUE(finalShieldedRpc.methodNames() == std::vector<std::string>({
"listtransactions", "z_listreceivedbyaddress"
}));
EXPECT_EQ(finalShieldedRpc.calls[1].params, json::array({"zs-two", 0}));
EXPECT_TRUE(finalShielded.shieldedScanComplete);
EXPECT_EQ(finalShielded.nextShieldedScanStartIndex, static_cast<size_t>(0));
EXPECT_EQ(finalShielded.shieldedScanHeights.at("zs-zero"), 400);
EXPECT_EQ(finalShielded.shieldedScanHeights.at("zs-one"), 400);
EXPECT_EQ(finalShielded.shieldedScanHeights.at("zs-two"), 400);
Refresh::TransactionRefreshSnapshot cachedShieldedSnapshot;
cachedShieldedSnapshot.shieldedAddresses = {"zs-cached"};
cachedShieldedSnapshot.shieldedScanHeights = {{"zs-cached", 500}};
cachedShieldedSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
"cached-shielded", "receive", 1.5, 120, 20, "zs-cached", "", "cached memo"
});
MockRefreshRpc cachedShieldedRpc;
cachedShieldedRpc.addResponse("listtransactions", json::array());
auto cachedShielded = Refresh::collectTransactionRefreshResult(
cachedShieldedRpc, cachedShieldedSnapshot, 500, 0);
EXPECT_TRUE(cachedShieldedRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
EXPECT_TRUE(cachedShielded.shieldedScanComplete);
EXPECT_EQ(cachedShielded.shieldedAddressesScanned, static_cast<size_t>(0));
EXPECT_EQ(cachedShielded.transactions.size(), static_cast<size_t>(1));
EXPECT_EQ(cachedShielded.transactions[0].txid, std::string("cached-shielded"));
Refresh::TransactionRefreshSnapshot staleProgressSnapshot;
staleProgressSnapshot.shieldedAddresses = {"zs-current", "zs-stale", "zs-missing"};
staleProgressSnapshot.shieldedScanHeights = {{"zs-current", 500}, {"zs-stale", 499}};
staleProgressSnapshot.maxShieldedReceiveScans = 1;
staleProgressSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
"current-shielded", "receive", 0.75, 110, 12, "zs-current", "", ""
});
MockRefreshRpc staleProgressRpc;
staleProgressRpc.addResponse("listtransactions", json::array());
staleProgressRpc.addResponse("z_listreceivedbyaddress", json::array({
json{{"txid", "stale-shielded"}, {"amount", 2.0}, {"confirmations", 1}, {"time", 130}}
}));
auto staleProgress = Refresh::collectTransactionRefreshResult(
staleProgressRpc, staleProgressSnapshot, 500, 0);
EXPECT_TRUE(staleProgressRpc.methodNames() == std::vector<std::string>({
"listtransactions", "z_listreceivedbyaddress"
}));
EXPECT_EQ(staleProgressRpc.calls[1].params, json::array({"zs-stale", 0}));
EXPECT_FALSE(staleProgress.shieldedScanComplete);
EXPECT_EQ(staleProgress.nextShieldedScanStartIndex, static_cast<size_t>(2));
EXPECT_EQ(staleProgress.shieldedAddressesScanned, static_cast<size_t>(1));
EXPECT_EQ(staleProgress.shieldedScanHeights.at("zs-current"), 500);
EXPECT_EQ(staleProgress.shieldedScanHeights.at("zs-stale"), 500);
EXPECT_TRUE(staleProgress.shieldedScanHeights.find("zs-missing") == staleProgress.shieldedScanHeights.end());
EXPECT_EQ(staleProgress.transactions.size(), static_cast<size_t>(2));
EXPECT_EQ(staleProgress.transactions[0].txid, std::string("stale-shielded"));
EXPECT_EQ(staleProgress.transactions[1].txid, std::string("current-shielded"));
Refresh::TransactionRefreshSnapshot miningSnapshot;
miningSnapshot.shieldedAddresses = {"zs-mine"};
miningSnapshot.miningAddresses = {"R-mine", "zs-mine"};
MockRefreshRpc miningTxRpc;
miningTxRpc.addResponse("listtransactions", json::array({
json{{"txid", "transparent-mined"}, {"category", "receive"}, {"amount", 3.0},
{"time", 220}, {"confirmations", 101}, {"address", "R-mine"}}
}));
miningTxRpc.addResponse("z_listreceivedbyaddress", json::array({
json{{"txid", "shielded-mined"}, {"amount", 4.0}, {"confirmations", 102},
{"time", 210}, {"memoStr", "pool"}}
}));
auto miningTxResult = Refresh::collectTransactionRefreshResult(miningTxRpc, miningSnapshot, 328, 0);
EXPECT_EQ(miningTxResult.transactions.size(), static_cast<size_t>(2));
EXPECT_EQ(miningTxResult.transactions[0].type, std::string("mined"));
EXPECT_EQ(miningTxResult.transactions[1].type, std::string("mined"));
}
void testNetworkRefreshResultModels()
@@ -1087,7 +1356,7 @@ void testNetworkRefreshResultModels()
auto core = Refresh::parseCoreRefreshResult(
json{{"private", "1.25000000"}, {"transparent", "0.50000000"}, {"total", "1.75000000"}},
true,
json{{"blocks", 100}, {"headers", 105}, {"verificationprogress", 0.75},
json{{"blocks", 100}, {"headers", 105}, {"bestblockhash", "apply-best-100"}, {"verificationprogress", 0.75},
{"longestchain", 110}, {"notarized", 90}},
true);
Refresh::applyCoreRefreshResult(state, core, 1234);
@@ -1096,6 +1365,7 @@ void testNetworkRefreshResultModels()
EXPECT_NEAR(state.total_balance, 1.75, 0.00000001);
EXPECT_EQ(state.sync.blocks, 100);
EXPECT_EQ(state.sync.headers, 105);
EXPECT_EQ(state.sync.best_blockhash, std::string("apply-best-100"));
EXPECT_TRUE(state.sync.syncing);
EXPECT_EQ(state.longestchain, 110);
EXPECT_EQ(state.notarized, 90);
@@ -1117,7 +1387,6 @@ void testNetworkRefreshResultModels()
EXPECT_TRUE(state.encrypted);
EXPECT_TRUE(state.locked);
EXPECT_TRUE(state.encryption_state_known);
auto unencrypted = Refresh::parseWalletEncryptionResult(json::object());
Refresh::applyWalletEncryptionResult(state, unencrypted);
EXPECT_FALSE(state.encrypted);
@@ -1297,6 +1566,36 @@ void testNetworkRefreshResultModels()
EXPECT_EQ(confirmedBlock, 222);
}
void testOperationStatusPollParsing()
{
using Refresh = dragonx::services::NetworkRefreshService;
using nlohmann::json;
auto parsed = Refresh::parseOperationStatusPoll(json::array({
json{{"id", "op-success"}, {"status", "success"}, {"result", json{{"txid", "tx-success"}}}},
json{{"id", "op-failed"}, {"status", "failed"}, {"error", json{{"message", "bad memo"}}}},
json{{"id", "op-running"}, {"status", "executing"}}
}), {"op-success", "op-failed", "op-running", "op-stale"});
EXPECT_TRUE(parsed.anySuccess);
EXPECT_EQ(parsed.doneOpids.size(), static_cast<size_t>(2));
EXPECT_EQ(parsed.doneOpids[0], std::string("op-success"));
EXPECT_EQ(parsed.doneOpids[1], std::string("op-failed"));
EXPECT_EQ(parsed.successTxids.size(), static_cast<size_t>(1));
EXPECT_EQ(parsed.successTxids[0], std::string("tx-success"));
EXPECT_EQ(parsed.successTxidsByOpid.size(), static_cast<size_t>(1));
EXPECT_EQ(parsed.successTxidsByOpid.at("op-success"), std::string("tx-success"));
EXPECT_EQ(parsed.failureMessages.size(), static_cast<size_t>(1));
EXPECT_EQ(parsed.failureMessages[0], std::string("bad memo"));
EXPECT_EQ(parsed.staleOpids.size(), static_cast<size_t>(1));
EXPECT_EQ(parsed.staleOpids[0], std::string("op-stale"));
auto malformed = Refresh::parseOperationStatusPoll(json{{"status", "success"}}, {"op-keep"});
EXPECT_FALSE(malformed.anySuccess);
EXPECT_TRUE(malformed.doneOpids.empty());
EXPECT_TRUE(malformed.staleOpids.empty());
}
void testWalletSecurityController()
{
using dragonx::services::WalletSecurityController;
@@ -1677,9 +1976,13 @@ void testRendererHelpers()
EXPECT_FALSE(dragonx::ui::IsPoolMiningActive(true, false, true));
EXPECT_TRUE(dragonx::ui::IsPoolMiningActive(false, false, true));
dragonx::ui::ConsoleOutputFilter filter{"error", false, false, 10, 20};
dragonx::ui::ConsoleOutputFilter filter{"error", false, false, false, 10, 20, 30};
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("RPC Error", 20, filter));
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("daemon line", 10, filter));
filter.text.clear();
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("[rpc] History -> listtransactions", 30, filter));
filter.rpcTraceEnabled = true;
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("[rpc] History -> listtransactions", 30, filter));
filter.daemonMessagesEnabled = true;
filter.errorsOnly = true;
filter.text.clear();
@@ -1838,9 +2141,9 @@ void testBalanceAddressListModel()
addresses.push_back(tFav);
std::vector<dragonx::ui::AddressListInput> inputs = {
{&addresses[0], true, false, false, "Main Vault", "", -1},
{&addresses[1], false, false, false, "Empty", "", -1},
{&addresses[2], false, false, true, "Favorite", "", -1}
{&addresses[0], true, false, false, false, "Main Vault", "", -1},
{&addresses[1], false, false, false, false, "Empty", "", -1},
{&addresses[2], false, false, true, false, "Favorite", "", -1}
};
auto rows = dragonx::ui::BuildAddressListRows(inputs, "", true, false);
@@ -1869,6 +2172,255 @@ void testBalanceAddressListModel()
EXPECT_EQ(dragonx::ui::FormatAddressUsdValue(0.0, 2.0), std::string(""));
}
void testExplorerBlockCache()
{
using dragonx::ui::ExplorerBlockCache;
using dragonx::ui::ExplorerBlockSummary;
using nlohmann::json;
fs::path dir = makeTempDir();
fs::path databasePath = dir / "explorer_blocks.sqlite";
fs::path legacyPath = dir / "explorer_blocks_cache.json";
json legacy = {
{"version", 1},
{"tip_height", 10},
{"tip_hash", "hash-10"},
{"blocks", json::array({
json{{"height", 10}, {"hash", "hash-10"}, {"tx_count", 3}, {"size", 1000}, {"time", 5000}, {"difficulty", 1.25}},
json{{"height", 9}, {"hash", "hash-9"}, {"tx_count", 2}, {"size", 900}, {"time", 4900}, {"difficulty", 1.20}}
})}
};
{
std::ofstream file(legacyPath);
file << legacy.dump(2);
}
ExplorerBlockCache cache(databasePath.string(), legacyPath.string());
EXPECT_TRUE(cache.ensureOpen());
EXPECT_EQ(cache.cachedBlockCount(), 2);
auto range = cache.loadRange(9, 10);
EXPECT_EQ(range.size(), static_cast<size_t>(2));
EXPECT_EQ(range[10].hash, std::string("hash-10"));
EXPECT_EQ(range[9].tx_count, 2);
auto sameTipValidation = cache.prepareValidation(10, "hash-10");
EXPECT_FALSE(sameTipValidation.needed);
auto advancedValidation = cache.prepareValidation(12, "hash-12");
EXPECT_TRUE(advancedValidation.needed);
EXPECT_EQ(advancedValidation.height, 10);
EXPECT_EQ(advancedValidation.expectedHash, std::string("hash-10"));
cache.applySavedTipValidation(advancedValidation, "hash-10", 12, "hash-12");
ExplorerBlockSummary block12;
block12.height = 12;
block12.hash = "hash-12";
block12.tx_count = 5;
block12.size = 1200;
block12.time = 5200;
block12.difficulty = 1.40;
EXPECT_TRUE(cache.storeBlock(block12));
EXPECT_EQ(cache.loadRange(12, 12)[12].tx_count, 5);
{
ExplorerBlockCache reopened(databasePath.string(), legacyPath.string());
EXPECT_TRUE(reopened.ensureOpen());
EXPECT_EQ(reopened.cachedBlockCount(), 3);
auto noRepeatValidation = reopened.prepareValidation(12, "hash-12");
EXPECT_FALSE(noRepeatValidation.needed);
}
cache.prepareValidation(12, "different-tip");
EXPECT_EQ(cache.cachedBlockCount(), 0);
{
ExplorerBlockCache reopened(databasePath.string(), legacyPath.string());
EXPECT_TRUE(reopened.ensureOpen());
EXPECT_EQ(reopened.cachedBlockCount(), 0);
}
fs::remove_all(dir);
}
void testTransactionHistoryCache()
{
using dragonx::TransactionInfo;
using dragonx::data::TransactionHistoryCache;
std::string identityA = TransactionHistoryCache::walletIdentityFromAddresses(
{"zs-beta", "zs-alpha"}, {"R-one"});
std::string identityB = TransactionHistoryCache::walletIdentityFromAddresses(
{"zs-alpha", "zs-beta"}, {"R-one"});
std::string identityWithSameText = TransactionHistoryCache::walletIdentityFromAddresses(
{"same-address-text"}, {"same-address-text"});
EXPECT_EQ(identityA, identityB);
EXPECT_TRUE(identityA.find("protocol=") == std::string::npos);
EXPECT_TRUE(identityA.find("p2p_port=") == std::string::npos);
EXPECT_TRUE(identityA.find("z:zs-alpha") != std::string::npos);
EXPECT_TRUE(identityA.find("t:R-one") != std::string::npos);
EXPECT_TRUE(identityWithSameText.find("z:same-address-text") != std::string::npos);
EXPECT_TRUE(identityWithSameText.find("t:same-address-text") != std::string::npos);
fs::path dir = makeTempDir();
fs::path databasePath = dir / "transaction_history.sqlite";
std::string walletIdentity = "mainnet|R-alpha|zs-beta";
std::string passphrase = "correct horse battery staple";
std::vector<TransactionInfo> transactions;
transactions.push_back(TransactionInfo{
"tx-sensitive-send", "send", -1.25, 1700000100, 12,
"zs-destination-sensitive", "R-source-sensitive", "private memo"
});
transactions.push_back(TransactionInfo{
"tx-mined", "mined", 3.5, 1700000000, 104,
"R-mining-address", "", ""
});
std::unordered_map<std::string, int> shieldedScanHeights{
{"zs-beta", 120},
{"zs-archive", 118}
};
{
TransactionHistoryCache cache(databasePath.string());
EXPECT_TRUE(cache.ensureOpen());
EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
EXPECT_TRUE(cache.replace(walletIdentity, 120, "tip-120", transactions, 1700000200, shieldedScanHeights));
EXPECT_EQ(cache.snapshotCount(), 1);
}
{
std::ifstream database(databasePath, std::ios::binary);
std::string bytes((std::istreambuf_iterator<char>(database)), std::istreambuf_iterator<char>());
EXPECT_TRUE(bytes.find("tx-sensitive-send") == std::string::npos);
EXPECT_TRUE(bytes.find("zs-destination-sensitive") == std::string::npos);
EXPECT_TRUE(bytes.find("zs-archive") == std::string::npos);
EXPECT_TRUE(bytes.find("private memo") == std::string::npos);
}
{
TransactionHistoryCache wrongKey(databasePath.string());
EXPECT_TRUE(wrongKey.unlockWithPassphrase(walletIdentity, "wrong passphrase"));
auto wrongLoad = wrongKey.load(walletIdentity, 120, "tip-120");
EXPECT_FALSE(wrongLoad.loaded);
}
{
TransactionHistoryCache reopened(databasePath.string());
EXPECT_TRUE(reopened.unlockWithPassphrase(walletIdentity, passphrase));
auto loaded = reopened.load(walletIdentity, 120, "tip-120");
EXPECT_TRUE(loaded.loaded);
EXPECT_FALSE(loaded.invalidated);
EXPECT_EQ(loaded.tipHeight, 120);
EXPECT_EQ(loaded.tipHash, std::string("tip-120"));
EXPECT_EQ(loaded.updatedAt, static_cast<std::time_t>(1700000200));
EXPECT_EQ(loaded.transactions.size(), static_cast<size_t>(2));
EXPECT_EQ(loaded.transactions[0].txid, std::string("tx-sensitive-send"));
EXPECT_EQ(loaded.transactions[0].memo, std::string("private memo"));
EXPECT_EQ(loaded.transactions[1].type, std::string("mined"));
EXPECT_EQ(loaded.shieldedScanHeights.size(), static_cast<size_t>(2));
EXPECT_EQ(loaded.shieldedScanHeights.at("zs-beta"), 120);
EXPECT_EQ(loaded.shieldedScanHeights.at("zs-archive"), 118);
}
{
TransactionHistoryCache cache(databasePath.string());
EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
EXPECT_TRUE(cache.replace(walletIdentity, 120, "tip-120", transactions, 1700000250));
auto loaded = cache.load(walletIdentity, 120, "tip-120");
EXPECT_TRUE(loaded.loaded);
EXPECT_EQ(loaded.shieldedScanHeights.size(), static_cast<size_t>(0));
}
{
TransactionHistoryCache staleTip(databasePath.string());
EXPECT_TRUE(staleTip.unlockWithPassphrase(walletIdentity, passphrase));
auto invalidated = staleTip.load(walletIdentity, 119, "tip-119");
EXPECT_FALSE(invalidated.loaded);
EXPECT_TRUE(invalidated.invalidated);
EXPECT_EQ(staleTip.snapshotCount(), 0);
}
{
TransactionHistoryCache cache(databasePath.string());
EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
EXPECT_TRUE(cache.replace(walletIdentity, 120, "tip-120", transactions, 1700000300));
auto invalidated = cache.load(walletIdentity, 120, "different-tip");
EXPECT_FALSE(invalidated.loaded);
EXPECT_TRUE(invalidated.invalidated);
EXPECT_EQ(cache.snapshotCount(), 0);
}
fs::remove_all(dir);
}
void testTransactionHistoryCacheRefreshApply()
{
using Refresh = dragonx::services::NetworkRefreshService;
using dragonx::TransactionInfo;
using dragonx::data::TransactionHistoryCache;
fs::path dir = makeTempDir();
fs::path databasePath = dir / "transaction_history_refresh.sqlite";
std::string walletIdentity = "mainnet|R-refresh-cache|zs-refresh-cache";
std::string passphrase = "refresh cache passphrase";
dragonx::WalletState state;
state.sync.blocks = 333;
state.sync.best_blockhash = "tip-333";
Refresh::TransactionRefreshResult refreshResult;
refreshResult.blockHeight = 333;
TransactionInfo refreshedTx;
refreshedTx.txid = "rpc-refreshed-send";
refreshedTx.type = "send";
refreshedTx.amount = -2.75;
refreshedTx.timestamp = 1700000400;
refreshedTx.confirmations = 14;
refreshedTx.address = "zs-rpc-destination";
refreshedTx.from_address = "R-refresh-cache";
refreshedTx.memo = "rpc refreshed memo";
refreshResult.transactions = {refreshedTx};
Refresh::TransactionViewCache viewCache;
std::unordered_set<std::string> sendTxids;
std::vector<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(refreshResult), 1700000500);
TransactionHistoryCache cache(databasePath.string());
EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
EXPECT_TRUE(cache.replace(walletIdentity,
state.sync.blocks,
state.sync.best_blockhash,
state.transactions,
state.last_tx_update));
TransactionHistoryCache reopened(databasePath.string());
EXPECT_TRUE(reopened.unlockWithPassphrase(walletIdentity, passphrase));
auto loaded = reopened.load(walletIdentity, 333, "tip-333");
EXPECT_TRUE(loaded.loaded);
EXPECT_EQ(loaded.transactions.size(), static_cast<size_t>(1));
EXPECT_EQ(loaded.transactions[0].txid, std::string("rpc-refreshed-send"));
EXPECT_EQ(loaded.transactions[0].memo, std::string("rpc refreshed memo"));
EXPECT_EQ(loaded.updatedAt, static_cast<std::time_t>(1700000500));
EXPECT_EQ(lastTxBlock, 333);
EXPECT_EQ(confirmedIds.count("rpc-refreshed-send"), static_cast<size_t>(1));
fs::remove_all(dir);
}
void testGeneratedResourceBehavior()
{
const auto* themes = dragonx::resources::getEmbeddedThemes();
@@ -1893,6 +2445,7 @@ int main()
testNetworkRefreshSnapshotHelpers();
testNetworkRefreshRpcCollectors();
testNetworkRefreshResultModels();
testOperationStatusPollParsing();
testWalletSecurityController();
testWalletSecurityWorkflow();
testWalletSecurityWorkflowExecutor();
@@ -1903,6 +2456,9 @@ int main()
testConsoleInputModel();
testMiningBenchmarkModel();
testBalanceAddressListModel();
testExplorerBlockCache();
testTransactionHistoryCache();
testTransactionHistoryCacheRefreshApply();
testGeneratedResourceBehavior();
if (g_failures != 0) {