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:
2026-05-05 03:22:14 -05:00
parent 948ef419ac
commit 975743f754
43 changed files with 3732 additions and 702 deletions

View File

@@ -80,6 +80,7 @@
#include <algorithm>
#include <map>
#include <set>
#include <unordered_set>
#include <fstream>
#include <filesystem>
#include <thread>
@@ -103,6 +104,7 @@ bool App::sendStopCommandSafely(rpc::RPCClient& client, const char* context)
{
const char* label = context ? context : "App";
try {
rpc::RPCClient::TraceScope trace("Daemon lifecycle / Stop command");
client.call("stop");
DEBUG_LOGF("[%s] Stop command sent\n", label);
return true;
@@ -405,10 +407,12 @@ void App::update()
// Poll getrescaninfo for rescan progress (if rescan flag is set)
// Use fast_rpc_ when available to avoid blocking on rpc_'s
// curl_mutex (which may be held by a long-running import).
if (state_.sync.rescanning && fast_worker_) {
if (state_.sync.rescanning && fast_worker_ && !rescan_status_poll_in_progress_) {
auto* rescanRpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
rescan_status_poll_in_progress_ = true;
fast_worker_->post([this, rescanRpc]() -> rpc::RPCWorker::MainCb {
try {
rpc::RPCClient::TraceScope trace("Startup / Rescan monitor");
auto info = rescanRpc->call("getrescaninfo");
bool rescanning = info.value("rescanning", false);
float progress = 0.0f;
@@ -417,6 +421,7 @@ void App::update()
try { progress = std::stof(progStr) * 100.0f; } catch (...) {}
}
return [this, rescanning, progress]() {
rescan_status_poll_in_progress_ = false;
if (rescanning) {
state_.sync.rescanning = true;
if (progress > 0.0f) {
@@ -432,7 +437,7 @@ void App::update()
};
} catch (...) {
// RPC not available yet or failed
return [](){};
return [this](){ rescan_status_poll_in_progress_ = false; };
}
});
}
@@ -573,70 +578,59 @@ void App::update()
// Poll pending z_sendmany operations for completion
if (network_refresh_.isDue(RefreshTimer::Opid) && !pending_opids_.empty()
&& state_.connected && fast_worker_) {
&& state_.connected && fast_worker_ && !opid_poll_in_progress_) {
network_refresh_.reset(RefreshTimer::Opid);
auto opids = pending_opids_; // copy for worker thread
opid_poll_in_progress_ = true;
fast_worker_->post([this, opids]() -> rpc::RPCWorker::MainCb {
auto* rpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
if (!rpc) return [](){};
if (!rpc) return [this](){ opid_poll_in_progress_ = false; };
json ids = json::array();
for (const auto& id : opids) ids.push_back(id);
json result;
try {
rpc::RPCClient::TraceScope trace("Send tab / Operation status");
result = rpc->call("z_getoperationstatus", {ids});
} catch (...) {
return [](){};
return [this](){ opid_poll_in_progress_ = false; };
}
// Collect completed/failed opids
std::vector<std::string> done;
bool anySuccess = false;
for (const auto& op : result) {
std::string status = op.value("status", "");
std::string opid = op.value("id", "");
if (status == "success") {
done.push_back(opid);
anySuccess = true;
} else if (status == "failed") {
done.push_back(opid);
std::string msg = "Transaction failed";
if (op.contains("error") && op["error"].contains("message"))
msg = op["error"]["message"].get<std::string>();
// Capture for main thread
return [this, done, msg]() {
ui::Notifications::instance().error(msg);
for (const auto& id : done) {
pending_opids_.erase(
std::remove(pending_opids_.begin(), pending_opids_.end(), id),
pending_opids_.end());
}
};
auto parsed = services::NetworkRefreshService::parseOperationStatusPoll(result, opids);
return [this, parsed = std::move(parsed)]() mutable {
opid_poll_in_progress_ = false;
for (const auto& msg : parsed.failureMessages) {
ui::Notifications::instance().error(msg);
}
}
// Extract txids from successful operations so shielded
// sends are discoverable by z_viewtransaction.
std::vector<std::string> successTxids;
for (const auto& op : result) {
if (op.value("status", "") == "success"
&& op.contains("result") && op["result"].contains("txid")) {
successTxids.push_back(op["result"]["txid"].get<std::string>());
}
}
return [this, done, anySuccess,
successTxids = std::move(successTxids)]() {
for (const auto& id : done) {
std::vector<std::string> terminalOpids = std::move(parsed.doneOpids);
terminalOpids.insert(terminalOpids.end(),
parsed.staleOpids.begin(), parsed.staleOpids.end());
for (const auto& id : terminalOpids) {
pending_opids_.erase(
std::remove(pending_opids_.begin(), pending_opids_.end(), id),
pending_opids_.end());
}
if (anySuccess) {
for (const auto& txid : successTxids) {
if (parsed.anySuccess) {
std::unordered_set<std::string> successfulOpids;
for (const auto& [opid, txid] : parsed.successTxidsByOpid) {
successfulOpids.insert(opid);
markPendingSendTransactionSucceeded(opid, txid);
send_txids_.insert(txid);
}
std::vector<std::string> successOpids;
std::vector<std::string> failedOrStaleOpids;
for (const auto& opid : terminalOpids) {
if (successfulOpids.find(opid) != successfulOpids.end()) successOpids.push_back(opid);
else failedOrStaleOpids.push_back(opid);
}
removePendingSendTransactions(successOpids, false);
removePendingSendTransactions(failedOrStaleOpids, true);
// Transaction confirmed by daemon — force immediate data refresh
transactions_dirty_ = true;
addresses_dirty_ = true;
last_tx_block_height_ = -1;
network_refresh_.markWalletMutationRefresh();
} else {
removePendingSendTransactions(terminalOpids, true);
maybeFinishTransactionSendProgress();
}
};
});
@@ -645,16 +639,23 @@ void App::update()
// Per-category refresh with tab-aware intervals
// Skip when wallet is locked — same reason as above.
if (state_.connected && !state_.isLocked()) {
const bool walletDataPage = currentPageNeedsWalletDataRefresh();
if (network_refresh_.consumeDue(RefreshTimer::Core)) {
refreshCoreData();
}
// Skip balance/tx/address refresh during warmup — RPC calls fail with -28
if (!state_.warming_up) {
if (network_refresh_.consumeDue(RefreshTimer::Transactions)) {
refreshTransactionData();
if (shouldRunWalletTransactionRefresh() && shouldRefreshTransactions()) {
refreshTransactionData();
} else if (walletDataPage && shouldRefreshRecentTransactions()) {
refreshRecentTransactionData();
}
}
if (network_refresh_.consumeDue(RefreshTimer::Addresses)) {
refreshAddressData();
if (walletDataPage || addresses_dirty_ || hasTransactionSendProgress()) {
refreshAddressData();
}
}
}
if (network_refresh_.consumeDue(RefreshTimer::Peers)) {
@@ -1062,8 +1063,12 @@ void App::render()
bool prevCollapsed = sidebar_collapsed_;
{
PERF_SCOPE("Render.Sidebar");
ui::RenderSidebar(current_page_, sidebarW, sidebarH, sbStatus, sidebar_collapsed_,
ui::NavPage requestedPage = current_page_;
ui::RenderSidebar(requestedPage, sidebarW, sidebarH, sbStatus, sidebar_collapsed_,
state_.isLocked());
if (requestedPage != current_page_) {
setCurrentPage(requestedPage);
}
}
if (sbStatus.exitClicked) {
requestQuit();
@@ -1601,6 +1606,22 @@ void App::renderStatusBar()
displayHashrate);
}
// Transaction submission/operation progress
if (hasTransactionSendProgress()) {
ImGui::SameLine(0, sbSectionGap);
ImGui::TextDisabled("|");
ImGui::SameLine(0, sbSeparatorGap);
ImGui::PushFont(ui::material::Type().iconSmall());
float pulse = 0.6f + 0.4f * sinf((float)ImGui::GetTime() * 3.0f);
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, pulse), ICON_MD_SEND);
ImGui::PopFont();
ImGui::SameLine(0, sbIconTextGap);
int dots = (int)(ImGui::GetTime() * 2.0f) % 4;
const char* dotStr = (dots == 0) ? "." : (dots == 1) ? ".." : (dots == 2) ? "..." : "";
std::string status = transactionSendProgressText();
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "%s%s", status.c_str(), dotStr);
}
// Decrypt-import background task indicator
if (wallet_security_workflow_.importActive()) {
ImGui::SameLine(0, sbSectionGap);
@@ -2026,6 +2047,7 @@ void App::refreshNow()
transactions_dirty_ = true; // Force transaction list update
addresses_dirty_ = true; // Force address/balance update
last_tx_block_height_ = -1; // Reset tx cache
invalidateShieldedHistoryScanProgress(true);
}
void App::handlePaymentURI(const std::string& uri)
@@ -2302,6 +2324,9 @@ void App::rescanBlockchain()
state_.sync.rescanning = true;
state_.sync.rescan_progress = 0.0f;
state_.sync.rescan_status = decision.status;
transactions_dirty_ = true;
last_tx_block_height_ = -1;
invalidateShieldedHistoryScanProgress(true);
// Set rescan flag BEFORE stopping so it's ready when we restart
daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
@@ -3064,6 +3089,67 @@ bool App::hasPendingRPCResults() const {
return (worker_ && worker_->hasPendingResults())
|| (fast_worker_ && fast_worker_->hasPendingResults());
}
std::string App::transactionSendProgressText() const
{
using Job = services::NetworkRefreshService::Job;
if (send_submissions_in_flight_ > 0) return TR("tx_progress_submitting");
if (!pending_opids_.empty()) {
char buf[128];
snprintf(buf, sizeof(buf), TR("tx_progress_waiting_ops"), (int)pending_opids_.size());
return buf;
}
if (addresses_dirty_ || network_refresh_.jobInProgress(Job::Addresses)) {
return TR("tx_progress_balances");
}
if (transactions_dirty_ || network_refresh_.jobInProgress(Job::Transactions)) {
return TR("tx_progress_history");
}
return TR("tx_progress_finalizing");
}
std::string App::transactionRefreshProgressText() const
{
using Job = services::NetworkRefreshService::Job;
bool running = network_refresh_.jobInProgress(Job::Transactions);
bool canRefresh = state_.connected && !state_.warming_up && !state_.isLocked();
if (!running && !(canRefresh && transactions_dirty_)) return {};
if (!running && transactions_dirty_) return TR("tx_loading_queued");
char buf[128];
if (!send_txids_.empty()) {
snprintf(buf, sizeof(buf), TR("tx_loading_enriching_sends"), (int)send_txids_.size());
return buf;
}
if (!state_.transactions.empty()) {
snprintf(buf, sizeof(buf), TR("tx_loading_refreshing_cached"), (int)state_.transactions.size());
return buf;
}
if (!state_.z_addresses.empty()) {
snprintf(buf, sizeof(buf), TR("tx_loading_scanning_shielded"), (int)state_.z_addresses.size());
return buf;
}
return TR("tx_loading_fetching_transparent");
}
void App::maybeFinishTransactionSendProgress()
{
using Job = services::NetworkRefreshService::Job;
if (!send_progress_active_) return;
if (send_submissions_in_flight_ > 0 || !pending_opids_.empty()) return;
if (addresses_dirty_ || transactions_dirty_) return;
if (network_refresh_.jobInProgress(Job::Addresses) ||
network_refresh_.jobInProgress(Job::Transactions)) return;
send_progress_active_ = false;
}
void App::restartDaemon()
{
auto decision = daemon::DaemonController::evaluateLifecycleOperation(