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

@@ -45,7 +45,8 @@ namespace {
class WalletSecurityRpcAdapter : public services::WalletSecurityController::RpcGateway {
public:
explicit WalletSecurityRpcAdapter(rpc::RPCClient* rpc) : rpc_(rpc) {}
explicit WalletSecurityRpcAdapter(rpc::RPCClient* rpc, std::string source = "Security settings")
: rpc_(rpc), source_(std::move(source)) {}
bool encryptWallet(const std::string& passphrase, std::string& error) override {
return callWithError([&] { rpc_->call("encryptwallet", {passphrase}); }, error);
@@ -71,6 +72,7 @@ private:
return false;
}
try {
rpc::RPCClient::TraceScope trace(source_);
fn();
return true;
} catch (const std::exception& e) {
@@ -80,6 +82,7 @@ private:
}
rpc::RPCClient* rpc_ = nullptr;
std::string source_;
};
class WalletSecurityVaultAdapter : public services::WalletSecurityController::VaultGateway {
@@ -98,8 +101,9 @@ class WalletSecurityDecryptRpcAdapter : public services::WalletSecurityWorkflowE
public:
using StopFn = std::function<bool(rpc::RPCClient&, const char*)>;
WalletSecurityDecryptRpcAdapter(rpc::RPCClient* rpc, StopFn stopFn)
: rpc_(rpc), stopFn_(std::move(stopFn)) {}
WalletSecurityDecryptRpcAdapter(rpc::RPCClient* rpc, StopFn stopFn,
std::string source = "Security / Decrypt wallet workflow")
: rpc_(rpc), stopFn_(std::move(stopFn)), source_(std::move(source)) {}
bool unlockWallet(const std::string& passphrase, int timeoutSeconds, std::string& error) override {
return callWithError([&] { rpc_->call("walletpassphrase", {passphrase, timeoutSeconds}); }, error);
@@ -131,6 +135,7 @@ private:
return false;
}
try {
rpc::RPCClient::TraceScope trace(source_);
fn();
return true;
} catch (const std::exception& e) {
@@ -141,6 +146,7 @@ private:
rpc::RPCClient* rpc_ = nullptr;
StopFn stopFn_;
std::string source_;
};
class WalletSecurityImportRpcAdapter : public services::WalletSecurityWorkflowExecutor::ImportGateway {
@@ -162,6 +168,7 @@ public:
}
try {
rpc::RPCClient::TraceScope trace("Security / Import wallet workflow");
rpcForImport->call("z_importwallet", {exportPath}, timeoutSeconds);
if (importRpc) importRpc->disconnect();
return true;
@@ -396,12 +403,14 @@ void App::unlockWallet(const std::string& passphrase, int timeout) {
// Use fast-lane worker to bypass head-of-line blocking behind refreshData.
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
w->post([this, r, passphrase, timeout]() -> rpc::RPCWorker::MainCb {
w->post([this, r, passphrase = std::string(passphrase), timeout]() mutable -> rpc::RPCWorker::MainCb {
std::string err_msg;
WalletSecurityRpcAdapter rpcAdapter(r);
bool ok = rpcAdapter.unlockWallet(passphrase, timeout, err_msg);
std::string cachePassphrase = passphrase;
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
return [this, ok, err_msg, timeout]() {
return [this, ok, err_msg, timeout, passphrase = std::move(cachePassphrase)]() mutable {
lock_unlock_in_progress_ = false;
if (ok) {
lock_error_msg_.clear();
@@ -413,6 +422,7 @@ void App::unlockWallet(const std::string& passphrase, int timeout) {
state_.encrypted = true;
state_.locked = false;
state_.unlocked_until = std::time(nullptr) + timeout;
unlockTransactionHistoryCacheWithPassphrase(passphrase);
} else {
lock_attempts_++;
lock_error_msg_ = TR("incorrect_passphrase");
@@ -426,6 +436,7 @@ void App::unlockWallet(const std::string& passphrase, int timeout) {
}
DEBUG_LOGF("[App] Wallet unlock failed (attempt %d): %s\n", lock_attempts_, err_msg.c_str());
}
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
};
});
}
@@ -450,6 +461,7 @@ void App::lockWallet() {
if (ok) {
state_.locked = true;
state_.unlocked_until = 0;
resetTransactionHistoryCacheSession();
DEBUG_LOGF("[App] Wallet locked\n");
}
};
@@ -463,7 +475,10 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
w->post([this, r, oldPass, newPass]() -> rpc::RPCWorker::MainCb {
w->post([this,
r,
oldPass = std::string(oldPass),
newPass = std::string(newPass)]() mutable -> rpc::RPCWorker::MainCb {
bool ok = false;
std::string err_msg;
try {
@@ -472,8 +487,14 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas
} catch (const std::exception& e) {
err_msg = e.what();
}
std::string cacheNewPass = newPass;
util::SecureVault::secureZero(oldPass.data(), oldPass.size());
util::SecureVault::secureZero(newPass.data(), newPass.size());
return [this, ok, err_msg]() {
return [this,
ok,
err_msg,
newPass = std::move(cacheNewPass)]() mutable {
encrypt_in_progress_ = false;
if (ok) {
encrypt_status_.clear();
@@ -481,10 +502,13 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas
memset(change_old_pass_buf_, 0, sizeof(change_old_pass_buf_));
memset(change_new_pass_buf_, 0, sizeof(change_new_pass_buf_));
memset(change_confirm_buf_, 0, sizeof(change_confirm_buf_));
unlockTransactionHistoryCacheWithPassphrase(newPass);
storeTransactionHistoryCacheIfAvailable();
ui::Notifications::instance().info("Passphrase changed successfully");
} else {
encrypt_status_ = "Failed: " + err_msg;
}
util::SecureVault::secureZero(newPass.data(), newPass.size());
};
});
}
@@ -500,6 +524,7 @@ void App::refreshWalletEncryptionState() {
json result;
bool ok = false;
try {
rpc::RPCClient::TraceScope trace("Security / Wallet encryption state");
result = rpc_->call("getwalletinfo");
ok = true;
} catch (...) {}
@@ -513,10 +538,24 @@ void App::refreshWalletEncryptionState() {
int64_t until = result["unlocked_until"].get<int64_t>();
state_.unlocked_until = until;
state_.locked = (until == 0);
state_.encryption_state_known = true;
if (state_.locked) {
resetTransactionHistoryCacheSession();
} else if (state_.transactions.empty()) {
loadTransactionHistoryCacheIfAvailable();
} else {
storeTransactionHistoryCacheIfAvailable();
}
} else {
state_.encrypted = false;
state_.locked = false;
state_.unlocked_until = 0;
state_.encryption_state_known = true;
if (state_.transactions.empty()) {
loadTransactionHistoryCacheIfAvailable();
} else {
storeTransactionHistoryCacheIfAvailable();
}
// Wallet is no longer encrypted — if a PIN vault exists,
// it's stale (passphrase it protects is gone). Reset PIN
@@ -530,7 +569,6 @@ void App::refreshWalletEncryptionState() {
settings_->save();
}
}
state_.encryption_state_known = true;
} catch (...) {}
};
});
@@ -974,11 +1012,8 @@ void App::renderLockScreen() {
rpcErr = e.what();
}
// Securely wipe passphrase
util::SecureVault::secureZero(&passphrase[0], passphrase.size());
if (rpcOk) {
return [this, timeout]() {
return [this, timeout, passphrase = std::move(passphrase)]() mutable {
lock_unlock_in_progress_ = false;
lock_error_msg_.clear();
lock_attempts_ = 0;
@@ -989,13 +1024,16 @@ void App::renderLockScreen() {
state_.encrypted = true;
state_.locked = false;
state_.unlocked_until = std::time(nullptr) + timeout;
unlockTransactionHistoryCacheWithPassphrase(passphrase);
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
};
} else {
return [this, rpcErr]() {
return [this, rpcErr, passphrase = std::move(passphrase)]() mutable {
lock_unlock_in_progress_ = false;
lock_attempts_++;
lock_error_msg_ = "Unlock failed: " + rpcErr;
lock_error_timer_ = 3.0f;
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
};
}
});
@@ -1496,6 +1534,7 @@ void App::renderDecryptWalletDialog() {
wallet_security_workflow_.finishImport();
// Force address + peer refresh
invalidateAddressValidationCache();
addresses_dirty_ = true;
transactions_dirty_ = true;
last_tx_block_height_ = -1;
@@ -1738,6 +1777,7 @@ void App::renderPinDialogs() {
worker_->post([this, passphrase, pin]() -> rpc::RPCWorker::MainCb {
// Verify passphrase via RPC (worker thread)
try {
rpc::RPCClient::TraceScope trace("Security / PIN setup");
rpc_->call("walletpassphrase", {passphrase, 5});
} catch (const std::exception& e) {
return [this]() {
@@ -1751,6 +1791,7 @@ void App::renderPinDialogs() {
// Lock wallet back
try {
rpc::RPCClient::TraceScope trace("Security / PIN setup");
rpc_->call("walletlock");
} catch (...) {}