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:
178
src/app.cpp
178
src/app.cpp
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user