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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user