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

@@ -118,6 +118,24 @@ void appendTrackedSendPlaceholder(std::vector<TransactionInfo>& transactions,
transactions.push_back(std::move(info));
}
void appendMissingPreviousTransactions(std::vector<TransactionInfo>& transactions,
const std::vector<TransactionInfo>& previousTransactions,
bool includeAll,
const std::unordered_set<std::string>& pinnedTxids)
{
for (const auto& transaction : previousTransactions) {
bool pinned = pinnedTxids.find(transaction.txid) != pinnedTxids.end();
if (!includeAll && !pinned) {
bool shieldedRelated = (!transaction.address.empty() && transaction.address[0] == 'z') ||
(!transaction.from_address.empty() && transaction.from_address[0] == 'z') ||
!transaction.memo.empty();
if (!shieldedRelated) continue;
}
if (hasTransactionType(transactions, transaction.txid, transaction.type)) continue;
transactions.push_back(transaction);
}
}
} // namespace
NetworkRefreshService::ConnectionInfoResult NetworkRefreshService::parseConnectionInfoResult(const json& info)
@@ -165,14 +183,20 @@ NetworkRefreshService::WarmupPollResult NetworkRefreshService::collectWarmupPoll
return result;
}
NetworkRefreshService::ConnectionInitResult NetworkRefreshService::collectConnectionInitResult(RefreshRpcGateway& rpc)
NetworkRefreshService::ConnectionInitResult NetworkRefreshService::collectConnectionInitResult(
RefreshRpcGateway& rpc,
const std::optional<ConnectionInfoResult>& prefetchedInfo)
{
ConnectionInitResult result;
try {
json info = rpc.call("getinfo", json::array());
result.info = parseConnectionInfoResult(info);
} catch (...) {}
if (prefetchedInfo && prefetchedInfo->ok) {
result.info = *prefetchedInfo;
} else {
try {
json info = rpc.call("getinfo", json::array());
result.info = parseConnectionInfoResult(info);
} catch (...) {}
}
try {
json walletInfo = rpc.call("getwalletinfo", json::array());
@@ -197,6 +221,7 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefresh
if (result.blockchainOk) {
result.blocks = readOptional<int>(blockInfo, "blocks");
result.headers = readOptional<int>(blockInfo, "headers");
result.bestBlockHash = readOptional<std::string>(blockInfo, "bestblockhash");
result.verificationProgress = readOptional<double>(blockInfo, "verificationprogress");
result.longestChain = readOptional<int>(blockInfo, "longestchain");
result.notarized = readOptional<int>(blockInfo, "notarized");
@@ -255,18 +280,21 @@ NetworkRefreshService::MiningRefreshResult NetworkRefreshService::parseMiningRef
NetworkRefreshService::MiningRefreshResult NetworkRefreshService::collectMiningRefreshResult(
RefreshRpcGateway& rpc,
double daemonMemoryMb,
bool includeSlowRefresh)
bool includeSlowRefresh,
bool includeLocalHashrate)
{
json miningInfo;
json localHashrate;
bool miningOk = false;
bool hashrateOk = false;
try {
localHashrate = rpc.call("getlocalsolps", json::array());
hashrateOk = true;
} catch (const std::exception& e) {
DEBUG_LOGF("getLocalHashrate error: %s\n", e.what());
if (includeLocalHashrate) {
try {
localHashrate = rpc.call("getlocalsolps", json::array());
hashrateOk = true;
} catch (const std::exception& e) {
DEBUG_LOGF("getLocalHashrate error: %s\n", e.what());
}
}
if (includeSlowRefresh) {
@@ -448,7 +476,18 @@ void NetworkRefreshService::applyTransparentBalancesFromUnspent(std::vector<Addr
applyBalancesFromUnspent(addresses, unspent);
}
NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddressRefreshResult(RefreshRpcGateway& rpc)
NetworkRefreshService::AddressRefreshSnapshot NetworkRefreshService::buildAddressRefreshSnapshot(const WalletState& state)
{
AddressRefreshSnapshot snapshot;
for (const auto& info : state.z_addresses) {
if (!info.address.empty()) snapshot.shieldedSpendingKeys[info.address] = info.has_spending_key;
}
return snapshot;
}
NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddressRefreshResult(
RefreshRpcGateway& rpc,
const AddressRefreshSnapshot& snapshot)
{
AddressRefreshResult result;
@@ -463,16 +502,25 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
continue;
}
json validationResult;
bool validationSucceeded = false;
try {
validationResult = rpc.call("z_validateaddress", json::array({address}));
validationSucceeded = true;
} catch (...) {
// Older daemons can fail validation for wallet-owned addresses.
auto cached = snapshot.shieldedSpendingKeys.find(address);
if (cached != snapshot.shieldedSpendingKeys.end()) {
AddressInfo info;
info.address = address;
info.type = "shielded";
info.has_spending_key = cached->second;
result.shieldedAddresses.push_back(std::move(info));
} else {
json validationResult;
bool validationSucceeded = false;
try {
validationResult = rpc.call("z_validateaddress", json::array({address}));
validationSucceeded = true;
} catch (...) {
// Older daemons can fail validation for wallet-owned addresses.
}
result.shieldedAddresses.push_back(
buildShieldedAddressInfo(address, validationResult, validationSucceeded));
}
result.shieldedAddresses.push_back(
buildShieldedAddressInfo(address, validationResult, validationSucceeded));
}
}
} catch (const std::exception& e) {
@@ -531,12 +579,14 @@ NetworkRefreshService::TransactionRefreshSnapshot NetworkRefreshService::buildTr
snapshot.viewTxCache = viewTxCache;
snapshot.sendTxids = sendTxids;
snapshot.previousTransactions = state.transactions;
return snapshot;
}
void NetworkRefreshService::appendTransparentTransactions(std::vector<TransactionInfo>& transactions,
std::set<std::string>& knownTxids,
const json& result)
const json& result,
const std::set<std::string>& miningAddresses)
{
if (!result.is_array()) return;
@@ -552,6 +602,9 @@ void NetworkRefreshService::appendTransparentTransactions(std::vector<Transactio
}
if (auto value = readOptional<int>(transactionJson, "confirmations")) info.confirmations = *value;
if (auto value = readOptional<std::string>(transactionJson, "address")) info.address = *value;
if (info.type == "receive" && !info.address.empty() && miningAddresses.count(info.address)) {
info.type = "mined";
}
if (!info.txid.empty()) knownTxids.insert(info.txid);
transactions.push_back(std::move(info));
}
@@ -560,7 +613,8 @@ void NetworkRefreshService::appendTransparentTransactions(std::vector<Transactio
void NetworkRefreshService::appendShieldedReceivedTransactions(std::vector<TransactionInfo>& transactions,
std::set<std::string>& knownTxids,
const std::string& address,
const json& received)
const json& received,
const std::set<std::string>& miningAddresses)
{
if (received.is_null() || !received.is_array()) return;
@@ -582,7 +636,7 @@ void NetworkRefreshService::appendShieldedReceivedTransactions(std::vector<Trans
TransactionInfo info;
info.txid = *txid;
info.type = "receive";
info.type = miningAddresses.count(address) ? "mined" : "receive";
info.address = address;
if (auto value = readOptional<double>(note, "amount")) info.amount = *value;
if (auto value = readOptional<int>(note, "confirmations")) info.confirmations = *value;
@@ -680,26 +734,73 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
{
TransactionRefreshResult result;
result.blockHeight = currentBlockHeight;
result.shieldedAddressCount = snapshot.shieldedAddresses.size();
result.shieldedScanHeights = snapshot.shieldedScanHeights;
std::set<std::string> knownTxids;
bool transactionRpcError = false;
try {
json transactions = rpc.call("listtransactions", json::array({"", 9999}));
appendTransparentTransactions(result.transactions, knownTxids, transactions);
constexpr int pageSize = 1000;
int skip = 0;
while (true) {
json transactions = rpc.call("listtransactions", json::array({"", pageSize, skip}));
appendTransparentTransactions(result.transactions, knownTxids, transactions, snapshot.miningAddresses);
if (!transactions.is_array() || transactions.size() < static_cast<std::size_t>(pageSize)) break;
skip += pageSize;
}
} catch (const std::exception& e) {
transactionRpcError = true;
DEBUG_LOGF("listtransactions error: %s\n", e.what());
}
for (const auto& address : snapshot.shieldedAddresses) {
auto scannedAtTip = [&](const std::string& address) {
if (currentBlockHeight < 0) return false;
auto it = result.shieldedScanHeights.find(address);
return it != result.shieldedScanHeights.end() && it->second >= currentBlockHeight;
};
std::size_t shieldedStart = snapshot.shieldedScanStartIndex;
if (shieldedStart >= snapshot.shieldedAddresses.size()) shieldedStart = 0;
std::size_t nextSearchIndex = shieldedStart;
for (std::size_t index = shieldedStart; index < snapshot.shieldedAddresses.size(); ++index) {
const auto& address = snapshot.shieldedAddresses[index];
if (scannedAtTip(address)) {
nextSearchIndex = index + 1;
continue;
}
if (snapshot.maxShieldedReceiveScans > 0 &&
result.shieldedAddressesScanned >= snapshot.maxShieldedReceiveScans) {
nextSearchIndex = index;
break;
}
++result.shieldedAddressesScanned;
nextSearchIndex = index + 1;
try {
json received = rpc.call("z_listreceivedbyaddress", json::array({address, 0}));
appendShieldedReceivedTransactions(result.transactions, knownTxids, address, received);
appendShieldedReceivedTransactions(result.transactions, knownTxids, address, received, snapshot.miningAddresses);
if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight;
} catch (const std::exception& e) {
DEBUG_LOGF("z_listreceivedbyaddress error for %s: %s\n",
address.substr(0, 12).c_str(), e.what());
transactionRpcError = true;
DEBUG_LOGF("z_listreceivedbyaddress error: %s\n", e.what());
}
}
auto findPendingShieldedIndex = [&]() -> std::optional<std::size_t> {
if (snapshot.shieldedAddresses.empty()) return std::nullopt;
std::size_t start = nextSearchIndex < snapshot.shieldedAddresses.size() ? nextSearchIndex : 0;
for (std::size_t offset = 0; offset < snapshot.shieldedAddresses.size(); ++offset) {
std::size_t index = (start + offset) % snapshot.shieldedAddresses.size();
if (!scannedAtTip(snapshot.shieldedAddresses[index])) return index;
}
return std::nullopt;
};
auto pendingShieldedIndex = findPendingShieldedIndex();
result.shieldedScanComplete = !pendingShieldedIndex.has_value();
result.nextShieldedScanStartIndex = pendingShieldedIndex.value_or(0);
for (const auto& txid : snapshot.sendTxids) {
knownTxids.insert(txid);
}
@@ -784,10 +885,140 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
}
}
if (!snapshot.previousTransactions.empty()) {
if (transactionRpcError) {
appendMissingPreviousTransactions(result.transactions, snapshot.previousTransactions, true, snapshot.pendingOpids);
} else if (snapshot.shieldedAddresses.empty() || !result.shieldedScanComplete ||
result.shieldedAddressesScanned < result.shieldedAddressCount) {
appendMissingPreviousTransactions(result.transactions, snapshot.previousTransactions, false, snapshot.pendingOpids);
}
}
sortTransactionsNewestFirst(result.transactions);
return result;
}
NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRecentTransactionRefreshResult(
RefreshRpcGateway& rpc,
const TransactionRefreshSnapshot& snapshot,
int currentBlockHeight,
int pageSize)
{
TransactionRefreshResult result;
result.blockHeight = currentBlockHeight;
result.transactions = snapshot.previousTransactions;
result.shieldedAddressCount = snapshot.shieldedAddresses.size();
result.shieldedScanHeights = snapshot.shieldedScanHeights;
try {
std::set<std::string> recentTxids;
std::vector<TransactionInfo> recentTransactions;
json transactions = rpc.call("listtransactions", json::array({"", pageSize, 0}));
appendTransparentTransactions(recentTransactions, recentTxids, transactions, snapshot.miningAddresses);
for (auto& recent : recentTransactions) {
bool replaced = false;
for (auto& existing : result.transactions) {
if (existing.txid == recent.txid && existing.type == recent.type) {
existing = recent;
replaced = true;
break;
}
}
if (!replaced) result.transactions.push_back(std::move(recent));
}
} catch (const std::exception& e) {
DEBUG_LOGF("recent listtransactions error: %s\n", e.what());
}
std::size_t shieldedStart = snapshot.shieldedScanStartIndex;
if (shieldedStart >= snapshot.shieldedAddresses.size()) shieldedStart = 0;
std::size_t shieldedLimit = snapshot.shieldedAddresses.size() - shieldedStart;
if (snapshot.maxShieldedReceiveScans > 0) {
shieldedLimit = std::min(shieldedLimit, snapshot.maxShieldedReceiveScans);
}
for (std::size_t index = shieldedStart; index < shieldedStart + shieldedLimit; ++index) {
const auto& address = snapshot.shieldedAddresses[index];
try {
std::vector<TransactionInfo> scannedTransactions;
std::set<std::string> scannedTxids;
json received = rpc.call("z_listreceivedbyaddress", json::array({address, 0}));
appendShieldedReceivedTransactions(scannedTransactions, scannedTxids, address, received, snapshot.miningAddresses);
for (auto& scanned : scannedTransactions) {
bool replaced = false;
for (auto& existing : result.transactions) {
if (existing.txid == scanned.txid && existing.type == scanned.type) {
existing = scanned;
replaced = true;
break;
}
}
if (!replaced) result.transactions.push_back(std::move(scanned));
}
if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight;
++result.shieldedAddressesScanned;
} catch (const std::exception& e) {
DEBUG_LOGF("recent z_listreceivedbyaddress error: %s\n", e.what());
}
}
result.nextShieldedScanStartIndex =
(shieldedStart + shieldedLimit >= snapshot.shieldedAddresses.size()) ? 0 : shieldedStart + shieldedLimit;
result.shieldedScanComplete = true;
sortTransactionsNewestFirst(result.transactions);
return result;
}
NetworkRefreshService::OperationStatusPollResult NetworkRefreshService::parseOperationStatusPoll(
const json& result,
const std::vector<std::string>& requestedOpids)
{
OperationStatusPollResult parsed;
if (!result.is_array()) return parsed;
std::set<std::string> reported;
for (const auto& op : result) {
if (!op.is_object()) continue;
std::string opid = op.value("id", std::string());
if (opid.empty()) continue;
reported.insert(opid);
std::string status = op.value("status", std::string());
if (status == "success") {
parsed.doneOpids.push_back(opid);
parsed.anySuccess = true;
if (op.contains("result") && op["result"].is_object() && op["result"].contains("txid")) {
try {
std::string txid = op["result"]["txid"].get<std::string>();
if (!txid.empty()) {
parsed.successTxids.push_back(txid);
parsed.successTxidsByOpid[opid] = std::move(txid);
}
} catch (...) {}
}
} else if (status == "failed" || status == "cancelled" || status == "canceled") {
parsed.doneOpids.push_back(opid);
std::string msg = status == "failed" ? "Transaction failed" : "Transaction cancelled";
if (op.contains("error") && op["error"].is_object() && op["error"].contains("message")) {
try {
msg = op["error"]["message"].get<std::string>();
} catch (...) {}
}
parsed.failureMessages.push_back(msg);
}
}
for (const auto& opid : requestedOpids) {
if (!opid.empty() && reported.find(opid) == reported.end()) {
parsed.staleOpids.push_back(opid);
}
}
return parsed;
}
void NetworkRefreshService::applyConnectionInfoResult(WalletState& state, const ConnectionInfoResult& result)
{
if (!result.ok) return;
@@ -838,6 +1069,7 @@ void NetworkRefreshService::applyCoreRefreshResult(WalletState& state,
if (result.blocks) state.sync.blocks = *result.blocks;
if (result.headers) state.sync.headers = *result.headers;
if (result.bestBlockHash) state.sync.best_blockhash = *result.bestBlockHash;
if (result.verificationProgress) state.sync.verification_progress = *result.verificationProgress;
if (result.longestChain && *result.longestChain > 0) state.longestchain = *result.longestChain;
if (state.longestchain > 0 && state.sync.blocks > state.longestchain) state.longestchain = state.sync.blocks;
@@ -952,12 +1184,10 @@ void NetworkRefreshService::applyTransactionRefreshResult(WalletState& state,
bool NetworkRefreshService::shouldRefreshTransactions(int lastTxBlockHeight,
int currentBlockHeight,
bool transactionsEmpty,
bool transactionsDirty) const
{
return scheduler_.shouldRefreshTransactions(lastTxBlockHeight,
currentBlockHeight,
transactionsEmpty,
transactionsDirty);
}