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

@@ -110,6 +110,32 @@ FetchContent_Declare(
)
FetchContent_MakeAvailable(tomlplusplus)
# SQLite amalgamation - local Explorer block-summary cache
FetchContent_Declare(
sqlite3
URL https://www.sqlite.org/2024/sqlite-amalgamation-3450300.zip
URL_HASH SHA256=ea170e73e447703e8359308ca2e4366a3ae0c4304a8665896f068c736781c651
)
FetchContent_GetProperties(sqlite3)
if(NOT sqlite3_POPULATED)
FetchContent_Populate(sqlite3)
endif()
file(GLOB SQLITE3_AMALGAMATION_C CONFIGURE_DEPENDS
${sqlite3_SOURCE_DIR}/sqlite3.c
${sqlite3_SOURCE_DIR}/*/sqlite3.c
)
if(NOT SQLITE3_AMALGAMATION_C)
message(FATAL_ERROR "SQLite amalgamation source not found")
endif()
list(GET SQLITE3_AMALGAMATION_C 0 SQLITE3_SOURCE_FILE)
get_filename_component(SQLITE3_INCLUDE_DIR ${SQLITE3_SOURCE_FILE} DIRECTORY)
add_library(sqlite3_amalgamation STATIC ${SQLITE3_SOURCE_FILE})
target_include_directories(sqlite3_amalgamation PUBLIC ${SQLITE3_INCLUDE_DIR})
target_compile_definitions(sqlite3_amalgamation PRIVATE
SQLITE_THREADSAFE=1
SQLITE_OMIT_LOAD_EXTENSION
)
# libcurl for HTTPS RPC connections (more reliable than cpp-httplib with OpenSSL 3.x)
if(WIN32)
# For Windows cross-compilation, fetch and build libcurl statically
@@ -255,8 +281,10 @@ set(APP_SOURCES
src/services/wallet_security_workflow.cpp
src/services/wallet_security_workflow_executor.cpp
src/data/wallet_state.cpp
src/data/transaction_history_cache.cpp
src/ui/theme.cpp
src/ui/theme_loader.cpp
src/ui/explorer/explorer_block_cache.cpp
src/ui/material/color_theme.cpp
src/ui/material/typography.cpp
src/ui/notifications.cpp
@@ -354,8 +382,10 @@ set(APP_HEADERS
src/services/wallet_security_workflow_executor.h
src/config/version.h
src/data/wallet_state.h
src/data/transaction_history_cache.h
src/ui/theme.h
src/ui/theme_loader.h
src/ui/explorer/explorer_block_cache.h
src/ui/notifications.h
src/ui/windows/main_window.h
src/ui/windows/balance_tab.h
@@ -507,6 +537,7 @@ target_link_libraries(ObsidianDragon PRIVATE
SDL3::SDL3
nlohmann_json::nlohmann_json
tomlplusplus::tomlplusplus
sqlite3_amalgamation
${CURL_LIBRARIES}
${SODIUM_LIBRARY}
)
@@ -712,6 +743,7 @@ if(BUILD_TESTING)
src/services/wallet_security_controller.cpp
src/services/wallet_security_workflow.cpp
src/services/wallet_security_workflow_executor.cpp
src/ui/explorer/explorer_block_cache.cpp
src/ui/windows/balance_address_list.cpp
src/ui/windows/balance_recent_tx.cpp
src/ui/windows/console_input_model.cpp
@@ -723,6 +755,7 @@ if(BUILD_TESTING)
src/util/payment_uri.cpp
src/util/amount_format.cpp
src/data/wallet_state.cpp
src/data/transaction_history_cache.cpp
src/daemon/lifecycle_adapters.cpp
src/rpc/connection.cpp
src/resources/embedded_resources.cpp
@@ -742,6 +775,7 @@ if(BUILD_TESTING)
target_link_libraries(ObsidianDragonTests PRIVATE
nlohmann_json::nlohmann_json
sqlite3_amalgamation
${SODIUM_LIBRARY}
)

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(

View File

@@ -12,6 +12,7 @@
#include <chrono>
#include <unordered_map>
#include <unordered_set>
#include "data/transaction_history_cache.h"
#include "data/wallet_state.h"
#include "rpc/connection.h"
#include "services/network_refresh_service.h"
@@ -217,6 +218,9 @@ public:
void setAddressSortOrder(const std::string& addr, int order);
int getNextSortOrder() const;
void swapAddressOrder(const std::string& a, const std::string& b);
bool isMiningAddress(const std::string& addr) const;
void setMiningAddress(const std::string& addr, bool mining);
void invalidateAddressValidationCache();
// Key export/import
void exportPrivateKey(const std::string& address, std::function<void(const std::string&)> callback);
@@ -353,12 +357,38 @@ public:
/// @brief Check if RPC worker has queued results waiting to be processed
bool hasPendingRPCResults() const;
bool hasTransactionSendProgress() const { return send_progress_active_ || send_submissions_in_flight_ > 0 || !pending_opids_.empty(); }
std::string transactionSendProgressText() const;
std::string transactionRefreshProgressText() const;
bool isTransactionRefreshInProgress() const {
return network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Transactions);
}
private:
friend class AppDaemonLifecycleRuntime;
friend class AppDaemonLifecycleTaskContext;
bool sendStopCommandSafely(rpc::RPCClient& client, const char* context);
void maybeFinishTransactionSendProgress();
void upsertPendingSendTransaction(const std::string& opid,
const std::string& from,
const std::string& to,
double amount,
const std::string& memo);
void markPendingSendTransactionSucceeded(const std::string& opid,
const std::string& txid);
void removePendingSendTransactions(const std::vector<std::string>& opids,
bool restoreBalances);
void applyPendingSendBalanceDeltas(bool includeAggregateBalances);
std::string transactionHistoryCacheWalletIdentity() const;
bool ensureTransactionHistoryCacheUnlockedFor(const std::string& walletIdentity);
void unlockTransactionHistoryCacheWithPassphrase(const std::string& passphrase);
void loadTransactionHistoryCacheIfAvailable();
void storeTransactionHistoryCacheIfAvailable();
void wipePendingTransactionHistoryCachePassphrase();
void resetTransactionHistoryCacheSession();
void pruneShieldedHistoryScanProgress();
void invalidateShieldedHistoryScanProgress(bool persistCache);
// Subsystems
std::unique_ptr<rpc::RPCClient> rpc_;
@@ -477,6 +507,9 @@ private:
// P4: Incremental transaction cache
int last_tx_block_height_ = -1; // block height at last full tx fetch
static constexpr int MAX_VIEWTX_PER_CYCLE = 25; // cap z_viewtransaction calls per refresh
std::size_t shielded_history_scan_cursor_ = 0;
bool shielded_history_scan_pending_ = false;
std::unordered_map<std::string, int> shielded_history_scan_heights_;
// P4b: z_viewtransaction result cache — avoids re-calling the RPC for
// txids we've already enriched. Keyed by txid.
@@ -492,11 +525,24 @@ private:
// Dirty flags for demand-driven refresh
bool addresses_dirty_ = true; // true → refreshAddresses() will run
bool address_validation_cache_dirty_ = true;
bool transactions_dirty_ = false; // true → force tx refresh regardless of block height
bool encryption_state_prefetched_ = false; // suppress duplicate getwalletinfo on connect
bool rescan_status_poll_in_progress_ = false;
bool opid_poll_in_progress_ = false;
// Pending z_sendmany operation tracking
bool send_progress_active_ = false;
int send_submissions_in_flight_ = 0;
std::vector<std::string> pending_opids_; // opids to poll for completion
struct PendingSendInfo {
std::string from;
std::string to;
std::string memo;
double amount = 0.0;
std::int64_t timestamp = 0;
};
std::unordered_map<std::string, PendingSendInfo> pending_send_info_;
// Txids from completed z_sendmany operations.
// Ensures shielded sends are discoverable by z_viewtransaction
// even when they don't appear in listtransactions or
@@ -520,6 +566,9 @@ private:
// PIN vault
std::unique_ptr<util::SecureVault> vault_;
data::TransactionHistoryCache transaction_history_cache_;
std::string pending_transaction_history_cache_passphrase_;
bool transaction_history_cache_loaded_ = false;
// Lock screen state
bool lock_screen_was_visible_ = false; // tracks lock→unlock transitions for auto-focus
@@ -600,13 +649,17 @@ private:
void refreshCoreData(); // Balance + blockchain info (can use fast_worker_)
void refreshAddressData(); // Address lists + balances
void refreshTransactionData(); // Transaction list + z_viewtransaction enrichment
void refreshEncryptionState(); // Wallet encryption/lock state
void refreshRecentTransactionData(); // Lightweight recent/unconfirmed tx poll
bool refreshEncryptionState(); // Wallet encryption/lock state
void refreshBalance(); // Legacy: balance-only refresh (used by specific callers)
void refreshAddresses(); // Legacy: standalone address refresh
void refreshPrice();
void refreshWalletEncryptionState();
void applyRefreshPolicy(ui::NavPage page);
bool currentPageNeedsWalletDataRefresh() const;
bool shouldRunWalletTransactionRefresh() const;
bool shouldRefreshTransactions() const;
bool shouldRefreshRecentTransactions() const;
void checkAutoLock();
void checkIdleMining();
};

View File

@@ -43,9 +43,13 @@
#include "util/platform.h"
#include "util/perf_log.h"
#include "util/i18n.h"
#include "util/secure_vault.h"
#include <nlohmann/json.hpp>
#include <curl/curl.h>
#include <algorithm>
#include <cmath>
#include <ctime>
#include <fstream>
#include <utility>
@@ -56,19 +60,61 @@ using NetworkRefreshService = services::NetworkRefreshService;
namespace {
std::string unencryptedTransactionHistoryCacheKey(const std::string& walletIdentity)
{
return std::string("obsidian-dragon-unencrypted-tx-cache-v1:") +
data::TransactionHistoryCache::walletIdentityHash(walletIdentity);
}
class AppRefreshRpcGateway final : public NetworkRefreshService::RefreshRpcGateway {
public:
explicit AppRefreshRpcGateway(rpc::RPCClient& rpc) : rpc_(rpc) {}
AppRefreshRpcGateway(rpc::RPCClient& rpc, std::string source)
: rpc_(rpc), source_(std::move(source)) {}
json call(const std::string& method, const json& params) override
{
rpc::RPCClient::TraceScope trace(source_);
return rpc_.call(method, params);
}
private:
rpc::RPCClient& rpc_;
std::string source_;
};
const char* tracePageName(ui::NavPage page)
{
switch (page) {
case ui::NavPage::Overview: return "Overview tab";
case ui::NavPage::Send: return "Send tab";
case ui::NavPage::Receive: return "Receive tab";
case ui::NavPage::History: return "History tab";
case ui::NavPage::Mining: return "Mining tab";
case ui::NavPage::Market: return "Market tab";
case ui::NavPage::Console: return "Console tab";
case ui::NavPage::Peers: return "Network tab";
case ui::NavPage::Explorer: return "Explorer tab";
case ui::NavPage::Settings: return "Settings";
case ui::NavPage::Count_: break;
}
return "App";
}
std::string traceSource(ui::NavPage page, const char* process)
{
std::string source = tracePageName(page);
if (process && process[0] != '\0') {
source += " / ";
source += process;
}
return source;
}
std::size_t shieldedReceiveScanBudget(ui::NavPage page)
{
return page == ui::NavPage::History ? 8u : 4u;
}
} // namespace
// ============================================================================
@@ -362,11 +408,19 @@ void App::onConnected()
// screen appears immediately instead of after 6+ queued RPC calls.
bool initialPrefetchQueued = false;
if (worker_ && rpc_) {
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::ConnectionInit, *worker_, [this]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc_);
auto result = NetworkRefreshService::collectConnectionInitResult(refreshRpc);
auto prefetchedInfo = NetworkRefreshService::parseConnectionInfoResult(rpc_->getLastConnectInfo());
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::ConnectionInit, *worker_, [this, prefetchedInfo]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc_, "Startup / Connection init");
auto result = NetworkRefreshService::collectConnectionInitResult(refreshRpc, prefetchedInfo);
return [this, result]() {
NetworkRefreshService::applyConnectionInitResult(state_, result);
if (state_.isLocked()) {
resetTransactionHistoryCacheSession();
} else if (state_.transactions.empty()) {
loadTransactionHistoryCacheIfAvailable();
} else {
storeTransactionHistoryCacheIfAvailable();
}
};
}, 3);
initialPrefetchQueued = enqueued.enqueued;
@@ -425,6 +479,15 @@ void App::onDisconnected(const std::string& reason)
confirmed_tx_ids_.clear();
confirmed_cache_block_ = -1;
last_tx_block_height_ = -1;
pending_opids_.clear();
pending_send_info_.clear();
send_progress_active_ = false;
send_submissions_in_flight_ = 0;
network_refresh_.resetJobs();
rescan_status_poll_in_progress_ = false;
opid_poll_in_progress_ = false;
address_validation_cache_dirty_ = true;
resetTransactionHistoryCacheSession();
// Tear down the fast-lane connection
if (fast_worker_) {
@@ -464,6 +527,22 @@ void App::applyRefreshPolicy(ui::NavPage page)
network_refresh_.setIntervals(getIntervalsForPage(page));
}
bool App::currentPageNeedsWalletDataRefresh() const
{
using NP = ui::NavPage;
return current_page_ == NP::Overview ||
current_page_ == NP::Send ||
current_page_ == NP::Receive ||
current_page_ == NP::History;
}
bool App::shouldRunWalletTransactionRefresh() const
{
if (currentPageNeedsWalletDataRefresh()) return true;
if (hasTransactionSendProgress() || !send_txids_.empty()) return true;
return transactions_dirty_ && !shielded_history_scan_pending_;
}
void App::setCurrentPage(ui::NavPage page)
{
if (page == current_page_) return;
@@ -508,10 +587,319 @@ bool App::shouldRefreshTransactions() const
const int currentBlocks = state_.sync.blocks;
return network_refresh_.shouldRefreshTransactions(last_tx_block_height_,
currentBlocks,
state_.transactions.empty(),
transactions_dirty_);
}
bool App::shouldRefreshRecentTransactions() const
{
using RefreshTimer = services::NetworkRefreshService::Timer;
return network_refresh_.isDue(RefreshTimer::TxAge)
&& last_tx_block_height_ >= 0
&& state_.sync.blocks == last_tx_block_height_
&& !state_.transactions.empty()
&& !transactions_dirty_
&& !addresses_dirty_;
}
void App::upsertPendingSendTransaction(const std::string& opid,
const std::string& from,
const std::string& to,
double amount,
const std::string& memo)
{
if (opid.empty()) return;
bool newPending = pending_send_info_.find(opid) == pending_send_info_.end();
auto& pendingInfo = pending_send_info_[opid];
if (pendingInfo.timestamp == 0) pendingInfo.timestamp = static_cast<std::int64_t>(std::time(nullptr));
pendingInfo.from = from;
pendingInfo.to = to;
pendingInfo.amount = std::abs(amount);
pendingInfo.memo = memo;
TransactionInfo pending;
pending.txid = opid;
pending.type = "send";
pending.amount = -pendingInfo.amount;
pending.timestamp = pendingInfo.timestamp;
pending.confirmations = 0;
pending.address = pendingInfo.to;
pending.from_address = pendingInfo.from;
pending.memo = pendingInfo.memo;
auto existing = std::find_if(state_.transactions.begin(), state_.transactions.end(),
[&](const TransactionInfo& transaction) { return transaction.txid == opid; });
if (existing != state_.transactions.end()) {
*existing = std::move(pending);
} else {
state_.transactions.insert(state_.transactions.begin(), std::move(pending));
}
if (newPending) {
auto applyDelta = [&](std::vector<AddressInfo>& addresses) {
for (auto& address : addresses) {
if (address.address == pendingInfo.from) {
address.balance = std::max(0.0, address.balance - pendingInfo.amount);
return true;
}
}
return false;
};
if (!applyDelta(state_.z_addresses)) applyDelta(state_.t_addresses);
if (!pendingInfo.from.empty() && pendingInfo.from[0] == 'z') {
state_.privateBalance = std::max(0.0, state_.privateBalance - pendingInfo.amount);
} else {
state_.transparentBalance = std::max(0.0, state_.transparentBalance - pendingInfo.amount);
}
state_.totalBalance = std::max(0.0, state_.totalBalance - pendingInfo.amount);
}
state_.last_tx_update = std::time(nullptr);
}
void App::markPendingSendTransactionSucceeded(const std::string& opid,
const std::string& txid)
{
if (opid.empty() || txid.empty()) return;
auto pending = std::find_if(state_.transactions.begin(), state_.transactions.end(),
[&](const TransactionInfo& transaction) { return transaction.txid == opid; });
if (pending == state_.transactions.end()) return;
bool duplicateRealTx = std::any_of(state_.transactions.begin(), state_.transactions.end(),
[&](const TransactionInfo& transaction) { return transaction.txid == txid; });
if (duplicateRealTx) {
state_.transactions.erase(pending);
} else {
pending->txid = txid;
pending->confirmations = 0;
if (pending->timestamp == 0) pending->timestamp = static_cast<std::int64_t>(std::time(nullptr));
}
state_.last_tx_update = std::time(nullptr);
}
void App::removePendingSendTransactions(const std::vector<std::string>& opids,
bool restoreBalances)
{
if (opids.empty()) return;
std::unordered_set<std::string> opidSet(opids.begin(), opids.end());
if (restoreBalances) {
for (const auto& opid : opidSet) {
auto pending = pending_send_info_.find(opid);
if (pending == pending_send_info_.end()) continue;
auto restoreBalance = [&](std::vector<AddressInfo>& addresses) {
for (auto& address : addresses) {
if (address.address == pending->second.from) {
address.balance += pending->second.amount;
return true;
}
}
return false;
};
if (!restoreBalance(state_.z_addresses)) restoreBalance(state_.t_addresses);
if (!pending->second.from.empty() && pending->second.from[0] == 'z') {
state_.privateBalance += pending->second.amount;
} else {
state_.transparentBalance += pending->second.amount;
}
state_.totalBalance += pending->second.amount;
}
}
state_.transactions.erase(
std::remove_if(state_.transactions.begin(), state_.transactions.end(),
[&](const TransactionInfo& transaction) {
return opidSet.find(transaction.txid) != opidSet.end();
}),
state_.transactions.end());
for (const auto& opid : opidSet) pending_send_info_.erase(opid);
state_.last_tx_update = std::time(nullptr);
}
void App::applyPendingSendBalanceDeltas(bool includeAggregateBalances)
{
for (const auto& [opid, pending] : pending_send_info_) {
(void)opid;
auto applyDelta = [&](std::vector<AddressInfo>& addresses) {
for (auto& address : addresses) {
if (address.address == pending.from) {
address.balance = std::max(0.0, address.balance - pending.amount);
return true;
}
}
return false;
};
if (!applyDelta(state_.z_addresses)) applyDelta(state_.t_addresses);
if (includeAggregateBalances) {
if (!pending.from.empty() && pending.from[0] == 'z') {
state_.privateBalance = std::max(0.0, state_.privateBalance - pending.amount);
} else {
state_.transparentBalance = std::max(0.0, state_.transparentBalance - pending.amount);
}
state_.totalBalance = std::max(0.0, state_.totalBalance - pending.amount);
}
}
}
std::string App::transactionHistoryCacheWalletIdentity() const
{
std::vector<std::string> shieldedAddresses;
std::vector<std::string> transparentAddresses;
shieldedAddresses.reserve(state_.z_addresses.size());
transparentAddresses.reserve(state_.t_addresses.size());
for (const auto& address : state_.z_addresses) {
if (!address.address.empty()) shieldedAddresses.push_back(address.address);
}
for (const auto& address : state_.t_addresses) {
if (!address.address.empty()) transparentAddresses.push_back(address.address);
}
return data::TransactionHistoryCache::walletIdentityFromAddresses(
shieldedAddresses, transparentAddresses);
}
void App::wipePendingTransactionHistoryCachePassphrase()
{
if (!pending_transaction_history_cache_passphrase_.empty()) {
util::SecureVault::secureZero(pending_transaction_history_cache_passphrase_.data(),
pending_transaction_history_cache_passphrase_.size());
pending_transaction_history_cache_passphrase_.clear();
}
}
void App::resetTransactionHistoryCacheSession()
{
transaction_history_cache_.lockKey();
wipePendingTransactionHistoryCachePassphrase();
transaction_history_cache_loaded_ = false;
invalidateShieldedHistoryScanProgress(false);
}
void App::pruneShieldedHistoryScanProgress()
{
std::unordered_set<std::string> currentShieldedAddresses;
currentShieldedAddresses.reserve(state_.z_addresses.size());
for (const auto& address : state_.z_addresses) {
if (!address.address.empty()) currentShieldedAddresses.insert(address.address);
}
for (auto it = shielded_history_scan_heights_.begin(); it != shielded_history_scan_heights_.end();) {
if (currentShieldedAddresses.find(it->first) == currentShieldedAddresses.end()) {
it = shielded_history_scan_heights_.erase(it);
} else {
++it;
}
}
}
void App::invalidateShieldedHistoryScanProgress(bool persistCache)
{
shielded_history_scan_cursor_ = 0;
shielded_history_scan_pending_ = false;
shielded_history_scan_heights_.clear();
if (persistCache) storeTransactionHistoryCacheIfAvailable();
}
bool App::ensureTransactionHistoryCacheUnlockedFor(const std::string& walletIdentity)
{
if (walletIdentity.empty()) return false;
if (transaction_history_cache_.isUnlockedFor(walletIdentity)) return true;
if (!pending_transaction_history_cache_passphrase_.empty()) {
std::string passphrase = pending_transaction_history_cache_passphrase_;
bool unlocked = transaction_history_cache_.unlockWithPassphrase(walletIdentity, passphrase);
if (unlocked) wipePendingTransactionHistoryCachePassphrase();
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
if (unlocked) return true;
}
if (state_.encryption_state_known && !state_.encrypted) {
std::string cacheKey = unencryptedTransactionHistoryCacheKey(walletIdentity);
bool unlocked = transaction_history_cache_.unlockWithPassphrase(walletIdentity, cacheKey);
util::SecureVault::secureZero(cacheKey.data(), cacheKey.size());
return unlocked;
}
return false;
}
void App::unlockTransactionHistoryCacheWithPassphrase(const std::string& passphrase)
{
if (passphrase.empty()) return;
std::string walletIdentity = transactionHistoryCacheWalletIdentity();
if (walletIdentity.empty()) {
wipePendingTransactionHistoryCachePassphrase();
pending_transaction_history_cache_passphrase_ = passphrase;
return;
}
if (transaction_history_cache_.unlockWithPassphrase(walletIdentity, passphrase)) {
wipePendingTransactionHistoryCachePassphrase();
if (state_.transactions.empty()) loadTransactionHistoryCacheIfAvailable();
else storeTransactionHistoryCacheIfAvailable();
}
}
void App::loadTransactionHistoryCacheIfAvailable()
{
if (transaction_history_cache_loaded_ || !state_.transactions.empty()) return;
std::string walletIdentity = transactionHistoryCacheWalletIdentity();
if (walletIdentity.empty()) return;
if (!ensureTransactionHistoryCacheUnlockedFor(walletIdentity)) return;
auto loaded = transaction_history_cache_.load(walletIdentity,
state_.sync.blocks,
state_.sync.best_blockhash);
if (!loaded.loaded) return;
state_.transactions = std::move(loaded.transactions);
shielded_history_scan_heights_ = std::move(loaded.shieldedScanHeights);
pruneShieldedHistoryScanProgress();
state_.last_tx_update = loaded.updatedAt;
last_tx_block_height_ = loaded.tipHeight;
confirmed_tx_cache_.clear();
confirmed_tx_ids_.clear();
for (const auto& transaction : state_.transactions) {
if (transaction.confirmations >= 10 && transaction.timestamp != 0) {
confirmed_tx_ids_.insert(transaction.txid);
confirmed_tx_cache_.push_back(transaction);
}
}
confirmed_cache_block_ = loaded.tipHeight;
transaction_history_cache_loaded_ = true;
transactions_dirty_ = true;
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
}
void App::storeTransactionHistoryCacheIfAvailable()
{
if (state_.transactions.empty()) return;
std::string walletIdentity = transactionHistoryCacheWalletIdentity();
if (walletIdentity.empty()) return;
if (!ensureTransactionHistoryCacheUnlockedFor(walletIdentity)) return;
pruneShieldedHistoryScanProgress();
std::unordered_set<std::string> pendingOpids(pending_opids_.begin(), pending_opids_.end());
std::vector<TransactionInfo> cacheTransactions;
cacheTransactions.reserve(state_.transactions.size());
for (const auto& transaction : state_.transactions) {
if (pendingOpids.find(transaction.txid) != pendingOpids.end()) continue;
cacheTransactions.push_back(transaction);
}
if (cacheTransactions.empty()) return;
std::time_t updatedAt = state_.last_tx_update != 0
? static_cast<std::time_t>(state_.last_tx_update)
: std::time(nullptr);
transaction_history_cache_.replace(walletIdentity,
state_.sync.blocks,
state_.sync.best_blockhash,
cacheTransactions,
updatedAt,
shielded_history_scan_heights_);
}
void App::refreshData()
{
if (!state_.connected || !rpc_ || !worker_) return;
@@ -527,18 +915,22 @@ void App::refreshData()
// as each completes, rather than waiting for the slowest phase.
refreshCoreData();
if (addresses_dirty_)
bool addressRefreshNeeded = addresses_dirty_;
bool walletDataPage = currentPageNeedsWalletDataRefresh();
if (addressRefreshNeeded)
refreshAddressData();
if (shouldRefreshTransactions())
if (!addressRefreshNeeded && shouldRunWalletTransactionRefresh() && shouldRefreshTransactions())
refreshTransactionData();
else if (!addressRefreshNeeded && walletDataPage && shouldRefreshRecentTransactions())
refreshRecentTransactionData();
if (current_page_ == ui::NavPage::Peers)
refreshPeerInfo();
if (!encryption_state_prefetched_) {
encryption_state_prefetched_ = false;
refreshEncryptionState();
if (!state_.encryption_state_known &&
!network_refresh_.jobInProgress(services::NetworkRefreshService::Job::ConnectionInit)) {
encryption_state_prefetched_ = refreshEncryptionState();
}
}
@@ -556,7 +948,7 @@ void App::refreshCoreData()
if (state_.warming_up) {
if (!worker_) return;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *worker_, [this]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc_);
AppRefreshRpcGateway refreshRpc(*rpc_, "Startup / Warmup poll");
auto result = NetworkRefreshService::collectWarmupPollResult(refreshRpc);
return [this, result = std::move(result)]() {
if (result.ready) {
@@ -595,13 +987,15 @@ void App::refreshCoreData()
auto* rpc = useFast && fast_rpc_ && fast_rpc_->isConnected()
? fast_rpc_.get() : rpc_.get();
if (!w || !rpc) return;
ui::NavPage tracePage = current_page_;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *w, [this, rpc]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc);
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *w, [this, rpc, tracePage]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc, traceSource(tracePage, "Core refresh"));
auto result = NetworkRefreshService::collectCoreRefreshResult(refreshRpc);
return [this, result]() {
try {
NetworkRefreshService::applyCoreRefreshResult(state_, result, std::time(nullptr));
applyPendingSendBalanceDeltas(true);
// Auto-shield transparent funds if enabled
if (result.balanceOk && settings_ && settings_->getAutoShield() &&
state_.transparent_balance > 0.0001 && !state_.sync.syncing &&
@@ -647,14 +1041,36 @@ void App::refreshCoreData()
void App::refreshAddressData()
{
if (!worker_ || !rpc_ || !state_.connected) return;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Addresses, *worker_, [this]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc_);
auto result = NetworkRefreshService::collectAddressRefreshResult(refreshRpc);
const std::size_t previousAddressCount = state_.z_addresses.size() + state_.t_addresses.size();
const std::string previousWalletIdentity = transactionHistoryCacheWalletIdentity();
auto addressSnapshot = address_validation_cache_dirty_
? NetworkRefreshService::AddressRefreshSnapshot{}
: NetworkRefreshService::buildAddressRefreshSnapshot(state_);
ui::NavPage tracePage = current_page_;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Addresses, *worker_, [this, previousAddressCount, previousWalletIdentity, addressSnapshot = std::move(addressSnapshot), tracePage]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Address refresh"));
auto result = NetworkRefreshService::collectAddressRefreshResult(refreshRpc, addressSnapshot);
return [this, result = std::move(result)]() mutable {
return [this, previousAddressCount, previousWalletIdentity, result = std::move(result)]() mutable {
NetworkRefreshService::applyAddressRefreshResult(state_, std::move(result));
applyPendingSendBalanceDeltas(false);
address_validation_cache_dirty_ = false;
address_list_dirty_ = true;
addresses_dirty_ = false;
const std::size_t currentAddressCount = state_.z_addresses.size() + state_.t_addresses.size();
const bool addressSetChanged = currentAddressCount != previousAddressCount ||
transactionHistoryCacheWalletIdentity() != previousWalletIdentity;
if (state_.transactions.empty() || addressSetChanged) {
if (addressSetChanged) {
invalidateShieldedHistoryScanProgress(false);
}
transactions_dirty_ = true;
last_tx_block_height_ = -1;
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
}
if (state_.transactions.empty()) loadTransactionHistoryCacheIfAvailable();
else storeTransactionHistoryCacheIfAvailable();
maybeFinishTransactionSendProgress();
};
}, 3);
if (!enqueued.enqueued) return;
@@ -667,18 +1083,37 @@ void App::refreshAddressData()
void App::refreshTransactionData()
{
if (!worker_ || !rpc_ || !state_.connected) return;
if (addresses_dirty_) {
refreshAddressData();
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
return;
}
const int currentBlocks = state_.sync.blocks;
if (last_tx_block_height_ < 0 || currentBlocks != last_tx_block_height_ ||
!shielded_history_scan_pending_) {
shielded_history_scan_cursor_ = 0;
shielded_history_scan_pending_ = false;
}
auto transactionSnapshot = NetworkRefreshService::buildTransactionRefreshSnapshot(
state_, viewtx_cache_, send_txids_);
transactionSnapshot.pendingOpids.insert(pending_opids_.begin(), pending_opids_.end());
if (settings_) transactionSnapshot.miningAddresses = settings_->getMiningAddresses();
transactionSnapshot.shieldedScanHeights = shielded_history_scan_heights_;
transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_;
transactionSnapshot.maxShieldedReceiveScans = shieldedReceiveScanBudget(current_page_);
ui::NavPage tracePage = current_page_;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks,
transactionSnapshot = std::move(transactionSnapshot)]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc_);
transactionSnapshot = std::move(transactionSnapshot), tracePage]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Transaction refresh"));
auto result = NetworkRefreshService::collectTransactionRefreshResult(
refreshRpc, transactionSnapshot, currentBlocks, MAX_VIEWTX_PER_CYCLE);
return [this, result = std::move(result)]() mutable {
bool shieldedScanComplete = result.shieldedScanComplete;
std::size_t nextShieldedScanStartIndex = result.nextShieldedScanStartIndex;
auto shieldedScanHeights = std::move(result.shieldedScanHeights);
NetworkRefreshService::TransactionCacheUpdate cacheUpdate{
viewtx_cache_,
send_txids_,
@@ -689,11 +1124,61 @@ void App::refreshTransactionData()
};
NetworkRefreshService::applyTransactionRefreshResult(
state_, cacheUpdate, std::move(result), std::time(nullptr));
shielded_history_scan_heights_ = std::move(shieldedScanHeights);
storeTransactionHistoryCacheIfAvailable();
shielded_history_scan_cursor_ = nextShieldedScanStartIndex;
shielded_history_scan_pending_ = !shieldedScanComplete;
transactions_dirty_ = !shieldedScanComplete;
maybeFinishTransactionSendProgress();
};
}, 3);
if (!enqueued.enqueued) return;
network_refresh_.resetTxAge();
}
void App::refreshRecentTransactionData()
{
if (!worker_ || !rpc_ || !state_.connected) return;
if (!shouldRefreshRecentTransactions()) return;
const int currentBlocks = state_.sync.blocks;
auto transactionSnapshot = NetworkRefreshService::buildTransactionRefreshSnapshot(
state_, viewtx_cache_, send_txids_);
transactionSnapshot.pendingOpids.insert(pending_opids_.begin(), pending_opids_.end());
if (settings_) transactionSnapshot.miningAddresses = settings_->getMiningAddresses();
transactionSnapshot.shieldedScanHeights = shielded_history_scan_heights_;
transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_;
transactionSnapshot.maxShieldedReceiveScans = 1;
ui::NavPage tracePage = current_page_;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks,
transactionSnapshot = std::move(transactionSnapshot), tracePage]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Recent transaction poll"));
auto result = NetworkRefreshService::collectRecentTransactionRefreshResult(
refreshRpc, transactionSnapshot, currentBlocks);
return [this, result = std::move(result)]() mutable {
std::size_t nextShieldedScanStartIndex = result.nextShieldedScanStartIndex;
auto shieldedScanHeights = std::move(result.shieldedScanHeights);
NetworkRefreshService::TransactionCacheUpdate cacheUpdate{
viewtx_cache_,
send_txids_,
confirmed_tx_cache_,
confirmed_tx_ids_,
confirmed_cache_block_,
last_tx_block_height_
};
NetworkRefreshService::applyTransactionRefreshResult(
state_, cacheUpdate, std::move(result), std::time(nullptr));
shielded_history_scan_heights_ = std::move(shieldedScanHeights);
shielded_history_scan_cursor_ = nextShieldedScanStartIndex;
storeTransactionHistoryCacheIfAvailable();
maybeFinishTransactionSendProgress();
};
}, 3);
if (!enqueued.enqueued) return;
transactions_dirty_ = false;
network_refresh_.resetTxAge();
}
@@ -701,14 +1186,15 @@ void App::refreshTransactionData()
// Encryption State: wallet info (one-shot on connect, lightweight)
// ============================================================================
void App::refreshEncryptionState()
bool App::refreshEncryptionState()
{
if (!worker_ || !rpc_ || !state_.connected) return;
if (!worker_ || !rpc_ || !state_.connected) return false;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Encryption, *worker_, [this]() -> rpc::RPCWorker::MainCb {
json walletInfo;
bool ok = false;
try {
rpc::RPCClient::TraceScope trace("Startup / Wallet encryption state");
walletInfo = rpc_->call("getwalletinfo");
ok = true;
} catch (...) {}
@@ -718,9 +1204,16 @@ void App::refreshEncryptionState()
auto result = NetworkRefreshService::parseWalletEncryptionResult(walletInfo);
return [this, result]() {
NetworkRefreshService::applyWalletEncryptionResult(state_, result);
if (state_.isLocked()) {
resetTransactionHistoryCacheSession();
} else if (state_.transactions.empty()) {
loadTransactionHistoryCacheIfAvailable();
} else {
storeTransactionHistoryCacheIfAvailable();
}
};
}, 3);
if (!enqueued.enqueued) return;
return enqueued.enqueued;
}
void App::refreshBalance()
@@ -750,17 +1243,21 @@ void App::refreshMiningInfo()
}
// Slow-tick counter: run full getmininginfo every ~5 seconds
// to reduce RPC overhead. getlocalsolps (returns H/s for RandomX) runs every tick (1s).
// to reduce RPC overhead. getlocalsolps is only needed while solo mining
// or while the Mining tab is actively showing live local hashrate.
// NOTE: getinfo is NOT called here — longestchain/notarized are updated by
// refreshBalance (via getblockchaininfo), and daemon_version/protocol_version/
// p2p_port are static for the lifetime of a connection (set in onConnected).
bool doSlowRefresh = (mining_slow_counter_++ % 5 == 0);
bool includeLocalHashrate = state_.mining.generate || current_page_ == ui::NavPage::Mining;
if (!includeLocalHashrate && !doSlowRefresh) return;
ui::NavPage tracePage = current_page_;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Mining, *w,
[this, rpc, daemonMemMb, doSlowRefresh]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc);
[this, rpc, daemonMemMb, doSlowRefresh, includeLocalHashrate, tracePage]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc, traceSource(tracePage, "Mining refresh"));
auto result = NetworkRefreshService::collectMiningRefreshResult(
refreshRpc, daemonMemMb, doSlowRefresh);
refreshRpc, daemonMemMb, doSlowRefresh, includeLocalHashrate);
return [this, result]() {
try {
NetworkRefreshService::applyMiningRefreshResult(state_, result, std::time(nullptr));
@@ -780,9 +1277,10 @@ void App::refreshPeerInfo()
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
if (!w) return;
ui::NavPage tracePage = current_page_;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Peers, *w, [this, r]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*r);
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Peers, *w, [this, r, tracePage]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*r, traceSource(tracePage, "Peer refresh"));
auto result = NetworkRefreshService::collectPeerRefreshResult(refreshRpc);
return [this, result = std::move(result)]() mutable {
NetworkRefreshService::applyPeerRefreshResult(state_, std::move(result), std::time(nullptr));
@@ -881,6 +1379,7 @@ void App::startMining(int threads)
bool ok = false;
std::string errMsg;
try {
rpc::RPCClient::TraceScope trace("Mining tab / Start mining");
rpc_->call("setgenerate", {true, threads});
ok = true;
} catch (const std::exception& e) {
@@ -908,6 +1407,7 @@ void App::stopMining()
worker_->post([this]() -> rpc::RPCWorker::MainCb {
bool ok = false;
try {
rpc::RPCClient::TraceScope trace("Mining tab / Stop mining");
rpc_->call("setgenerate", {false, 0});
ok = true;
} catch (const std::exception& e) {
@@ -1065,6 +1565,7 @@ void App::applyDefaultBanlist()
int applied = 0;
for (const auto& ip : ips) {
try {
rpc::RPCClient::TraceScope trace("Startup / Default banlist");
// 0 = permanent ban (until node restart or manual unban)
// Using a very long duration (10 years) for effectively permanent bans
rpc_->call("setban", {ip, "add", 315360000});
@@ -1092,6 +1593,7 @@ void App::createNewZAddress(std::function<void(const std::string&)> callback)
worker_->post([this, callback]() -> rpc::RPCWorker::MainCb {
std::string addr;
try {
rpc::RPCClient::TraceScope trace("Receive tab / New shielded address");
json result = rpc_->call("z_getnewaddress");
addr = result.get<std::string>();
} catch (const std::exception& e) {
@@ -1122,6 +1624,7 @@ void App::createNewTAddress(std::function<void(const std::string&)> callback)
worker_->post([this, callback]() -> rpc::RPCWorker::MainCb {
std::string addr;
try {
rpc::RPCClient::TraceScope trace("Receive tab / New transparent address");
json result = rpc_->call("getnewaddress");
addr = result.get<std::string>();
} catch (const std::exception& e) {
@@ -1247,6 +1750,30 @@ void App::swapAddressOrder(const std::string& a, const std::string& b)
}
}
bool App::isMiningAddress(const std::string& addr) const
{
return settings_ && settings_->isMiningAddress(addr);
}
void App::setMiningAddress(const std::string& addr, bool mining)
{
if (settings_) {
settings_->setMiningAddress(addr, mining);
settings_->save();
invalidateShieldedHistoryScanProgress(true);
transactions_dirty_ = true;
last_tx_block_height_ = -1;
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
}
}
void App::invalidateAddressValidationCache()
{
address_validation_cache_dirty_ = true;
addresses_dirty_ = true;
invalidateShieldedHistoryScanProgress(true);
}
// ============================================================================
// Key Export/Import Operations
// ============================================================================
@@ -1261,6 +1788,7 @@ void App::exportPrivateKey(const std::string& address, std::function<void(const
auto keyKind = services::WalletSecurityController::classifyAddress(address);
if (keyKind == services::WalletSecurityController::KeyKind::Shielded) {
// Z-address: use z_exportkey
rpc::RPCClient::TraceScope trace("Settings / Export private key");
rpc_->z_exportKey(address, [callback](const json& result) {
if (callback) callback(result.get<std::string>());
}, [callback](const std::string& error) {
@@ -1270,6 +1798,7 @@ void App::exportPrivateKey(const std::string& address, std::function<void(const
});
} else {
// T-address: use dumpprivkey
rpc::RPCClient::TraceScope trace("Settings / Export private key");
rpc_->dumpPrivKey(address, [callback](const json& result) {
if (callback) callback(result.get<std::string>());
}, [callback](const std::string& error) {
@@ -1339,7 +1868,9 @@ void App::importPrivateKey(const std::string& key, std::function<void(bool, cons
auto keyKind = services::WalletSecurityController::classifyPrivateKey(key);
if (keyKind == services::WalletSecurityController::KeyKind::Shielded) {
rpc::RPCClient::TraceScope trace("Settings / Import private key");
rpc_->z_importKey(key, true, [this, callback](const json& result) {
invalidateAddressValidationCache();
refreshAddresses();
if (callback) callback(true, services::WalletSecurityController::importSuccessMessage(
services::WalletSecurityController::KeyKind::Shielded));
@@ -1347,7 +1878,9 @@ void App::importPrivateKey(const std::string& key, std::function<void(bool, cons
if (callback) callback(false, error);
});
} else {
rpc::RPCClient::TraceScope trace("Settings / Import private key");
rpc_->importPrivKey(key, true, [this, callback](const json& result) {
invalidateAddressValidationCache();
refreshAddresses();
if (callback) callback(true, services::WalletSecurityController::importSuccessMessage(
services::WalletSecurityController::KeyKind::Transparent));
@@ -1425,34 +1958,45 @@ void App::sendTransaction(const std::string& from, const std::string& to,
recipients.push_back(recipient);
// Run z_sendmany on worker thread to avoid blocking UI
if (worker_) {
worker_->post([this, from, recipients, callback]() -> rpc::RPCWorker::MainCb {
bool ok = false;
std::string result_str;
try {
auto result = rpc_->call("z_sendmany", {from, recipients});
result_str = result.get<std::string>();
ok = true;
} catch (const std::exception& e) {
result_str = e.what();
}
return [this, callback, ok, result_str]() {
if (ok) {
// A send changes address balances — refresh on next cycle
addresses_dirty_ = true;
// Force transaction list refresh so the sent tx appears immediately
transactions_dirty_ = true;
last_tx_block_height_ = -1;
network_refresh_.markWalletMutationRefresh();
// Track the opid so we can poll for completion
if (!result_str.empty()) {
pending_opids_.push_back(result_str);
}
}
if (callback) callback(ok, result_str);
};
});
if (!worker_) {
send_progress_active_ = false;
if (callback) callback(false, "RPC worker unavailable");
return;
}
send_progress_active_ = true;
++send_submissions_in_flight_;
worker_->post([this, from, to, amount, memo, recipients, callback]() -> rpc::RPCWorker::MainCb {
bool ok = false;
std::string result_str;
try {
rpc::RPCClient::TraceScope trace("Send tab / Submit transaction");
auto result = rpc_->call("z_sendmany", {from, recipients});
result_str = result.get<std::string>();
ok = true;
} catch (const std::exception& e) {
result_str = e.what();
}
return [this, callback, ok, result_str, from, to, amount, memo]() {
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
if (ok) {
// A send changes address balances — refresh on next cycle
addresses_dirty_ = true;
// Force transaction list refresh so the sent tx appears immediately
transactions_dirty_ = true;
last_tx_block_height_ = -1;
network_refresh_.markWalletMutationRefresh();
// Track the opid so we can poll for completion
if (!result_str.empty()) {
pending_opids_.push_back(result_str);
upsertPendingSendTransaction(result_str, from, to, amount, memo);
}
} else {
send_progress_active_ = false;
}
if (callback) callback(ok, result_str);
};
});
}
} // namespace dragonx

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 (...) {}

View File

@@ -136,6 +136,8 @@ bool Settings::load(const std::string& path)
m.icon = meta["icon"].get<std::string>();
if (meta.contains("order") && meta["order"].is_number_integer())
m.sortOrder = meta["order"].get<int>();
if (meta.contains("mining") && meta["mining"].is_boolean())
m.mining = meta["mining"].get<bool>();
address_meta_[addr] = m;
}
}
@@ -246,11 +248,12 @@ bool Settings::save(const std::string& path)
{
json meta_obj = json::object();
for (const auto& [addr, m] : address_meta_) {
if (m.label.empty() && m.icon.empty() && m.sortOrder < 0) continue;
if (m.label.empty() && m.icon.empty() && m.sortOrder < 0 && !m.mining) continue;
json entry = json::object();
if (!m.label.empty()) entry["label"] = m.label;
if (!m.icon.empty()) entry["icon"] = m.icon;
if (m.sortOrder >= 0) entry["order"] = m.sortOrder;
if (m.mining) entry["mining"] = true;
meta_obj[addr] = entry;
}
j["address_meta"] = meta_obj;

View File

@@ -147,6 +147,7 @@ public:
std::string label;
std::string icon; // material icon name, e.g. "savings"
int sortOrder = -1; // -1 = auto (use default sort)
bool mining = false;
};
const AddressMeta& getAddressMeta(const std::string& addr) const {
static const AddressMeta empty{};
@@ -162,6 +163,20 @@ public:
void setAddressSortOrder(const std::string& addr, int order) {
address_meta_[addr].sortOrder = order;
}
bool isMiningAddress(const std::string& addr) const {
auto it = address_meta_.find(addr);
return it != address_meta_.end() && it->second.mining;
}
void setMiningAddress(const std::string& addr, bool mining) {
address_meta_[addr].mining = mining;
}
std::set<std::string> getMiningAddresses() const {
std::set<std::string> addresses;
for (const auto& [addr, meta] : address_meta_) {
if (meta.mining) addresses.insert(addr);
}
return addresses;
}
int getNextSortOrder() const {
int mx = -1;
for (const auto& [k, v] : address_meta_)

View File

@@ -0,0 +1,510 @@
#include "transaction_history_cache.h"
#include "../util/logger.h"
#include "../util/platform.h"
#include <nlohmann/json.hpp>
#include <sqlite3.h>
#include <sodium.h>
#include <algorithm>
#include <cstdio>
#include <filesystem>
#include <utility>
namespace fs = std::filesystem;
using json = nlohmann::json;
namespace dragonx {
namespace data {
namespace {
constexpr int kSchemaVersion = 1;
constexpr std::size_t kKeyBytes = 32;
struct Statement {
sqlite3_stmt* handle = nullptr;
Statement(sqlite3* db, const char* sql)
{
if (sqlite3_prepare_v2(db, sql, -1, &handle, nullptr) != SQLITE_OK) {
handle = nullptr;
}
}
~Statement()
{
if (handle) sqlite3_finalize(handle);
}
Statement(const Statement&) = delete;
Statement& operator=(const Statement&) = delete;
};
bool bindText(sqlite3_stmt* statement, int index, const std::string& value)
{
return sqlite3_bind_text(statement, index, value.c_str(), -1, SQLITE_TRANSIENT) == SQLITE_OK;
}
bool bindBlob(sqlite3_stmt* statement, int index, const std::vector<unsigned char>& value)
{
return sqlite3_bind_blob(statement, index, value.data(), static_cast<int>(value.size()), SQLITE_TRANSIENT) == SQLITE_OK;
}
std::vector<unsigned char> readBlob(sqlite3_stmt* statement, int index)
{
const void* data = sqlite3_column_blob(statement, index);
int bytes = sqlite3_column_bytes(statement, index);
if (!data || bytes <= 0) return {};
const auto* begin = static_cast<const unsigned char*>(data);
return std::vector<unsigned char>(begin, begin + bytes);
}
std::string hexEncode(const unsigned char* bytes, std::size_t length)
{
static constexpr char kHex[] = "0123456789abcdef";
std::string output;
output.resize(length * 2);
for (std::size_t index = 0; index < length; ++index) {
output[index * 2] = kHex[(bytes[index] >> 4) & 0x0F];
output[index * 2 + 1] = kHex[bytes[index] & 0x0F];
}
return output;
}
json transactionToJson(const TransactionInfo& transaction)
{
return json{
{"txid", transaction.txid},
{"type", transaction.type},
{"amount", transaction.amount},
{"timestamp", transaction.timestamp},
{"confirmations", transaction.confirmations},
{"address", transaction.address},
{"from_address", transaction.from_address},
{"memo", transaction.memo}
};
}
TransactionInfo transactionFromJson(const json& source)
{
TransactionInfo transaction;
transaction.txid = source.value("txid", std::string());
transaction.type = source.value("type", std::string());
transaction.amount = source.value("amount", 0.0);
transaction.timestamp = source.value("timestamp", static_cast<std::int64_t>(0));
transaction.confirmations = source.value("confirmations", 0);
transaction.address = source.value("address", std::string());
transaction.from_address = source.value("from_address", std::string());
transaction.memo = source.value("memo", std::string());
return transaction;
}
std::string associatedDataForWallet(const std::string& walletHash)
{
return std::string("obsidian-dragon-tx-history-v1:") + walletHash;
}
} // namespace
TransactionHistoryCache::TransactionHistoryCache()
: TransactionHistoryCache(defaultDatabasePath())
{
}
TransactionHistoryCache::TransactionHistoryCache(std::string databasePath)
: database_path_(std::move(databasePath))
{
if (sodium_init() < 0) {
DEBUG_LOGF("Failed to initialize libsodium for transaction history cache\n");
}
}
TransactionHistoryCache::~TransactionHistoryCache()
{
lockKey();
close();
}
std::string TransactionHistoryCache::defaultDatabasePath()
{
return (fs::path(util::Platform::getConfigDir()) / "transaction_history.sqlite").string();
}
std::string TransactionHistoryCache::walletIdentityFromAddresses(
const std::vector<std::string>& shieldedAddresses,
const std::vector<std::string>& transparentAddresses)
{
std::vector<std::string> addresses;
addresses.reserve(shieldedAddresses.size() + transparentAddresses.size());
for (const auto& address : shieldedAddresses) {
if (!address.empty()) addresses.push_back("z:" + address);
}
for (const auto& address : transparentAddresses) {
if (!address.empty()) addresses.push_back("t:" + address);
}
if (addresses.empty()) return {};
std::sort(addresses.begin(), addresses.end());
std::string identity = "wallet-addresses-v1\n";
for (const auto& address : addresses) {
identity += address;
identity += '\n';
}
return identity;
}
std::string TransactionHistoryCache::walletIdentityHash(const std::string& walletIdentity)
{
unsigned char digest[crypto_generichash_BYTES];
crypto_generichash(digest, sizeof(digest),
reinterpret_cast<const unsigned char*>(walletIdentity.data()),
walletIdentity.size(), nullptr, 0);
return hexEncode(digest, sizeof(digest));
}
bool TransactionHistoryCache::ensureOpen()
{
if (db_) return true;
try {
fs::path path(database_path_);
if (!path.parent_path().empty()) fs::create_directories(path.parent_path());
} catch (const std::exception& exception) {
DEBUG_LOGF("Failed to create transaction history cache directory: %s\n", exception.what());
return false;
}
sqlite3* openedDb = nullptr;
if (sqlite3_open(database_path_.c_str(), &openedDb) != SQLITE_OK) {
DEBUG_LOGF("Failed to open transaction history cache: %s\n",
openedDb ? sqlite3_errmsg(openedDb) : "unknown error");
if (openedDb) sqlite3_close(openedDb);
return false;
}
db_ = openedDb;
sqlite3_busy_timeout(db_, 2000);
exec("PRAGMA journal_mode=WAL");
exec("PRAGMA synchronous=NORMAL");
if (!createSchema()) {
close();
return false;
}
return true;
}
bool TransactionHistoryCache::unlockWithPassphrase(const std::string& walletIdentity,
const std::string& passphrase)
{
if (walletIdentity.empty() || passphrase.empty() || !ensureOpen()) return false;
std::string walletHash = walletIdentityHash(walletIdentity);
std::vector<unsigned char> salt = getOrCreateSalt(walletHash);
if (salt.empty()) return false;
if (!deriveKey(passphrase, salt)) return false;
unlocked_wallet_hash_ = std::move(walletHash);
key_ready_ = true;
return true;
}
void TransactionHistoryCache::lockKey()
{
if (key_ready_) sodium_memzero(key_.data(), key_.size());
key_ready_ = false;
unlocked_wallet_hash_.clear();
}
bool TransactionHistoryCache::isUnlockedFor(const std::string& walletIdentity) const
{
return key_ready_ && !walletIdentity.empty() &&
unlocked_wallet_hash_ == walletIdentityHash(walletIdentity);
}
TransactionHistoryCache::LoadResult TransactionHistoryCache::load(
const std::string& walletIdentity,
int currentTipHeight,
const std::string& currentTipHash)
{
LoadResult result;
if (!isUnlockedFor(walletIdentity) || !ensureOpen()) return result;
std::string walletHash = walletIdentityHash(walletIdentity);
int tipHeight = 0;
std::string tipHash;
std::time_t updatedAt = 0;
std::vector<unsigned char> nonce;
std::vector<unsigned char> cipherText;
if (!readSnapshot(walletHash, tipHeight, tipHash, updatedAt, nonce, cipherText)) return result;
if ((currentTipHeight > 0 && tipHeight > currentTipHeight) ||
(currentTipHeight > 0 && tipHeight == currentTipHeight &&
!currentTipHash.empty() && !tipHash.empty() && tipHash != currentTipHash)) {
clearWalletByHash(walletHash);
result.invalidated = true;
return result;
}
std::string plainText;
if (!decryptPayload(walletHash, nonce, cipherText, plainText)) return result;
try {
json payload = json::parse(plainText);
if (payload.value("schema_version", 0) != kSchemaVersion) return result;
if (payload.value("wallet_hash", std::string()) != walletHash) return result;
if (!payload.contains("transactions") || !payload["transactions"].is_array()) return result;
result.transactions.reserve(payload["transactions"].size());
for (const auto& transactionJson : payload["transactions"]) {
if (transactionJson.is_object()) {
result.transactions.push_back(transactionFromJson(transactionJson));
}
}
if (payload.contains("shielded_scan_heights") && payload["shielded_scan_heights"].is_object()) {
for (auto it = payload["shielded_scan_heights"].begin();
it != payload["shielded_scan_heights"].end(); ++it) {
if (!it.key().empty() && it.value().is_number_integer()) {
result.shieldedScanHeights[it.key()] = it.value().get<int>();
}
}
}
result.tipHeight = tipHeight;
result.tipHash = tipHash;
result.updatedAt = updatedAt;
result.loaded = true;
} catch (...) {
result.transactions.clear();
}
sodium_memzero(plainText.data(), plainText.size());
return result;
}
bool TransactionHistoryCache::replace(const std::string& walletIdentity,
int tipHeight,
const std::string& tipHash,
const std::vector<TransactionInfo>& transactions,
std::time_t updatedAt,
const std::unordered_map<std::string, int>& shieldedScanHeights)
{
if (!isUnlockedFor(walletIdentity) || !ensureOpen()) return false;
std::string walletHash = walletIdentityHash(walletIdentity);
json payload;
payload["schema_version"] = kSchemaVersion;
payload["wallet_hash"] = walletHash;
payload["tip_height"] = tipHeight;
payload["tip_hash"] = tipHash;
payload["updated_at"] = static_cast<std::int64_t>(updatedAt);
payload["transactions"] = json::array();
for (const auto& transaction : transactions) {
payload["transactions"].push_back(transactionToJson(transaction));
}
payload["shielded_scan_heights"] = json::object();
for (const auto& [address, height] : shieldedScanHeights) {
if (!address.empty() && height >= 0) {
payload["shielded_scan_heights"][address] = height;
}
}
std::string plainText = payload.dump();
std::vector<unsigned char> nonce;
std::vector<unsigned char> cipherText;
bool encrypted = encryptPayload(walletHash, plainText, nonce, cipherText);
sodium_memzero(plainText.data(), plainText.size());
if (!encrypted) return false;
Statement statement(db_,
"INSERT OR REPLACE INTO transaction_history_snapshots "
"(wallet_hash, schema_version, tip_height, tip_hash, updated_at, nonce, ciphertext) "
"VALUES (?, ?, ?, ?, ?, ?, ?)");
if (!statement.handle) return false;
if (!bindText(statement.handle, 1, walletHash)) return false;
sqlite3_bind_int(statement.handle, 2, kSchemaVersion);
sqlite3_bind_int(statement.handle, 3, std::max(0, tipHeight));
if (!bindText(statement.handle, 4, tipHash)) return false;
sqlite3_bind_int64(statement.handle, 5, static_cast<sqlite3_int64>(updatedAt));
if (!bindBlob(statement.handle, 6, nonce)) return false;
if (!bindBlob(statement.handle, 7, cipherText)) return false;
return sqlite3_step(statement.handle) == SQLITE_DONE;
}
void TransactionHistoryCache::clearWallet(const std::string& walletIdentity)
{
if (walletIdentity.empty()) return;
clearWalletByHash(walletIdentityHash(walletIdentity));
}
int TransactionHistoryCache::snapshotCount()
{
if (!ensureOpen()) return 0;
Statement statement(db_, "SELECT COUNT(*) FROM transaction_history_snapshots");
if (!statement.handle || sqlite3_step(statement.handle) != SQLITE_ROW) return 0;
return sqlite3_column_int(statement.handle, 0);
}
bool TransactionHistoryCache::exec(const char* sql)
{
if (!db_) return false;
char* error = nullptr;
int result = sqlite3_exec(db_, sql, nullptr, nullptr, &error);
if (result != SQLITE_OK) {
DEBUG_LOGF("Transaction history cache SQL error: %s\n", error ? error : sqlite3_errmsg(db_));
if (error) sqlite3_free(error);
return false;
}
return true;
}
bool TransactionHistoryCache::createSchema()
{
return exec("CREATE TABLE IF NOT EXISTS transaction_history_keys ("
"wallet_hash TEXT PRIMARY KEY,"
"salt BLOB NOT NULL)") &&
exec("CREATE TABLE IF NOT EXISTS transaction_history_snapshots ("
"wallet_hash TEXT PRIMARY KEY,"
"schema_version INTEGER NOT NULL,"
"tip_height INTEGER NOT NULL,"
"tip_hash TEXT NOT NULL,"
"updated_at INTEGER NOT NULL,"
"nonce BLOB NOT NULL,"
"ciphertext BLOB NOT NULL)");
}
std::vector<unsigned char> TransactionHistoryCache::getOrCreateSalt(const std::string& walletHash)
{
if (!ensureOpen()) return {};
{
Statement statement(db_, "SELECT salt FROM transaction_history_keys WHERE wallet_hash = ?");
if (!statement.handle) return {};
if (!bindText(statement.handle, 1, walletHash)) return {};
if (sqlite3_step(statement.handle) == SQLITE_ROW) {
auto salt = readBlob(statement.handle, 0);
if (salt.size() == crypto_pwhash_SALTBYTES) return salt;
}
}
std::vector<unsigned char> salt(crypto_pwhash_SALTBYTES);
randombytes_buf(salt.data(), salt.size());
Statement insert(db_,
"INSERT OR REPLACE INTO transaction_history_keys (wallet_hash, salt) VALUES (?, ?)");
if (!insert.handle) return {};
if (!bindText(insert.handle, 1, walletHash)) return {};
if (!bindBlob(insert.handle, 2, salt)) return {};
if (sqlite3_step(insert.handle) != SQLITE_DONE) return {};
return salt;
}
bool TransactionHistoryCache::deriveKey(const std::string& passphrase,
const std::vector<unsigned char>& salt)
{
if (salt.size() != crypto_pwhash_SALTBYTES) return false;
unsigned char derived[kKeyBytes];
int result = crypto_pwhash(derived, sizeof(derived),
passphrase.c_str(), passphrase.size(),
salt.data(),
crypto_pwhash_OPSLIMIT_INTERACTIVE,
crypto_pwhash_MEMLIMIT_INTERACTIVE,
crypto_pwhash_ALG_ARGON2ID13);
if (result != 0) return false;
std::copy(derived, derived + sizeof(derived), key_.begin());
sodium_memzero(derived, sizeof(derived));
return true;
}
bool TransactionHistoryCache::encryptPayload(const std::string& walletHash,
const std::string& plainText,
std::vector<unsigned char>& nonce,
std::vector<unsigned char>& cipherText) const
{
if (!key_ready_) return false;
nonce.resize(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
randombytes_buf(nonce.data(), nonce.size());
std::string associatedData = associatedDataForWallet(walletHash);
cipherText.resize(plainText.size() + crypto_aead_xchacha20poly1305_ietf_ABYTES);
unsigned long long cipherLength = 0;
int result = crypto_aead_xchacha20poly1305_ietf_encrypt(
cipherText.data(), &cipherLength,
reinterpret_cast<const unsigned char*>(plainText.data()), plainText.size(),
reinterpret_cast<const unsigned char*>(associatedData.data()), associatedData.size(),
nullptr, nonce.data(), key_.data());
if (result != 0) return false;
cipherText.resize(static_cast<std::size_t>(cipherLength));
return true;
}
bool TransactionHistoryCache::decryptPayload(const std::string& walletHash,
const std::vector<unsigned char>& nonce,
const std::vector<unsigned char>& cipherText,
std::string& plainText) const
{
if (!key_ready_ || nonce.size() != crypto_aead_xchacha20poly1305_ietf_NPUBBYTES ||
cipherText.size() < crypto_aead_xchacha20poly1305_ietf_ABYTES) {
return false;
}
std::string associatedData = associatedDataForWallet(walletHash);
std::vector<unsigned char> plain(cipherText.size() - crypto_aead_xchacha20poly1305_ietf_ABYTES);
unsigned long long plainLength = 0;
int result = crypto_aead_xchacha20poly1305_ietf_decrypt(
plain.data(), &plainLength, nullptr,
cipherText.data(), cipherText.size(),
reinterpret_cast<const unsigned char*>(associatedData.data()), associatedData.size(),
nonce.data(), key_.data());
if (result != 0) return false;
plainText.assign(reinterpret_cast<const char*>(plain.data()), static_cast<std::size_t>(plainLength));
sodium_memzero(plain.data(), plain.size());
return true;
}
bool TransactionHistoryCache::readSnapshot(const std::string& walletHash,
int& tipHeight,
std::string& tipHash,
std::time_t& updatedAt,
std::vector<unsigned char>& nonce,
std::vector<unsigned char>& cipherText)
{
Statement statement(db_,
"SELECT tip_height, tip_hash, updated_at, nonce, ciphertext "
"FROM transaction_history_snapshots WHERE wallet_hash = ?");
if (!statement.handle) return false;
if (!bindText(statement.handle, 1, walletHash)) return false;
if (sqlite3_step(statement.handle) != SQLITE_ROW) return false;
tipHeight = sqlite3_column_int(statement.handle, 0);
const unsigned char* tipHashText = sqlite3_column_text(statement.handle, 1);
tipHash = tipHashText ? reinterpret_cast<const char*>(tipHashText) : std::string();
updatedAt = static_cast<std::time_t>(sqlite3_column_int64(statement.handle, 2));
nonce = readBlob(statement.handle, 3);
cipherText = readBlob(statement.handle, 4);
return !nonce.empty() && !cipherText.empty();
}
void TransactionHistoryCache::clearWalletByHash(const std::string& walletHash)
{
if (!ensureOpen()) return;
Statement statement(db_, "DELETE FROM transaction_history_snapshots WHERE wallet_hash = ?");
if (!statement.handle) return;
if (!bindText(statement.handle, 1, walletHash)) return;
sqlite3_step(statement.handle);
}
void TransactionHistoryCache::close()
{
if (!db_) return;
sqlite3_close(db_);
db_ = nullptr;
}
} // namespace data
} // namespace dragonx

View File

@@ -0,0 +1,90 @@
#pragma once
#include "wallet_state.h"
#include <array>
#include <cstdint>
#include <ctime>
#include <string>
#include <unordered_map>
#include <vector>
struct sqlite3;
namespace dragonx {
namespace data {
class TransactionHistoryCache {
public:
struct LoadResult {
bool loaded = false;
bool invalidated = false;
int tipHeight = 0;
std::string tipHash;
std::time_t updatedAt = 0;
std::vector<TransactionInfo> transactions;
std::unordered_map<std::string, int> shieldedScanHeights;
};
TransactionHistoryCache();
explicit TransactionHistoryCache(std::string databasePath);
~TransactionHistoryCache();
TransactionHistoryCache(const TransactionHistoryCache&) = delete;
TransactionHistoryCache& operator=(const TransactionHistoryCache&) = delete;
static std::string defaultDatabasePath();
static std::string walletIdentityFromAddresses(const std::vector<std::string>& shieldedAddresses,
const std::vector<std::string>& transparentAddresses);
static std::string walletIdentityHash(const std::string& walletIdentity);
bool ensureOpen();
bool unlockWithPassphrase(const std::string& walletIdentity, const std::string& passphrase);
void lockKey();
bool hasKey() const { return key_ready_; }
bool isUnlockedFor(const std::string& walletIdentity) const;
LoadResult load(const std::string& walletIdentity,
int currentTipHeight,
const std::string& currentTipHash);
bool replace(const std::string& walletIdentity,
int tipHeight,
const std::string& tipHash,
const std::vector<TransactionInfo>& transactions,
std::time_t updatedAt,
const std::unordered_map<std::string, int>& shieldedScanHeights = {});
void clearWallet(const std::string& walletIdentity);
int snapshotCount();
private:
bool exec(const char* sql);
bool createSchema();
std::vector<unsigned char> getOrCreateSalt(const std::string& walletHash);
bool deriveKey(const std::string& passphrase,
const std::vector<unsigned char>& salt);
bool encryptPayload(const std::string& walletHash,
const std::string& plainText,
std::vector<unsigned char>& nonce,
std::vector<unsigned char>& cipherText) const;
bool decryptPayload(const std::string& walletHash,
const std::vector<unsigned char>& nonce,
const std::vector<unsigned char>& cipherText,
std::string& plainText) const;
bool readSnapshot(const std::string& walletHash,
int& tipHeight,
std::string& tipHash,
std::time_t& updatedAt,
std::vector<unsigned char>& nonce,
std::vector<unsigned char>& cipherText);
void clearWalletByHash(const std::string& walletHash);
void close();
sqlite3* db_ = nullptr;
std::string database_path_;
std::array<unsigned char, 32> key_{};
bool key_ready_ = false;
std::string unlocked_wallet_hash_;
};
} // namespace data
} // namespace dragonx

View File

@@ -1224,6 +1224,8 @@ int main(int argc, char* argv[])
// Immediate triggers: async RPC results or visible notifications
bool hasImmediateWork = app.hasPendingRPCResults()
|| app.hasTransactionSendProgress()
|| app.isTransactionRefreshInProgress()
|| dragonx::ui::Notifications::instance().hasActive();
// Periodic maintenance: fire refresh timers in app.update()
@@ -1801,6 +1803,8 @@ int main(int argc, char* argv[])
&& !opaqueBackground;
bool animating = app.isShuttingDown()
|| backdropNeedsFrames
|| app.hasTransactionSendProgress()
|| app.isTransactionRefreshInProgress()
|| dragonx::ui::effects::ThemeEffects::instance().hasActiveAnimation()
|| dragonx::ui::Notifications::instance().hasActive()
|| dragonx::ui::material::SmoothScrollAnimating();

View File

@@ -10,13 +10,76 @@
#include "../util/base64.h"
#include <curl/curl.h>
#include <atomic>
#include <cstdio>
#include <cstring>
#include <utility>
#include "../util/logger.h"
namespace dragonx {
namespace rpc {
namespace {
std::mutex g_trace_mutex;
RPCClient::TraceCallback g_trace_callback;
std::atomic_bool g_trace_enabled{false};
thread_local std::string g_trace_source;
void emitRpcTrace(const std::string& method)
{
if (!g_trace_enabled.load(std::memory_order_relaxed)) return;
RPCClient::TraceCallback callback;
{
std::lock_guard<std::mutex> lock(g_trace_mutex);
callback = g_trace_callback;
}
if (!callback) return;
std::string source = g_trace_source.empty() ? std::string("App") : g_trace_source;
callback(source, method);
}
} // namespace
RPCClient::TraceScope::TraceScope(std::string source)
: previous_(RPCClient::currentTraceSource())
{
RPCClient::setTraceSource(std::move(source));
}
RPCClient::TraceScope::~TraceScope()
{
RPCClient::setTraceSource(std::move(previous_));
}
void RPCClient::setTraceCallback(TraceCallback callback)
{
std::lock_guard<std::mutex> lock(g_trace_mutex);
g_trace_callback = std::move(callback);
}
void RPCClient::setTraceEnabled(bool enabled)
{
g_trace_enabled.store(enabled, std::memory_order_relaxed);
}
bool RPCClient::isTraceEnabled()
{
return g_trace_enabled.load(std::memory_order_relaxed);
}
std::string RPCClient::currentTraceSource()
{
return g_trace_source;
}
void RPCClient::setTraceSource(std::string source)
{
g_trace_source = std::move(source);
}
// Callback for libcurl to write response data
static size_t WriteCallback(void* contents, size_t size, size_t nmemb, std::string* userp) {
size_t totalSize = size * nmemb;
@@ -71,6 +134,7 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
host_ = host;
port_ = port;
last_connect_info_ = json();
// Create Basic auth header with proper base64 encoding
std::string credentials = user + ":" + password;
@@ -116,6 +180,7 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
warming_up_ = false;
warmup_status_.clear();
last_connect_error_.clear();
last_connect_info_ = result;
DEBUG_LOGF("Connected to dragonxd v%d\n", result["version"].get<int>());
return true;
}
@@ -146,15 +211,23 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
connected_ = false;
warming_up_ = false;
warmup_status_.clear();
last_connect_info_ = json();
return false;
}
json RPCClient::getLastConnectInfo() const
{
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
return last_connect_info_;
}
void RPCClient::disconnect()
{
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
connected_ = false;
warming_up_ = false;
warmup_status_.clear();
last_connect_info_ = json();
if (impl_->curl) {
curl_easy_cleanup(impl_->curl);
impl_->curl = nullptr;
@@ -182,6 +255,8 @@ json RPCClient::call(const std::string& method, const json& params)
throw std::runtime_error("Not connected");
}
emitRpcTrace(method);
json payload = makePayload(method, params);
std::string body = payload.dump();
std::string response_data;
@@ -235,6 +310,8 @@ json RPCClient::call(const std::string& method, const json& params, long timeout
throw std::runtime_error("Not connected");
}
emitRpcTrace(method);
// Temporarily override timeout
long prevTimeout = 30L;
curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, timeoutSec);
@@ -294,6 +371,8 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
throw std::runtime_error("Not connected");
}
emitRpcTrace(method);
json payload = makePayload(method, params);
std::string body = payload.dump();
std::string response_data;

View File

@@ -25,6 +25,20 @@ using ErrorCallback = std::function<void(const std::string&)>;
*/
class RPCClient {
public:
using TraceCallback = std::function<void(const std::string& source, const std::string& method)>;
class TraceScope {
public:
explicit TraceScope(std::string source);
~TraceScope();
TraceScope(const TraceScope&) = delete;
TraceScope& operator=(const TraceScope&) = delete;
private:
std::string previous_;
};
RPCClient();
~RPCClient();
@@ -74,6 +88,13 @@ public:
* @brief Get the error message from the last failed connect() attempt.
*/
const std::string& getLastConnectError() const { return last_connect_error_; }
json getLastConnectInfo() const;
static void setTraceCallback(TraceCallback callback);
static void setTraceEnabled(bool enabled);
static bool isTraceEnabled();
static std::string currentTraceSource();
static void setTraceSource(std::string source);
/**
* @brief Make a raw RPC call
@@ -202,6 +223,7 @@ private:
bool warming_up_ = false;
std::string warmup_status_;
std::string last_connect_error_;
json last_connect_info_;
mutable std::recursive_mutex curl_mutex_; // serializes all curl handle access
// HTTP client (implementation hidden)

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);
}

View File

@@ -103,6 +103,7 @@ public:
bool blockchainOk = false;
std::optional<int> blocks;
std::optional<int> headers;
std::optional<std::string> bestBlockHash;
std::optional<double> verificationProgress;
std::optional<int> longestChain;
std::optional<int> notarized;
@@ -146,6 +147,10 @@ public:
std::vector<AddressInfo> transparentAddresses;
};
struct AddressRefreshSnapshot {
std::unordered_map<std::string, bool> shieldedSpendingKeys;
};
struct TransactionViewCacheEntry {
std::string from_address;
std::int64_t timestamp = 0;
@@ -165,12 +170,32 @@ public:
std::unordered_set<std::string> fullyEnrichedTxids;
TransactionViewCache viewTxCache;
std::unordered_set<std::string> sendTxids;
std::unordered_set<std::string> pendingOpids;
std::vector<TransactionInfo> previousTransactions;
std::set<std::string> miningAddresses;
std::unordered_map<std::string, int> shieldedScanHeights;
std::size_t shieldedScanStartIndex = 0;
std::size_t maxShieldedReceiveScans = 0;
};
struct TransactionRefreshResult {
std::vector<TransactionInfo> transactions;
int blockHeight = -1;
TransactionViewCache newViewTxEntries;
std::size_t nextShieldedScanStartIndex = 0;
std::size_t shieldedAddressesScanned = 0;
std::size_t shieldedAddressCount = 0;
std::unordered_map<std::string, int> shieldedScanHeights;
bool shieldedScanComplete = true;
};
struct OperationStatusPollResult {
std::vector<std::string> doneOpids;
std::vector<std::string> staleOpids;
std::vector<std::string> successTxids;
std::unordered_map<std::string, std::string> successTxidsByOpid;
std::vector<std::string> failureMessages;
bool anySuccess = false;
};
struct TransactionCacheUpdate {
@@ -187,7 +212,9 @@ public:
static ConnectionInfoResult parseConnectionInfoResult(const nlohmann::json& info);
static WalletEncryptionResult parseWalletEncryptionResult(const nlohmann::json& walletInfo);
static WarmupPollResult collectWarmupPollResult(RefreshRpcGateway& rpc);
static ConnectionInitResult collectConnectionInitResult(RefreshRpcGateway& rpc);
static ConnectionInitResult collectConnectionInitResult(
RefreshRpcGateway& rpc,
const std::optional<ConnectionInfoResult>& prefetchedInfo = std::nullopt);
static CoreRefreshResult parseCoreRefreshResult(const nlohmann::json& totalBalance,
bool balanceOk,
const nlohmann::json& blockInfo,
@@ -200,7 +227,8 @@ public:
double daemonMemoryMb);
static MiningRefreshResult collectMiningRefreshResult(RefreshRpcGateway& rpc,
double daemonMemoryMb,
bool includeSlowRefresh);
bool includeSlowRefresh,
bool includeLocalHashrate = true);
static PeerRefreshResult parsePeerRefreshResult(const nlohmann::json& peers,
const nlohmann::json& bannedPeers);
static PeerRefreshResult collectPeerRefreshResult(RefreshRpcGateway& rpc);
@@ -217,17 +245,22 @@ public:
const nlohmann::json& unspent);
static void applyTransparentBalancesFromUnspent(std::vector<AddressInfo>& addresses,
const nlohmann::json& unspent);
static AddressRefreshResult collectAddressRefreshResult(RefreshRpcGateway& rpc);
static AddressRefreshSnapshot buildAddressRefreshSnapshot(const WalletState& state);
static AddressRefreshResult collectAddressRefreshResult(
RefreshRpcGateway& rpc,
const AddressRefreshSnapshot& snapshot = {});
static TransactionRefreshSnapshot buildTransactionRefreshSnapshot(const WalletState& state,
const TransactionViewCache& viewTxCache,
const std::unordered_set<std::string>& sendTxids);
static void appendTransparentTransactions(std::vector<TransactionInfo>& transactions,
std::set<std::string>& knownTxids,
const nlohmann::json& result);
const nlohmann::json& result,
const std::set<std::string>& miningAddresses = {});
static void appendShieldedReceivedTransactions(std::vector<TransactionInfo>& transactions,
std::set<std::string>& knownTxids,
const std::string& address,
const nlohmann::json& received);
const nlohmann::json& received,
const std::set<std::string>& miningAddresses = {});
static TransactionViewCacheEntry parseViewTransactionCacheEntry(const nlohmann::json& viewTransaction);
static void appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
const std::string& txid,
@@ -237,6 +270,13 @@ public:
const TransactionRefreshSnapshot& snapshot,
int currentBlockHeight,
int maxViewTransactionsPerCycle);
static TransactionRefreshResult collectRecentTransactionRefreshResult(
RefreshRpcGateway& rpc,
const TransactionRefreshSnapshot& snapshot,
int currentBlockHeight,
int pageSize = 100);
static OperationStatusPollResult parseOperationStatusPoll(const nlohmann::json& result,
const std::vector<std::string>& requestedOpids);
static void applyConnectionInfoResult(WalletState& state, const ConnectionInfoResult& result);
static void applyWalletEncryptionResult(WalletState& state, const WalletEncryptionResult& result);
@@ -280,7 +320,6 @@ public:
void resetTxAge() { scheduler_.resetTxAge(); }
bool shouldRefreshTransactions(int lastTxBlockHeight,
int currentBlockHeight,
bool transactionsEmpty,
bool transactionsDirty) const;
bool beginJob(Job job);
@@ -301,7 +340,12 @@ public:
if (!ticket.accepted) return {ticket, false, queueDepth};
worker.post([this, ticket, work = std::forward<WorkFn>(work)]() mutable -> rpc::RPCWorker::MainCb {
auto mainCallback = work();
rpc::RPCWorker::MainCb mainCallback;
try {
mainCallback = work();
} catch (...) {
mainCallback = nullptr;
}
return [this, ticket, mainCallback = std::move(mainCallback)]() mutable {
if (!completeDispatch(ticket)) return;
if (mainCallback) mainCallback();

View File

@@ -16,6 +16,7 @@ RefreshScheduler::Intervals RefreshScheduler::intervalsForPage(ui::NavPage page)
case NP::Mining: return {5.0f, 15.0f, 15.0f, 0.0f};
case NP::Peers: return {5.0f, 15.0f, 15.0f, 5.0f};
case NP::Market: return {5.0f, 15.0f, 15.0f, 0.0f};
case NP::Console: return {10.0f, 30.0f, 30.0f, 0.0f};
default: return {5.0f, 15.0f, 15.0f, 0.0f};
}
}
@@ -114,14 +115,11 @@ void RefreshScheduler::resetTxAge()
bool RefreshScheduler::shouldRefreshTransactions(int lastTxBlockHeight,
int currentBlockHeight,
bool transactionsEmpty,
bool transactionsDirty) const
{
return lastTxBlockHeight < 0
|| currentBlockHeight != lastTxBlockHeight
|| transactionsEmpty
|| transactionsDirty
|| isDue(Timer::TxAge);
|| transactionsDirty;
}
float& RefreshScheduler::timerRef(Timer timer)

View File

@@ -55,7 +55,6 @@ public:
void resetTxAge();
bool shouldRefreshTransactions(int lastTxBlockHeight,
int currentBlockHeight,
bool transactionsEmpty,
bool transactionsDirty) const;
private:

View File

@@ -0,0 +1,362 @@
#include "explorer_block_cache.h"
#include "../../util/logger.h"
#include "../../util/platform.h"
#include <nlohmann/json.hpp>
#include <sqlite3.h>
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <utility>
namespace fs = std::filesystem;
using json = nlohmann::json;
namespace dragonx {
namespace ui {
namespace {
constexpr int kCacheSchemaVersion = 1;
struct Statement {
sqlite3_stmt* handle = nullptr;
Statement(sqlite3* db, const char* sql)
{
if (sqlite3_prepare_v2(db, sql, -1, &handle, nullptr) != SQLITE_OK) {
handle = nullptr;
}
}
~Statement()
{
if (handle) sqlite3_finalize(handle);
}
Statement(const Statement&) = delete;
Statement& operator=(const Statement&) = delete;
};
bool bindText(sqlite3_stmt* statement, int index, const std::string& value)
{
return sqlite3_bind_text(statement, index, value.c_str(), -1, SQLITE_TRANSIENT) == SQLITE_OK;
}
bool blockSummaryFromJson(const json& source, ExplorerBlockSummary& block)
{
if (!source.is_object()) return false;
try {
block.height = source.value("height", 0);
block.hash = source.value("hash", std::string());
block.tx_count = source.value("tx_count", 0);
block.size = source.value("size", 0);
block.time = source.value("time", static_cast<std::int64_t>(0));
block.difficulty = source.value("difficulty", 0.0);
} catch (...) {
return false;
}
return block.height > 0 && !block.hash.empty();
}
} // namespace
ExplorerBlockCache::ExplorerBlockCache()
: ExplorerBlockCache(defaultDatabasePath(), defaultLegacyJsonPath())
{
}
ExplorerBlockCache::ExplorerBlockCache(std::string databasePath, std::string legacyJsonPath)
: database_path_(std::move(databasePath)), legacy_json_path_(std::move(legacyJsonPath))
{
}
ExplorerBlockCache::~ExplorerBlockCache()
{
close();
}
std::string ExplorerBlockCache::defaultDatabasePath()
{
return (fs::path(util::Platform::getConfigDir()) / "explorer_blocks.sqlite").string();
}
std::string ExplorerBlockCache::defaultLegacyJsonPath()
{
return (fs::path(util::Platform::getConfigDir()) / "explorer_blocks_cache.json").string();
}
bool ExplorerBlockCache::ensureOpen()
{
if (db_) return true;
try {
fs::path path(database_path_);
if (!path.parent_path().empty()) fs::create_directories(path.parent_path());
} catch (const std::exception& e) {
DEBUG_LOGF("Failed to create explorer cache directory: %s\n", e.what());
return false;
}
sqlite3* openedDb = nullptr;
if (sqlite3_open(database_path_.c_str(), &openedDb) != SQLITE_OK) {
DEBUG_LOGF("Failed to open explorer block cache: %s\n",
openedDb ? sqlite3_errmsg(openedDb) : "unknown error");
if (openedDb) sqlite3_close(openedDb);
return false;
}
db_ = openedDb;
sqlite3_busy_timeout(db_, 2000);
exec("PRAGMA journal_mode=WAL");
exec("PRAGMA synchronous=NORMAL");
if (!createSchema()) {
close();
return false;
}
migrateLegacyJsonIfNeeded();
return true;
}
std::map<int, ExplorerBlockSummary> ExplorerBlockCache::loadRange(int minHeight, int maxHeight)
{
std::map<int, ExplorerBlockSummary> blocks;
if (minHeight > maxHeight) std::swap(minHeight, maxHeight);
if (minHeight < 1 || maxHeight < 1 || !ensureOpen()) return blocks;
Statement statement(db_,
"SELECT height, hash, tx_count, size, time, difficulty "
"FROM explorer_blocks WHERE height BETWEEN ? AND ? ORDER BY height DESC");
if (!statement.handle) return blocks;
sqlite3_bind_int(statement.handle, 1, minHeight);
sqlite3_bind_int(statement.handle, 2, maxHeight);
while (sqlite3_step(statement.handle) == SQLITE_ROW) {
ExplorerBlockSummary block;
block.height = sqlite3_column_int(statement.handle, 0);
const unsigned char* hashText = sqlite3_column_text(statement.handle, 1);
if (hashText) block.hash = reinterpret_cast<const char*>(hashText);
block.tx_count = sqlite3_column_int(statement.handle, 2);
block.size = sqlite3_column_int(statement.handle, 3);
block.time = static_cast<std::int64_t>(sqlite3_column_int64(statement.handle, 4));
block.difficulty = sqlite3_column_double(statement.handle, 5);
if (block.height > 0 && !block.hash.empty()) {
blocks[block.height] = std::move(block);
}
}
return blocks;
}
bool ExplorerBlockCache::storeBlock(const ExplorerBlockSummary& block)
{
if (block.height < 1 || block.hash.empty() || !ensureOpen()) return false;
Statement statement(db_,
"INSERT OR REPLACE INTO explorer_blocks "
"(height, hash, tx_count, size, time, difficulty) VALUES (?, ?, ?, ?, ?, ?)");
if (!statement.handle) return false;
sqlite3_bind_int(statement.handle, 1, block.height);
if (!bindText(statement.handle, 2, block.hash)) return false;
sqlite3_bind_int(statement.handle, 3, block.tx_count);
sqlite3_bind_int(statement.handle, 4, block.size);
sqlite3_bind_int64(statement.handle, 5, static_cast<sqlite3_int64>(block.time));
sqlite3_bind_double(statement.handle, 6, block.difficulty);
return sqlite3_step(statement.handle) == SQLITE_DONE;
}
int ExplorerBlockCache::cachedBlockCount()
{
if (!ensureOpen()) return 0;
Statement statement(db_, "SELECT COUNT(*) FROM explorer_blocks");
if (!statement.handle || sqlite3_step(statement.handle) != SQLITE_ROW) return 0;
return sqlite3_column_int(statement.handle, 0);
}
void ExplorerBlockCache::clearBlocks()
{
if (!ensureOpen()) return;
exec("DELETE FROM explorer_blocks");
}
ExplorerBlockCache::SavedTipValidation ExplorerBlockCache::prepareValidation(
int currentHeight, const std::string& currentBestHash)
{
SavedTipValidation validation;
if (currentHeight <= 0 || !ensureOpen()) return validation;
int savedHeight = getMetaInt("tip_height", 0);
std::string savedHash = getMetaValue("tip_hash");
if (savedHeight <= 0 || savedHash.empty()) {
if (!currentBestHash.empty()) updateTip(currentHeight, currentBestHash);
return validation;
}
if (currentHeight < savedHeight) {
clearBlocks();
if (!currentBestHash.empty()) updateTip(currentHeight, currentBestHash);
else updateTip(0, std::string());
return validation;
}
if (currentHeight == savedHeight) {
if (currentBestHash.empty()) return validation;
if (currentBestHash != savedHash) clearBlocks();
updateTip(currentHeight, currentBestHash);
return validation;
}
if (currentBestHash.empty()) return validation;
validation.needed = true;
validation.height = savedHeight;
validation.expectedHash = savedHash;
return validation;
}
void ExplorerBlockCache::applySavedTipValidation(const SavedTipValidation& validation,
const std::string& actualHash,
int currentHeight,
const std::string& currentBestHash)
{
if (!validation.needed || !ensureOpen()) return;
if (actualHash.empty()) return;
if (actualHash != validation.expectedHash) {
clearBlocks();
}
if (currentHeight > 0 && !currentBestHash.empty()) {
updateTip(currentHeight, currentBestHash);
}
}
void ExplorerBlockCache::updateTip(int height, const std::string& hash)
{
if (!ensureOpen()) return;
setMetaValue("tip_height", std::to_string(std::max(0, height)));
setMetaValue("tip_hash", hash);
}
bool ExplorerBlockCache::exec(const char* sql)
{
if (!db_) return false;
char* error = nullptr;
int result = sqlite3_exec(db_, sql, nullptr, nullptr, &error);
if (result != SQLITE_OK) {
DEBUG_LOGF("Explorer block cache SQL error: %s\n", error ? error : sqlite3_errmsg(db_));
if (error) sqlite3_free(error);
return false;
}
return true;
}
std::string ExplorerBlockCache::getMetaValue(const std::string& key)
{
if (!ensureOpen()) return {};
Statement statement(db_, "SELECT value FROM explorer_cache_meta WHERE key = ?");
if (!statement.handle) return {};
if (!bindText(statement.handle, 1, key)) return {};
if (sqlite3_step(statement.handle) != SQLITE_ROW) return {};
const unsigned char* valueText = sqlite3_column_text(statement.handle, 0);
return valueText ? reinterpret_cast<const char*>(valueText) : std::string();
}
int ExplorerBlockCache::getMetaInt(const std::string& key, int fallback)
{
std::string value = getMetaValue(key);
if (value.empty()) return fallback;
try {
return std::stoi(value);
} catch (...) {
return fallback;
}
}
void ExplorerBlockCache::setMetaValue(const std::string& key, const std::string& value)
{
if (!ensureOpen()) return;
Statement statement(db_,
"INSERT OR REPLACE INTO explorer_cache_meta (key, value) VALUES (?, ?)");
if (!statement.handle) return;
if (!bindText(statement.handle, 1, key)) return;
if (!bindText(statement.handle, 2, value)) return;
sqlite3_step(statement.handle);
}
bool ExplorerBlockCache::createSchema()
{
return exec("CREATE TABLE IF NOT EXISTS explorer_blocks ("
"height INTEGER PRIMARY KEY,"
"hash TEXT NOT NULL,"
"tx_count INTEGER NOT NULL,"
"size INTEGER NOT NULL,"
"time INTEGER NOT NULL,"
"difficulty REAL NOT NULL)") &&
exec("CREATE TABLE IF NOT EXISTS explorer_cache_meta ("
"key TEXT PRIMARY KEY,"
"value TEXT NOT NULL)") &&
exec("INSERT OR IGNORE INTO explorer_cache_meta (key, value) VALUES "
"('schema_version', '1')");
}
void ExplorerBlockCache::migrateLegacyJsonIfNeeded()
{
if (!db_ || getMetaValue("json_migrated") == "1") return;
bool migrated = false;
try {
if (!legacy_json_path_.empty() && fs::exists(legacy_json_path_)) {
std::ifstream file(legacy_json_path_);
json cache;
file >> cache;
if (cache.is_object() && cache.value("version", 0) == kCacheSchemaVersion) {
exec("BEGIN IMMEDIATE TRANSACTION");
if (cache.contains("blocks") && cache["blocks"].is_array()) {
for (const auto& entry : cache["blocks"]) {
ExplorerBlockSummary block;
if (blockSummaryFromJson(entry, block)) {
storeBlock(block);
}
}
}
exec("COMMIT");
int tipHeight = cache.value("tip_height", 0);
std::string tipHash = cache.value("tip_hash", std::string());
if (tipHeight > 0 && !tipHash.empty()) {
updateTip(tipHeight, tipHash);
}
}
}
migrated = true;
} catch (const std::exception& e) {
exec("ROLLBACK");
DEBUG_LOGF("Failed to migrate explorer JSON cache: %s\n", e.what());
migrated = true;
}
if (migrated) setMetaValue("json_migrated", "1");
}
void ExplorerBlockCache::close()
{
if (!db_) return;
sqlite3_close(db_);
db_ = nullptr;
}
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,70 @@
#pragma once
#include <cstdint>
#include <map>
#include <string>
struct sqlite3;
namespace dragonx {
namespace ui {
struct ExplorerBlockSummary {
int height = 0;
std::string hash;
int tx_count = 0;
int size = 0;
std::int64_t time = 0;
double difficulty = 0.0;
};
class ExplorerBlockCache {
public:
struct SavedTipValidation {
bool needed = false;
int height = 0;
std::string expectedHash;
};
ExplorerBlockCache();
ExplorerBlockCache(std::string databasePath, std::string legacyJsonPath);
~ExplorerBlockCache();
ExplorerBlockCache(const ExplorerBlockCache&) = delete;
ExplorerBlockCache& operator=(const ExplorerBlockCache&) = delete;
static std::string defaultDatabasePath();
static std::string defaultLegacyJsonPath();
bool ensureOpen();
bool isOpen() const { return db_ != nullptr; }
const std::string& databasePath() const { return database_path_; }
std::map<int, ExplorerBlockSummary> loadRange(int minHeight, int maxHeight);
bool storeBlock(const ExplorerBlockSummary& block);
int cachedBlockCount();
void clearBlocks();
SavedTipValidation prepareValidation(int currentHeight, const std::string& currentBestHash);
void applySavedTipValidation(const SavedTipValidation& validation,
const std::string& actualHash,
int currentHeight,
const std::string& currentBestHash);
void updateTip(int height, const std::string& hash);
private:
bool exec(const char* sql);
std::string getMetaValue(const std::string& key);
int getMetaInt(const std::string& key, int fallback);
void setMetaValue(const std::string& key, const std::string& value);
bool createSchema();
void migrateLegacyJsonIfNeeded();
void close();
sqlite3* db_ = nullptr;
std::string database_path_;
std::string legacy_json_path_;
};
} // namespace ui
} // namespace dragonx

View File

@@ -576,7 +576,7 @@ inline float mainCardTargetH(float formW, float vs) {
float innerW = formW - pad * 2;
float qrColW = innerW * 0.35f;
float qrPad = spacingMd();
float maxQrSz = std::min(qrColW - qrPad * 2, 280.0f * dp);
float maxQrSz = std::min(std::max(0.0f, qrColW - qrPad * 2), 280.0f * dp);
float qrSize = std::max(100.0f * dp, maxQrSz);
float totalQr = qrSize + qrPad * 2;
float innerGap = spacingLg();

View File

@@ -1484,6 +1484,7 @@ void RenderSettingsPage(App* app) {
if (app->rpc() && app->rpc()->isConnected() && app->worker()) {
app->worker()->post([rpc = app->rpc()]() -> rpc::RPCWorker::MainCb {
try {
rpc::RPCClient::TraceScope trace("Settings / Test connection");
rpc->call("getinfo");
return []() {
Notifications::instance().success("RPC connection OK");

View File

@@ -11,6 +11,7 @@
#include "../../app.h"
#include "../../util/i18n.h"
#include "../material/draw_helpers.h"
#include "../notifications.h"
#include "../theme.h"
#include "imgui.h"
@@ -171,11 +172,6 @@ public:
ImGui::PopStyleColor();
}
ImGui::Spacing();
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
// Buttons
const char* cancelLabel = TR("cancel");
const char* confirmLabel = TR("confirm_transfer");
@@ -192,6 +188,20 @@ public:
buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, sendingLabel).x);
float confirmW = std::max(confirmMinW, confirmTextW + buttonPadW);
float totalW = cancelW + confirmW + Layout::spacingMd();
float footerH = ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.y * 3.0f + 1.0f;
ImGuiViewport* vp = ImGui::GetMainViewport();
float cardBottomY = vp->Pos.y + vp->Size.y * 0.85f;
float footerTopY = cardBottomY - 24.0f * dp - footerH;
float currentY = ImGui::GetCursorScreenPos().y;
if (currentY < footerTopY) {
ImGui::Dummy(ImVec2(0, footerTopY - currentY));
} else {
ImGui::Spacing();
}
ImGui::Separator();
ImGui::Spacing();
float rowStartX = ImGui::GetCursorPosX();
float contentW = ImGui::GetContentRegionAvail().x;
ImGui::SetCursorPosX(rowStartX + std::max(0.0f, (contentW - totalW) * 0.5f));
@@ -209,11 +219,14 @@ public:
[](bool ok, const std::string& result) {
s_sending = false;
s_success = ok;
if (ok)
s_resultMsg = result; // opid
else
s_resultMsg = result; // error message
s_resultMsg = result;
if (ok) {
Notifications::instance().success(TR("transfer_sent_desc"));
} else {
Notifications::instance().error(result.empty() ? TR("transfer_failed") : result);
}
});
s_open = false;
}
ImGui::EndDisabled();

View File

@@ -120,6 +120,7 @@ void BackupWalletDialog::render(App* app)
bool success = false;
std::string statusMsg;
try {
rpc::RPCClient::TraceScope trace("Settings / Backup wallet");
rpc->call("backupwallet", json::array({dest}));
// Check if file was created
if (fs::exists(dest)) {

View File

@@ -42,7 +42,7 @@ std::vector<AddressListRow> BuildAddressListRows(const std::vector<AddressListIn
if (!AddressListMatchesFilter(input, filter)) continue;
if (input.hidden && !showHidden) continue;
if (hideZeroBalances && input.info->balance < 1e-9 && !input.hidden && !input.favorite) continue;
rows.push_back({input.info, input.isZ, input.hidden, input.favorite,
rows.push_back({input.info, input.isZ, input.hidden, input.favorite, input.mining,
input.label, input.icon, input.sortOrder});
}

View File

@@ -13,6 +13,7 @@ struct AddressListInput {
bool isZ = false;
bool hidden = false;
bool favorite = false;
bool mining = false;
std::string label;
std::string icon;
int sortOrder = -1;
@@ -23,6 +24,7 @@ struct AddressListRow {
bool isZ = false;
bool hidden = false;
bool favorite = false;
bool mining = false;
std::string label;
std::string icon;
int sortOrder = -1;

View File

@@ -652,6 +652,7 @@ static void RenderBalanceClassic(App* app)
bool isZ;
bool hidden;
bool favorite;
bool mining;
};
std::vector<AddrRow> rows;
rows.reserve(state.z_addresses.size() + state.t_addresses.size());
@@ -665,7 +666,7 @@ static void RenderBalanceClassic(App* app)
bool isFav = app->isAddressFavorite(a.address);
if (s_hideZeroBalances && a.balance < 1e-9 && !isHidden && !isFav)
continue;
rows.push_back({&a, true, isHidden, isFav});
rows.push_back({&a, true, isHidden, isFav, app->isMiningAddress(a.address)});
}
for (const auto& a : state.t_addresses) {
std::string filter(addr_search);
@@ -677,7 +678,7 @@ static void RenderBalanceClassic(App* app)
bool isFav = app->isAddressFavorite(a.address);
if (s_hideZeroBalances && a.balance < 1e-9 && !isHidden && !isFav)
continue;
rows.push_back({&a, false, isHidden, isFav});
rows.push_back({&a, false, isHidden, isFav, app->isMiningAddress(a.address)});
}
// Sort: favorites first, then Z addresses, then by balance descending
@@ -951,8 +952,9 @@ static void RenderBalanceClassic(App* app)
const char* typeLabel = row.isZ ? "Shielded" : "Transparent";
const char* hiddenTag = row.hidden ? " (hidden)" : "";
const char* viewOnlyTag = (!addr.has_spending_key) ? " (view-only)" : "";
const char* miningTag = row.mining ? TR("mining_tag") : "";
char typeBuf[64];
snprintf(typeBuf, sizeof(typeBuf), "%s%s%s", typeLabel, hiddenTag, viewOnlyTag);
snprintf(typeBuf, sizeof(typeBuf), "%s%s%s%s", typeLabel, hiddenTag, viewOnlyTag, miningTag);
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), typeCol, typeBuf);
// Label (if present, next to type)
@@ -1033,6 +1035,9 @@ static void RenderBalanceClassic(App* app)
app->setCurrentPage(NavPage::Send);
}
ImGui::Separator();
if (ImGui::MenuItem(row.mining ? TR("unmark_mining_address") : TR("mark_mining_address"))) {
app->setMiningAddress(addr.address, !row.mining);
}
if (ImGui::MenuItem(TR("export_private_key"))) {
KeyExportDialog::show(addr.address, KeyExportDialog::KeyType::Private);
}
@@ -1111,18 +1116,19 @@ static void RenderBalanceClassic(App* app)
}
ImGui::Spacing();
const auto& txs = state.transactions;
int maxTx = kRecentTxCount;
int count = (int)txs.size();
if (count > maxTx) count = maxTx;
ImFont* capFont = Type().caption();
float rowH = std::max(18.0f * dp, kRecentTxRowHeight * vs);
float listH = std::max(rowH, ImGui::GetContentRegionAvail().y);
ImGui::BeginChild("##BalanceClassicRecentRows", ImVec2(0, listH), false,
ImGuiWindowFlags_NoBackground);
const auto& txs = state.transactions;
int count = (int)txs.size();
if (count == 0) {
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
"No transactions yet");
} else {
ImDrawList* dl = ImGui::GetWindowDrawList();
ImFont* capFont = Type().caption();
float rowH = std::max(18.0f * dp, kRecentTxRowHeight * vs);
float iconSz = std::max(S.drawElement("tabs.balance", "recent-tx-icon-min-size").size, S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs);
for (int i = 0; i < count; i++) {
@@ -1176,6 +1182,7 @@ static void RenderBalanceClassic(App* app)
ImGui::Dummy(ImVec2(0, rowH));
}
}
ImGui::EndChild();
}
}
@@ -1297,7 +1304,8 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
std::string addrLabel = app->getAddressLabel(a.address);
bool isHidden = app->isAddressHidden(a.address);
bool isFav = app->isAddressFavorite(a.address);
rowInputs.push_back({&a, isZ, isHidden, isFav,
bool isMining = app->isMiningAddress(a.address);
rowInputs.push_back({&a, isZ, isHidden, isFav, isMining,
addrLabel, app->getAddressIcon(a.address),
app->getAddressSortOrder(a.address)});
}
@@ -1646,8 +1654,9 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
{
const char* typeLabel = row.isZ ? TR("shielded") : TR("transparent");
const char* hiddenTag = row.hidden ? TR("hidden_tag") : "";
const char* miningTag = row.mining ? TR("mining_tag") : "";
char typeBuf[64];
snprintf(typeBuf, sizeof(typeBuf), "%s%s", typeLabel, hiddenTag);
snprintf(typeBuf, sizeof(typeBuf), "%s%s%s", typeLabel, hiddenTag, miningTag);
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), typeCol, typeBuf);
// User label next to type
@@ -1761,6 +1770,9 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
if (ImGui::MenuItem(TR("set_label"))) {
AddressLabelDialog::show(app, addr.address, row.isZ);
}
if (ImGui::MenuItem(row.mining ? TR("unmark_mining_address") : TR("mark_mining_address"))) {
app->setMiningAddress(addr.address, !row.mining);
}
if (ImGui::MenuItem(TR("export_private_key")))
KeyExportDialog::show(addr.address, KeyExportDialog::KeyType::Private);
if (row.isZ) {
@@ -1869,10 +1881,13 @@ static void RenderSharedRecentTx(App* app, float recentH, float availW, float hs
ImGui::Spacing();
float scaledRowH = std::max(S.drawElement("tabs.balance", "recent-tx-row-min-height").size, kRecentTxRowHeight * vs);
int maxTx = std::clamp((int)(recentH / scaledRowH), 2, 5);
float availableListH = ImGui::GetContentRegionAvail().y;
float listH = std::max({scaledRowH, recentH, availableListH});
ImGui::BeginChild("##BalanceSharedRecentRows", ImVec2(0, listH), false,
ImGuiWindowFlags_NoBackground);
const auto& txs = state.transactions;
int count = (int)txs.size();
if (count > maxTx) count = maxTx;
if (count == 0) {
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), "No transactions yet");
@@ -1923,6 +1938,7 @@ static void RenderSharedRecentTx(App* app, float recentH, float availW, float hs
ImGui::Dummy(ImVec2(0, rowH));
}
}
ImGui::EndChild();
}
// Render sync progress bar (used by multiple layouts)

View File

@@ -20,6 +20,7 @@ bool consoleLinePassesFilter(const std::string& lineText,
const ConsoleOutputFilter& filter)
{
if (!filter.daemonMessagesEnabled && lineColor == filter.daemonColor) return false;
if (!filter.rpcTraceEnabled && lineColor == filter.rpcTraceColor) return false;
if (filter.errorsOnly && lineColor != filter.errorColor) return false;
if (!filter.text.empty()) {
std::string needle = lowerCopy(filter.text);

View File

@@ -11,8 +11,10 @@ struct ConsoleOutputFilter {
std::string text;
bool daemonMessagesEnabled = true;
bool errorsOnly = false;
bool rpcTraceEnabled = false;
ImU32 daemonColor = 0;
ImU32 errorColor = 0;
ImU32 rpcTraceColor = 0;
};
bool consoleLinePassesFilter(const std::string& lineText,

View File

@@ -27,6 +27,8 @@
#include <sstream>
#include <algorithm>
#include <cmath>
#include <mutex>
#include <ctime>
#include <unordered_set>
namespace dragonx {
@@ -38,10 +40,36 @@ ImU32 ConsoleTab::COLOR_RESULT = IM_COL32(200, 200, 200, 255);
ImU32 ConsoleTab::COLOR_ERROR = IM_COL32(246, 71, 64, 255);
ImU32 ConsoleTab::COLOR_DAEMON = IM_COL32(160, 160, 160, 180);
ImU32 ConsoleTab::COLOR_INFO = IM_COL32(191, 209, 229, 255);
ImU32 ConsoleTab::COLOR_RPC = IM_COL32(120, 180, 255, 210);
bool ConsoleTab::s_scanline_enabled = true;
float ConsoleTab::s_console_zoom = 1.0f;
bool ConsoleTab::s_daemon_messages_enabled = true;
bool ConsoleTab::s_errors_only_enabled = false;
bool ConsoleTab::s_rpc_trace_enabled = false;
namespace {
std::mutex s_rpc_trace_console_mutex;
ConsoleTab* s_rpc_trace_console = nullptr;
std::string rpcTraceTimestamp()
{
std::time_t now = std::time(nullptr);
std::tm localTime{};
static std::mutex timeMutex;
{
std::lock_guard<std::mutex> lock(timeMutex);
if (const std::tm* current = std::localtime(&now)) {
localTime = *current;
}
}
char buffer[16];
std::strftime(buffer, sizeof(buffer), "%H:%M:%S", &localTime);
return buffer;
}
} // namespace
void ConsoleTab::refreshColors()
{
@@ -55,18 +83,21 @@ void ConsoleTab::refreshColors()
auto err = S.drawElement("console", "color-error");
auto dmn = S.drawElement("console", "color-daemon");
auto inf = S.drawElement("console", "color-info");
auto rpc = S.drawElement("console", "color-rpc");
ImU32 defCmd = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
ImU32 defRes = dark ? IM_COL32(200, 200, 200, 255) : IM_COL32(50, 50, 50, 255);
ImU32 defErr = dark ? IM_COL32(246, 71, 64, 255) : IM_COL32(198, 40, 40, 255);
ImU32 defDmn = dark ? IM_COL32(160, 160, 160, 180) : IM_COL32(90, 90, 90, 200);
ImU32 defInf = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
ImU32 defRpc = dark ? IM_COL32(120, 180, 255, 210) : IM_COL32(25, 118, 210, 220);
COLOR_COMMAND = !cmd.color.empty() ? S.resolveColor(cmd.color, defCmd) : defCmd;
COLOR_RESULT = !res.color.empty() ? S.resolveColor(res.color, defRes) : defRes;
COLOR_ERROR = !err.color.empty() ? S.resolveColor(err.color, defErr) : defErr;
COLOR_DAEMON = !dmn.color.empty() ? S.resolveColor(dmn.color, defDmn) : defDmn;
COLOR_INFO = !inf.color.empty() ? S.resolveColor(inf.color, defInf) : defInf;
COLOR_RPC = !rpc.color.empty() ? S.resolveColor(rpc.color, defRpc) : defRpc;
} else {
// No schema — use hardcoded defaults per theme
COLOR_COMMAND = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
@@ -74,11 +105,26 @@ void ConsoleTab::refreshColors()
COLOR_ERROR = dark ? IM_COL32(246, 71, 64, 255) : IM_COL32(198, 40, 40, 255);
COLOR_DAEMON = dark ? IM_COL32(160, 160, 160, 180) : IM_COL32(90, 90, 90, 200);
COLOR_INFO = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
COLOR_RPC = dark ? IM_COL32(120, 180, 255, 210) : IM_COL32(25, 118, 210, 220);
}
}
ConsoleTab::ConsoleTab()
{
{
std::lock_guard<std::mutex> lock(s_rpc_trace_console_mutex);
s_rpc_trace_console = this;
}
rpc::RPCClient::setTraceCallback([](const std::string& source, const std::string& method) {
ConsoleTab* console = nullptr;
{
std::lock_guard<std::mutex> lock(s_rpc_trace_console_mutex);
console = s_rpc_trace_console;
}
if (console) console->addRpcTraceLine(source, method);
});
rpc::RPCClient::setTraceEnabled(s_rpc_trace_enabled);
// Load console colors from ui.toml schema (uses current theme)
refreshColors();
@@ -88,6 +134,14 @@ ConsoleTab::ConsoleTab()
addLine("", COLOR_RESULT);
}
ConsoleTab::~ConsoleTab()
{
rpc::RPCClient::setTraceEnabled(false);
rpc::RPCClient::setTraceCallback(nullptr);
std::lock_guard<std::mutex> lock(s_rpc_trace_console_mutex);
if (s_rpc_trace_console == this) s_rpc_trace_console = nullptr;
}
void ConsoleTab::render(daemon::EmbeddedDaemon* daemon, rpc::RPCClient* rpc, rpc::RPCWorker* worker, daemon::XmrigManager* xmrig)
{
using namespace material;
@@ -100,7 +154,7 @@ void ConsoleTab::render(daemon::EmbeddedDaemon* daemon, rpc::RPCClient* rpc, rpc
// Save old colors to remap existing lines
ImU32 oldCmd = COLOR_COMMAND, oldRes = COLOR_RESULT;
ImU32 oldErr = COLOR_ERROR, oldDmn = COLOR_DAEMON;
ImU32 oldInf = COLOR_INFO;
ImU32 oldInf = COLOR_INFO, oldRpc = COLOR_RPC;
refreshColors();
// Remap stored line colors from old to new
{
@@ -111,6 +165,7 @@ void ConsoleTab::render(daemon::EmbeddedDaemon* daemon, rpc::RPCClient* rpc, rpc
else if (line.color == oldErr) line.color = COLOR_ERROR;
else if (line.color == oldDmn) line.color = COLOR_DAEMON;
else if (line.color == oldInf) line.color = COLOR_INFO;
else if (line.color == oldRpc) line.color = COLOR_RPC;
}
}
s_lastDark = nowDark;
@@ -282,81 +337,46 @@ void ConsoleTab::render(daemon::EmbeddedDaemon* daemon, rpc::RPCClient* rpc, rpc
// CRT scanline effect over output area — aligned to text lines
if (s_scanline_enabled) {
float panelH = outPanelMax.y - outPanelMin.y;
// --- Text-aligned horizontal scanlines ---
// Stride matches the actual text line height so each band sits between lines.
float textLineH = output_line_height_;
if (textLineH <= 1.0f) textLineH = Type().caption()->LegacySize * s_console_zoom + 2.0f; // fallback
float bandH = schema::UI().drawElement("tabs.console", "scanline-gap").sizeOr(2.0f);
int lineAlpha = (int)schema::UI().drawElement("tabs.console", "scanline-line-alpha").sizeOr(18.0f);
if (textLineH <= 1.0f) textLineH = Type().caption()->LegacySize * s_console_zoom + 2.0f;
// Glow fringe parameters (soft gradient above/below each band)
float glowSpread = schema::UI().drawElement("tabs.console", "scanline-glow-spread").sizeOr(0.0f);
float glowIntensity = schema::UI().drawElement("tabs.console", "scanline-glow-intensity").sizeOr(0.6f);
int glowRGB = (int)schema::UI().drawElement("tabs.console", "scanline-glow-color").sizeOr(255.0f);
bool drawGlow = glowSpread > 0.0f && glowIntensity > 0.0f && lineAlpha > 0;
int glowAlpha = drawGlow ? std::min(255, (int)(lineAlpha * glowIntensity)) : 0;
ImU32 glowPeak = IM_COL32(glowRGB, glowRGB, glowRGB, glowAlpha);
ImU32 glowClear = IM_COL32(glowRGB, glowRGB, glowRGB, 0);
if (textLineH >= 1.0f && lineAlpha > 0) {
ImU32 lineCol = IM_COL32(255, 255, 255, lineAlpha);
float stride = textLineH; // one text line per scanline period
// Align with text: account for inner padding and scroll position
float padY = Layout::spacingSm();
float scrollFrac = std::fmod(consoleScrollY, stride);
float startY = outPanelMin.y + padY - scrollFrac;
// Ensure first band starts above the visible area
while (startY > outPanelMin.y) startY -= stride;
for (float y = startY; y < outPanelMax.y; y += stride) {
// Place the dark band at the bottom edge of each text line period
float bandTop = y + stride - bandH;
float bandBot = y + stride;
float yTop = std::max(bandTop, outPanelMin.y);
float yBot = std::min(bandBot, outPanelMax.y);
int lightAlpha = std::clamp((int)schema::UI().drawElement("tabs.console", "scanline-line-alpha").sizeOr(10.0f), 0, 255);
int darkAlpha = std::clamp(lightAlpha + 10, 0, 255);
if (textLineH >= 1.0f && (lightAlpha > 0 || darkAlpha > 0)) {
ImU32 lightCol = IM_COL32(255, 255, 255, lightAlpha);
ImU32 darkCol = IM_COL32(0, 0, 0, darkAlpha);
for (const auto& row : scanline_rows_) {
float yTop = std::max(row.yTop, outPanelMin.y);
float yBot = std::min(row.yBot, outPanelMax.y);
if (yTop < yBot) {
// Glow fringes (gradient tapers away from band)
if (drawGlow) {
// Above fringe: transparent at top, glowPeak at bottom
float gTop = std::max(yTop - glowSpread, outPanelMin.y);
if (gTop < yTop) {
dlOut->AddRectFilledMultiColor(
ImVec2(outPanelMin.x, gTop), ImVec2(outPanelMax.x, yTop),
glowClear, glowClear, glowPeak, glowPeak);
}
// Below fringe: glowPeak at top, transparent at bottom
float gBot = std::min(yBot + glowSpread, outPanelMax.y);
if (yBot < gBot) {
dlOut->AddRectFilledMultiColor(
ImVec2(outPanelMin.x, yBot), ImVec2(outPanelMax.x, gBot),
glowPeak, glowPeak, glowClear, glowClear);
}
}
// Opaque scanline band (drawn on top of glow)
dlOut->AddRectFilled(ImVec2(outPanelMin.x, yTop), ImVec2(outPanelMax.x, yBot), lineCol);
dlOut->AddRectFilled(ImVec2(outPanelMin.x, yTop), ImVec2(outPanelMax.x, yBot),
(row.rowIndex % 2 == 0) ? lightCol : darkCol);
}
}
}
// --- Animated sweep band (brighter moving highlight) ---
float panelH = outPanelMax.y - outPanelMin.y;
float scanSpeed = schema::UI().drawElement("tabs.console", "scanline-speed").sizeOr(40.0f);
float scanH = schema::UI().drawElement("tabs.console", "scanline-height").sizeOr(30.0f);
int scanAlpha = (int)schema::UI().drawElement("tabs.console", "scanline-alpha").sizeOr(12.0f);
float t = (float)std::fmod(ImGui::GetTime() * scanSpeed, (double)(panelH + scanH));
float scanY = outPanelMin.y + t - scanH;
float yTop = std::max(scanY, outPanelMin.y);
float yBot = std::min(scanY + scanH, outPanelMax.y);
if (yTop < yBot) {
float mid = (yTop + yBot) * 0.5f;
ImU32 clear = IM_COL32(255, 255, 255, 0);
ImU32 peak = IM_COL32(255, 255, 255, scanAlpha);
dlOut->AddRectFilledMultiColor(
ImVec2(outPanelMin.x, yTop), ImVec2(outPanelMax.x, mid),
clear, clear, peak, peak);
dlOut->AddRectFilledMultiColor(
ImVec2(outPanelMin.x, mid), ImVec2(outPanelMax.x, yBot),
peak, peak, clear, clear);
float rawScanH = schema::UI().drawElement("tabs.console", "scanline-height").sizeOr(textLineH * 2.0f);
int scanAlpha = std::clamp((int)schema::UI().drawElement("tabs.console", "scanline-alpha").sizeOr(8.0f), 0, 255);
if (panelH > 1.0f && textLineH >= 1.0f && scanSpeed > 0.0f && scanAlpha > 0) {
float scanLines = std::max(1.0f, std::round(rawScanH / textLineH));
float scanH = scanLines * textLineH;
float t = (float)std::fmod(ImGui::GetTime() * scanSpeed, (double)(panelH + scanH));
float scanY = outPanelMin.y + t - scanH;
float yTop = std::max(scanY, outPanelMin.y);
float yBot = std::min(scanY + scanH, outPanelMax.y);
if (yTop < yBot) {
float mid = (yTop + yBot) * 0.5f;
ImU32 clear = IM_COL32(255, 255, 255, 0);
ImU32 peak = IM_COL32(255, 255, 255, scanAlpha);
dlOut->AddRectFilledMultiColor(
ImVec2(outPanelMin.x, yTop), ImVec2(outPanelMax.x, mid),
clear, clear, peak, peak);
dlOut->AddRectFilledMultiColor(
ImVec2(outPanelMin.x, mid), ImVec2(outPanelMax.x, yBot),
peak, peak, clear, clear);
}
}
}
@@ -492,6 +512,25 @@ void ConsoleTab::renderToolbar(daemon::EmbeddedDaemon* daemon)
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", TR("console_show_errors_only"));
}
ImGui::SameLine();
ImGui::Spacing();
ImGui::SameLine();
// App RPC trace toggle — captures method/source only, never results or params
{
static bool s_prev_rpc_trace_enabled = false;
if (ImGui::Checkbox(TR("console_rpc_trace"), &s_rpc_trace_enabled)) {
rpc::RPCClient::setTraceEnabled(s_rpc_trace_enabled);
}
if (s_prev_rpc_trace_enabled != s_rpc_trace_enabled && auto_scroll_) {
scroll_to_bottom_ = true;
}
s_prev_rpc_trace_enabled = s_rpc_trace_enabled;
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", TR("console_show_rpc_trace"));
}
ImGui::SameLine();
ImGui::Spacing();
@@ -607,12 +646,15 @@ void ConsoleTab::renderOutput()
output_line_height_ = line_height; // store for scanline alignment
output_origin_ = ImGui::GetCursorScreenPos();
output_scroll_y_ = ImGui::GetScrollY();
scanline_rows_.clear();
// Build filtered line index list BEFORE mouse handling (so screenToTextPos works)
ConsoleOutputFilter outputFilter{filter_text_, s_daemon_messages_enabled,
s_errors_only_enabled, COLOR_DAEMON, COLOR_ERROR};
s_errors_only_enabled, s_rpc_trace_enabled,
COLOR_DAEMON, COLOR_ERROR, COLOR_RPC};
bool has_text_filter = !outputFilter.text.empty();
bool has_filter = has_text_filter || !outputFilter.daemonMessagesEnabled || outputFilter.errorsOnly;
bool has_filter = has_text_filter || !outputFilter.daemonMessagesEnabled ||
!outputFilter.rpcTraceEnabled || outputFilter.errorsOnly;
visible_indices_.clear();
if (has_filter) {
for (int i = 0; i < static_cast<int>(lines_.size()); i++) {
@@ -826,6 +868,11 @@ void ConsoleTab::renderOutput()
float rowY = lineOrigin.y + seg.yOffset;
const char* segStart = line.text.c_str() + seg.byteStart;
const char* segEnd = line.text.c_str() + seg.byteEnd;
if (s_scanline_enabled && line_height > 0.0f) {
int rowIndex = static_cast<int>(std::floor((cumulative_y_offsets_[vi] + seg.yOffset) / line_height + 0.5f));
scanline_rows_.push_back({rowY, rowY + seg.height, rowIndex});
}
// Selection highlight for this sub-row
if (lineSelected && selByteStart < seg.byteEnd && selByteEnd > seg.byteStart) {
@@ -1469,6 +1516,7 @@ void ConsoleTab::executeCommand(const std::string& cmd, rpc::RPCClient* rpc, rpc
std::string result_str;
bool is_error = false;
try {
rpc::RPCClient::TraceScope trace("Console tab / User command");
result_str = rpc->callRaw(method, params);
} catch (const std::exception& e) {
result_str = e.what();
@@ -1499,6 +1547,7 @@ void ConsoleTab::executeCommand(const std::string& cmd, rpc::RPCClient* rpc, rpc
} else {
// Fallback: synchronous execution if no worker available
try {
rpc::RPCClient::TraceScope trace("Console tab / User command");
std::string result_str = rpc->callRaw(method, params);
for (const auto& resultLine : FormatConsoleRpcResultLines(result_str, false)) {
addLine(resultLine.text, COLOR_RESULT);
@@ -1545,6 +1594,11 @@ void ConsoleTab::addLine(const std::string& line, ImU32 color)
scroll_to_bottom_ = auto_scroll_;
}
void ConsoleTab::addRpcTraceLine(const std::string& source, const std::string& method)
{
addLine("[rpc] [" + rpcTraceTimestamp() + "] [" + source + "] " + method, COLOR_RPC);
}
void ConsoleTab::addCommandResult(const std::string& cmd, const std::string& result, bool is_error)
{
addLine("> " + cmd, COLOR_COMMAND);

View File

@@ -26,7 +26,7 @@ namespace ui {
class ConsoleTab {
public:
ConsoleTab();
~ConsoleTab() = default;
~ConsoleTab();
/**
* @brief Render the console tab
@@ -46,6 +46,7 @@ public:
* @brief Add a line to the console output
*/
void addLine(const std::string& line, ImU32 color = IM_COL32(200, 200, 200, 255));
void addRpcTraceLine(const std::string& source, const std::string& method);
/**
* @brief Clear console output
@@ -69,6 +70,9 @@ public:
// Show only error messages (filter toggle)
static bool s_errors_only_enabled;
// Show app RPC calls made through RPCClient (method/source only)
static bool s_rpc_trace_enabled;
/// Refresh console text colors for current theme (call after theme switch)
static void refreshColors();
@@ -78,6 +82,7 @@ public:
static ImU32 COLOR_ERROR;
static ImU32 COLOR_DAEMON;
static ImU32 COLOR_INFO;
static ImU32 COLOR_RPC;
private:
struct ConsoleLine {
@@ -129,6 +134,13 @@ private:
float output_scroll_y_ = 0.0f; // Track scroll position for selection
ImVec2 output_origin_ = {0, 0}; // Top-left of output area
float output_line_height_ = 0.0f; // Text line height (for scanline alignment)
struct ScanlineRow {
float yTop = 0.0f;
float yBot = 0.0f;
int rowIndex = 0;
};
std::vector<ScanlineRow> scanline_rows_;
std::mutex lines_mutex_;

View File

@@ -8,6 +8,7 @@
#include "../../rpc/rpc_worker.h"
#include "../../data/wallet_state.h"
#include "../../util/i18n.h"
#include "../explorer/explorer_block_cache.h"
#include "../schema/ui_schema.h"
#include "../material/type.h"
#include "../material/draw_helpers.h"
@@ -25,6 +26,8 @@
#include <algorithm>
#include <cctype>
#include <atomic>
#include <map>
#include <set>
namespace dragonx {
namespace ui {
@@ -39,18 +42,13 @@ static char s_search_buf[128] = {};
static bool s_search_loading = false;
static std::string s_search_error;
// Recent blocks cache
struct BlockSummary {
int height = 0;
std::string hash;
int tx_count = 0;
int size = 0;
int64_t time = 0;
double difficulty = 0.0;
};
static std::vector<BlockSummary> s_recent_blocks;
static int s_last_known_height = 0;
using BlockSummary = ExplorerBlockSummary;
static ExplorerBlockCache s_recent_block_cache;
static std::set<int> s_pending_block_heights;
static int s_recent_page = 0;
static int s_recent_max_page = 0;
static std::atomic<int> s_pending_block_fetches{0};
static bool s_recent_disk_cache_validation_pending = false;
// Block detail modal
static bool s_show_detail_modal = false;
@@ -116,6 +114,34 @@ static std::string truncateHashToFit(const std::string& hash, ImFont* font, floa
return truncateHash(hash, front, back);
}
static void validateRecentBlockCache(App* app, const WalletState& state) {
if (s_recent_disk_cache_validation_pending) return;
auto validation = s_recent_block_cache.prepareValidation(state.sync.blocks, state.sync.best_blockhash);
if (!validation.needed) return;
auto* worker = app ? app->worker() : nullptr;
auto* rpc = app ? app->rpc() : nullptr;
if (!worker || !rpc || !rpc->isConnected()) return;
int currentHeight = state.sync.blocks;
std::string currentHash = state.sync.best_blockhash;
s_recent_disk_cache_validation_pending = true;
worker->post([rpc, validation, currentHeight, currentHash = std::move(currentHash)]() mutable -> rpc::RPCWorker::MainCb {
std::string actualHash;
try {
rpc::RPCClient::TraceScope trace("Explorer tab / Cache validation");
actualHash = rpc->call("getblockhash", {validation.height}).get<std::string>();
} catch (...) {}
return [validation, currentHeight, currentHash = std::move(currentHash),
actualHash = std::move(actualHash)]() mutable {
s_recent_disk_cache_validation_pending = false;
s_recent_block_cache.applySavedTipValidation(validation, actualHash, currentHeight, currentHash);
};
});
}
static std::string formatSize(int bytes) {
char b[32];
if (bytes >= 1048576)
@@ -127,6 +153,21 @@ static std::string formatSize(int bytes) {
return b;
}
static std::string formatHashrate(double hashrate) {
char b[32];
if (hashrate >= 1e12)
snprintf(b, sizeof(b), "%.2f TH/s", hashrate / 1e12);
else if (hashrate >= 1e9)
snprintf(b, sizeof(b), "%.2f GH/s", hashrate / 1e9);
else if (hashrate >= 1e6)
snprintf(b, sizeof(b), "%.2f MH/s", hashrate / 1e6);
else if (hashrate >= 1e3)
snprintf(b, sizeof(b), "%.2f KH/s", hashrate / 1e3);
else
snprintf(b, sizeof(b), "%.0f H/s", hashrate);
return b;
}
// Copy icon button — draws a small icon that copies text to clipboard on click
static void copyButton(const char* id, const std::string& text, float x, float y) {
ImFont* iconFont = Type().iconSmall();
@@ -211,6 +252,7 @@ static void fetchBlockDetail(App* app, int height) {
json result;
std::string error;
try {
rpc::RPCClient::TraceScope trace("Explorer tab / Block detail");
auto hashResult = rpc->call("getblockhash", {height});
std::string hash = hashResult.get<std::string>();
result = rpc->call("getblock", {hash});
@@ -236,12 +278,14 @@ static void fetchBlockDetailByHash(App* app, const std::string& hash) {
bool gotTx = false;
// Try as block hash first
try {
rpc::RPCClient::TraceScope trace("Explorer tab / Search");
blockResult = rpc->call("getblock", {hash});
gotBlock = true;
} catch (...) {}
// If not a block hash, try as txid
if (!gotBlock) {
try {
rpc::RPCClient::TraceScope trace("Explorer tab / Search");
txResult = rpc->call("getrawtransaction", {hash, 1});
gotTx = true;
} catch (...) {}
@@ -267,43 +311,39 @@ static void fetchBlockDetailByHash(App* app, const std::string& hash) {
});
}
static bool fetchRecentBlocks(App* app, int currentHeight, int count = 10) {
static bool fetchRecentBlock(App* app, int height) {
auto* worker = app->worker();
auto* rpc = app->rpc();
if (!worker || !rpc || s_pending_block_fetches > 0) return false;
if (height < 1 || !worker || !rpc) return false;
if (s_pending_block_heights.find(height) != s_pending_block_heights.end()) return false;
if (s_recent_blocks.empty()) s_recent_blocks.resize(count);
s_pending_block_fetches = 1; // single batched fetch
s_pending_block_heights.insert(height);
s_pending_block_fetches.fetch_add(1);
worker->post([rpc, currentHeight, count]() -> rpc::RPCWorker::MainCb {
std::vector<BlockSummary> results(count);
for (int i = 0; i < count; i++) {
int h = currentHeight - i;
if (h < 1) continue;
try {
auto hashResult = rpc->call("getblockhash", {h});
auto hash = hashResult.get<std::string>();
auto result = rpc->call("getblock", {hash});
auto& bs = results[i];
bs.height = result.value("height", 0);
bs.hash = result.value("hash", "");
bs.time = result.value("time", (int64_t)0);
bs.size = result.value("size", 0);
bs.difficulty = result.value("difficulty", 0.0);
if (result.contains("tx") && result["tx"].is_array())
bs.tx_count = static_cast<int>(result["tx"].size());
} catch (...) {}
}
return [results = std::move(results)]() mutable {
bool gotAny = false;
for (const auto& block : results) {
if (block.height > 0) {
gotAny = true;
break;
}
worker->post([rpc, height]() -> rpc::RPCWorker::MainCb {
BlockSummary block;
try {
rpc::RPCClient::TraceScope trace("Explorer tab / Recent blocks");
auto hashResult = rpc->call("getblockhash", {height});
auto hash = hashResult.get<std::string>();
auto result = rpc->call("getblock", {hash});
block.height = result.value("height", 0);
block.hash = result.value("hash", "");
block.time = result.value("time", (int64_t)0);
block.size = result.value("size", 0);
block.difficulty = result.value("difficulty", 0.0);
if (result.contains("tx") && result["tx"].is_array())
block.tx_count = static_cast<int>(result["tx"].size());
} catch (...) {}
return [height, block = std::move(block)]() mutable {
if (block.height > 0) {
s_recent_block_cache.storeBlock(block);
}
s_pending_block_heights.erase(height);
if (s_pending_block_fetches.load() > 0) {
s_pending_block_fetches.fetch_sub(1);
}
if (gotAny) s_recent_blocks = std::move(results);
s_pending_block_fetches = 0;
};
});
return true;
@@ -318,6 +358,7 @@ static void fetchMempoolInfo(App* app) {
int txCount = 0;
int64_t bytes = 0;
try {
rpc::RPCClient::TraceScope trace("Explorer tab / Mempool summary");
auto result = rpc->call("getmempoolinfo", json::array());
txCount = result.value("size", 0);
bytes = result.value("bytes", (int64_t)0);
@@ -338,7 +379,10 @@ static void fetchTxDetail(App* app, const std::string& txid) {
s_tx_detail = json();
worker->post([rpc, txid]() -> rpc::RPCWorker::MainCb {
json result;
try { result = rpc->call("getrawtransaction", {txid, 1}); }
try {
rpc::RPCClient::TraceScope trace("Explorer tab / Transaction detail");
result = rpc->call("getrawtransaction", {txid, 1});
}
catch (...) {}
return [result]() {
s_tx_loading = false;
@@ -377,6 +421,16 @@ static void renderSearchBar(App* app, float availWidth) {
auto& S = schema::UI();
float pad = Layout::cardInnerPadding();
ImFont* capFont = Type().caption();
float navBtnSz = std::max(24.0f * Layout::dpiScale(), capFont->LegacySize + 8.0f * Layout::dpiScale());
if (s_recent_page > s_recent_max_page) s_recent_page = s_recent_max_page;
if (s_recent_page < 0) s_recent_page = 0;
char pageBuf[32];
snprintf(pageBuf, sizeof(pageBuf), "%d / %d", s_recent_page + 1, s_recent_max_page + 1);
float pageW = capFont->CalcTextSizeA(capFont->LegacySize, 1000.0f, 0.0f, pageBuf).x;
float navGap = Layout::spacingXs();
float navW = navBtnSz * 2.0f + pageW + navGap * 2.0f;
float inputW = std::min(
S.drawElement("tabs.explorer", "search-input-width").size,
availWidth * 0.65f);
@@ -384,11 +438,12 @@ static void renderSearchBar(App* app, float availWidth) {
float barH = S.drawElement("tabs.explorer", "search-bar-height").size;
// Clamp so search bar never overflows
float maxInputW = availWidth - btnW - pad * 3 - Type().iconMed()->LegacySize;
float maxInputW = availWidth - btnW - navW - pad * 4 - Type().iconMed()->LegacySize;
if (inputW > maxInputW) inputW = maxInputW;
if (inputW < 80.0f) inputW = 80.0f;
ImGui::Spacing();
float rowStartX = ImGui::GetCursorScreenPos().x;
// Icon
ImGui::PushFont(Type().iconMed());
@@ -429,6 +484,32 @@ static void renderSearchBar(App* app, float availWidth) {
ImGui::TextDisabled("%s", TR("loading"));
}
float navX = rowStartX + availWidth - navW;
float navY = cursorY + std::max(0.0f, (barH - navBtnSz) * 0.5f);
ImGui::SetCursorScreenPos(ImVec2(navX, navY));
ImGui::BeginDisabled(s_recent_page <= 0);
ImGui::PushID("RecentBlocksSearchPrev");
if (TactileButton(ICON_MD_CHEVRON_LEFT, ImVec2(navBtnSz, navBtnSz), Type().iconSmall())) {
--s_recent_page;
}
ImGui::PopID();
ImGui::EndDisabled();
ImGui::SameLine(0, navGap);
ImVec2 pagePos = ImGui::GetCursorScreenPos();
ImGui::GetWindowDrawList()->AddText(capFont, capFont->LegacySize,
ImVec2(pagePos.x, pagePos.y + (navBtnSz - capFont->LegacySize) * 0.5f), OnSurfaceMedium(), pageBuf);
ImGui::Dummy(ImVec2(pageW, navBtnSz));
ImGui::SameLine(0, navGap);
ImGui::BeginDisabled(s_recent_page >= s_recent_max_page);
ImGui::PushID("RecentBlocksSearchNext");
if (TactileButton(ICON_MD_CHEVRON_RIGHT, ImVec2(navBtnSz, navBtnSz), Type().iconSmall())) {
++s_recent_page;
}
ImGui::PopID();
ImGui::EndDisabled();
ImGui::SetCursorScreenPos(ImVec2(rowStartX, cursorY + barH));
// Error
if (!s_search_error.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.4f, 0.4f, 1.0f));
@@ -443,6 +524,7 @@ static void renderChainStats(App* app, float availWidth) {
const auto& state = app->getWalletState();
float pad = Layout::cardInnerPadding();
float gap = Layout::cardGap();
float dp = Layout::dpiScale();
ImDrawList* dl = ImGui::GetWindowDrawList();
GlassPanelSpec glassSpec;
@@ -451,109 +533,185 @@ static void renderChainStats(App* app, float availWidth) {
ImFont* ovFont = Type().overline();
ImFont* capFont = Type().caption();
ImFont* sub1 = Type().subtitle1();
ImFont* heroFont = Type().h2();
ImFont* iconFont = Type().iconSmall();
float cardW = (availWidth - gap) * 0.5f;
float rowH = capFont->LegacySize + Layout::spacingXs() + sub1->LegacySize;
float headerH = ovFont->LegacySize + Layout::spacingSm();
float cardH = pad * 0.5f + headerH + rowH * 3 + Layout::spacingSm() * 2 + pad * 0.5f;
float heroLineH = capFont->LegacySize + Layout::spacingXs() + heroFont->LegacySize;
float hashLineH = capFont->LegacySize + Layout::spacingXs() + sub1->LegacySize;
float cardH = std::max(148.0f * dp,
pad * 1.5f + headerH + heroLineH + hashLineH + Layout::spacingLg() * 2.0f);
bool stacked = availWidth < 760.0f * dp;
float chainW = stacked ? availWidth : (availWidth - gap) * 0.60f;
float metricsW = stacked ? availWidth : availWidth - chainW - gap;
ImVec2 basePos = ImGui::GetCursorScreenPos();
char buf[128];
// ── Chain Card (left) ──
{
ImVec2 cardMin = basePos;
auto drawStatusPill = [&](const ImVec2& cardMin, float cardW) {
bool connected = app->rpc() && app->rpc()->isConnected();
ImU32 pillCol = connected ? Success() : Error();
std::string statusText;
if (!connected) {
statusText = TR("not_connected");
} else if (state.sync.syncing && state.sync.headers > 0) {
snprintf(buf, sizeof(buf), "%s %.1f%%", TR("syncing"), state.sync.verification_progress * 100.0);
statusText = buf;
pillCol = Warning();
} else {
statusText = TR("connected");
}
float maxTextW = cardW * 0.34f;
statusText = truncateHashToFit(statusText, capFont, maxTextW);
ImVec2 textSz = capFont->CalcTextSizeA(capFont->LegacySize, 1000.0f, 0.0f, statusText.c_str());
float pillPadX = Layout::spacingSm();
float pillH = capFont->LegacySize + Layout::spacingXs() * 2.0f;
float pillW = textSz.x + pillPadX * 2.0f;
ImVec2 pillMin(cardMin.x + cardW - pad - pillW, cardMin.y + pad * 0.5f);
ImVec2 pillMax(pillMin.x + pillW, pillMin.y + pillH);
dl->AddRectFilled(pillMin, pillMax, WithAlpha(pillCol, 32), pillH * 0.5f);
dl->AddRect(pillMin, pillMax, WithAlpha(pillCol, 110), pillH * 0.5f, 0, 1.0f * dp);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(pillMin.x + pillPadX, pillMin.y + Layout::spacingXs()), pillCol, statusText.c_str());
};
auto drawChainTip = [&](const ImVec2& cardMin, float cardW) {
ImVec2 cardMax(cardMin.x + cardW, cardMin.y + cardH);
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
dl->AddText(ovFont, ovFont->LegacySize,
ImVec2(cardMin.x + pad, cardMin.y + pad * 0.5f), Primary(), TR("explorer_chain_stats"));
drawStatusPill(cardMin, cardW);
float colW = (cardW - pad * 2) / 2.0f;
float ry = cardMin.y + pad * 0.5f + headerH;
float labelY = cardMin.y + pad * 0.5f + headerH + Layout::spacingLg();
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cardMin.x + pad, labelY), OnSurfaceMedium(), TR("explorer_block_height"));
// Row 1: Height | Difficulty
{
float cx = cardMin.x + pad;
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("explorer_block_height"));
snprintf(buf, sizeof(buf), "%d", state.sync.blocks);
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), buf);
snprintf(buf, sizeof(buf), "%d", state.sync.blocks);
float valueY = labelY + capFont->LegacySize + Layout::spacingXs();
DrawTextShadow(dl, heroFont, heroFont->LegacySize,
ImVec2(cardMin.x + pad, valueY), OnSurface(), buf);
cx = cardMin.x + pad + colW;
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("difficulty"));
snprintf(buf, sizeof(buf), "%.4f", state.mining.difficulty);
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), buf);
if (state.sync.syncing && state.sync.headers > 0) {
float progress = std::clamp(static_cast<float>(state.sync.verification_progress), 0.0f, 1.0f);
float barY = valueY + heroFont->LegacySize + Layout::spacingMd();
float barH = std::max(3.0f * dp, 3.0f);
float barW = cardW - pad * 2.0f;
dl->AddRectFilled(ImVec2(cardMin.x + pad, barY),
ImVec2(cardMin.x + pad + barW, barY + barH), WithAlpha(OnSurface(), 18), barH * 0.5f);
dl->AddRectFilled(ImVec2(cardMin.x + pad, barY),
ImVec2(cardMin.x + pad + barW * progress, barY + barH), WithAlpha(Warning(), 180), barH * 0.5f);
}
ry += rowH + Layout::spacingSm();
// Row 2: Hashrate | Notarized
{
float cx = cardMin.x + pad;
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("peers_hashrate"));
double hr = state.mining.networkHashrate;
if (hr >= 1e9)
snprintf(buf, sizeof(buf), "%.2f GH/s", hr / 1e9);
else if (hr >= 1e6)
snprintf(buf, sizeof(buf), "%.2f MH/s", hr / 1e6);
else if (hr >= 1e3)
snprintf(buf, sizeof(buf), "%.2f KH/s", hr / 1e3);
else
snprintf(buf, sizeof(buf), "%.0f H/s", hr);
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), buf);
float hashLabelY = cardMax.y - pad - hashLineH;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cardMin.x + pad, hashLabelY), OnSurfaceMedium(), TR("peers_best_block"));
cx = cardMin.x + pad + colW;
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("peers_notarized"));
snprintf(buf, sizeof(buf), "%d", state.notarized);
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), buf);
float hashY = hashLabelY + capFont->LegacySize + Layout::spacingXs();
float copyW = iconFont->LegacySize + Layout::spacingSm() * 2.0f;
float hashMaxW = cardW - pad * 2.0f - (state.sync.best_blockhash.empty() ? 0.0f : copyW + Layout::spacingSm());
std::string hashDisp = state.sync.best_blockhash.empty()
? std::string("--")
: truncateHashToFit(state.sync.best_blockhash, sub1, hashMaxW);
dl->AddText(sub1, sub1->LegacySize,
ImVec2(cardMin.x + pad, hashY), OnSurface(), hashDisp.c_str());
if (!state.sync.best_blockhash.empty()) {
ImVec2 savedCursor = ImGui::GetCursorScreenPos();
copyButton("ChainTipBestHash", state.sync.best_blockhash,
cardMax.x - pad - copyW, hashY);
ImGui::SetCursorScreenPos(savedCursor);
}
ry += rowH + Layout::spacingSm();
};
// Row 3: Best Block Hash — adaptive truncation to fit card
{
float cx = cardMin.x + pad;
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("peers_best_block"));
float maxHashW = cardW - pad * 2;
std::string hashDisp = truncateHashToFit(state.sync.best_blockhash, sub1, maxHashW);
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), hashDisp.c_str());
}
}
// ── Mempool Card (right) ──
{
ImVec2 cardMin(basePos.x + cardW + gap, basePos.y);
auto drawMetricGrid = [&](const ImVec2& cardMin, float cardW) {
ImVec2 cardMax(cardMin.x + cardW, cardMin.y + cardH);
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
dl->AddText(ovFont, ovFont->LegacySize,
ImVec2(cardMin.x + pad, cardMin.y + pad * 0.5f), Primary(), TR("explorer_mempool"));
struct MetricSpec {
const char* icon;
const char* label;
std::string value;
std::string detail;
ImU32 accent;
};
float ry = cardMin.y + pad * 0.5f + headerH;
snprintf(buf, sizeof(buf), "%.4f", state.mining.difficulty);
std::string difficulty = buf;
snprintf(buf, sizeof(buf), "%d", state.notarized);
std::string notarized = buf;
snprintf(buf, sizeof(buf), "%d", s_mempool_tx_count);
std::string mempoolTxs = s_mempool_loading ? std::string("...") : std::string(buf);
// Row 1: Transactions
{
float cx = cardMin.x + pad;
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("explorer_mempool_txs"));
snprintf(buf, sizeof(buf), "%d", s_mempool_tx_count);
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), buf);
MetricSpec metrics[4] = {
{ ICON_MD_SPEED, TR("difficulty"), difficulty, "", Warning() },
{ ICON_MD_SHOW_CHART, TR("peers_hashrate"), formatHashrate(state.mining.networkHashrate), "", Success() },
{ ICON_MD_CONFIRMATION_NUMBER, TR("peers_notarized"), notarized, "", Primary() },
{ ICON_MD_STORAGE, TR("explorer_mempool"), mempoolTxs, s_mempool_loading ? std::string("") : formatSize(static_cast<int>(s_mempool_size)), Secondary() },
};
float gridX = cardMin.x + pad;
float gridY = cardMin.y + pad;
float gridW = cardW - pad * 2.0f;
float gridH = cardH - pad * 2.0f;
float cellW = gridW * 0.5f;
float cellH = gridH * 0.5f;
ImU32 dividerCol = WithAlpha(OnSurface(), 22);
dl->AddLine(ImVec2(gridX + cellW, gridY), ImVec2(gridX + cellW, gridY + gridH), dividerCol, 1.0f * dp);
dl->AddLine(ImVec2(gridX, gridY + cellH), ImVec2(gridX + gridW, gridY + cellH), dividerCol, 1.0f * dp);
for (int i = 0; i < 4; ++i) {
int col = i % 2;
int row = i / 2;
ImVec2 cellMin(gridX + col * cellW, gridY + row * cellH);
ImVec2 cellMax(cellMin.x + cellW, cellMin.y + cellH);
float inset = Layout::spacingSm();
float textX = cellMin.x + inset;
float textY = cellMin.y + inset;
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(cellMax.x - inset - iconFont->LegacySize, textY),
WithAlpha(metrics[i].accent, 180), metrics[i].icon);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(textX, textY), OnSurfaceMedium(), metrics[i].label);
float valueY = textY + capFont->LegacySize + Layout::spacingXs();
float valueMaxW = cellW - inset * 2.0f;
std::string value = truncateHashToFit(metrics[i].value, sub1, valueMaxW);
dl->AddText(sub1, sub1->LegacySize,
ImVec2(textX, valueY), OnSurface(), value.c_str());
if (!metrics[i].detail.empty()) {
std::string detail = truncateHashToFit(metrics[i].detail, capFont, valueMaxW);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(textX, valueY + sub1->LegacySize + Layout::spacingXs()),
OnSurfaceDisabled(), detail.c_str());
}
}
ry += rowH + Layout::spacingSm();
};
// Row 2: Size
{
float cx = cardMin.x + pad;
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("explorer_mempool_size"));
std::string sizeStr = formatSize(static_cast<int>(s_mempool_size));
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), sizeStr.c_str());
}
drawChainTip(basePos, chainW);
if (stacked) {
drawMetricGrid(ImVec2(basePos.x, basePos.y + cardH + gap), metricsW);
ImGui::Dummy(ImVec2(availWidth, cardH * 2.0f + gap + Layout::spacingMd()));
} else {
drawMetricGrid(ImVec2(basePos.x + chainW + gap, basePos.y), metricsW);
ImGui::Dummy(ImVec2(availWidth, cardH + Layout::spacingMd()));
}
ImGui::Dummy(ImVec2(availWidth, cardH + Layout::spacingMd()));
}
static void renderRecentBlocks(App* app, float availWidth) {
auto& S = schema::UI();
float pad = Layout::cardInnerPadding();
float dp = Layout::dpiScale();
const auto& state = app->getWalletState();
validateRecentBlockCache(app, state);
ImDrawList* dl = ImGui::GetWindowDrawList();
GlassPanelSpec glassSpec;
@@ -564,19 +722,13 @@ static void renderRecentBlocks(App* app, float availWidth) {
ImFont* body2 = Type().body2();
ImFont* sub1 = Type().subtitle1();
float rowH = S.drawElement("tabs.explorer", "row-height").size;
float baseRowH = S.drawElement("tabs.explorer", "row-height").size;
float rowRound = S.drawElement("tabs.explorer", "row-rounding").size;
float headerH = ovFont->LegacySize + Layout::spacingSm() + pad * 0.5f;
// Filter out empty entries
std::vector<const BlockSummary*> blocks;
for (const auto& bs : s_recent_blocks) {
if (bs.height > 0) blocks.push_back(&bs);
}
// Stretch card to fill the remaining tab height; rows scroll inside.
float maxRows = 10.0f;
float contentH = capFont->LegacySize + Layout::spacingXs() + rowH * maxRows;
float contentH = capFont->LegacySize + Layout::spacingXs() + baseRowH * maxRows;
float minTableH = headerH + contentH + pad;
float remainingH = ImGui::GetContentRegionAvail().y;
float tableH = std::max(minTableH, remainingH - Layout::spacingSm());
@@ -585,10 +737,84 @@ static void renderRecentBlocks(App* app, float availWidth) {
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + tableH);
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
// Header
dl->AddText(ovFont, ovFont->LegacySize,
ImVec2(cardMin.x + pad, cardMin.y + pad * 0.5f), Primary(), TR("explorer_recent_blocks"));
float hdrY = cardMin.y + pad * 0.5f + ovFont->LegacySize + Layout::spacingSm();
float rowAreaTop = hdrY + capFont->LegacySize + Layout::spacingXs();
float rowAreaH = cardMax.y - rowAreaTop - pad * 0.5f;
int rowsPerPage = std::max(1, (int)std::ceil(rowAreaH / std::max(1.0f, baseRowH)));
float rowH = baseRowH;
int maxPage = (state.sync.blocks > 0) ? (state.sync.blocks - 1) / rowsPerPage : 0;
s_recent_max_page = maxPage;
if (s_recent_page > maxPage) s_recent_page = maxPage;
if (s_recent_page < 0) s_recent_page = 0;
int startHeight = state.sync.blocks - s_recent_page * rowsPerPage;
std::vector<int> pageHeights;
pageHeights.reserve(rowsPerPage);
for (int i = 0; i < rowsPerPage; ++i) {
int height = startHeight - i;
if (height < 1) break;
pageHeights.push_back(height);
}
std::map<int, BlockSummary> cachedBlocks;
if (!pageHeights.empty()) {
cachedBlocks = s_recent_block_cache.loadRange(pageHeights.back(), pageHeights.front());
}
bool canFetchBlocks = state.sync.blocks > 0 &&
!s_show_detail_modal && !s_detail_loading && !s_tx_loading;
if (canFetchBlocks) {
bool currentPageComplete = true;
bool scheduledFetch = false;
auto* rpc = app->rpc();
if (rpc && rpc->isConnected()) {
for (int height : pageHeights) {
if (cachedBlocks.find(height) == cachedBlocks.end()) {
currentPageComplete = false;
scheduledFetch = fetchRecentBlock(app, height) || scheduledFetch;
}
}
if (currentPageComplete && s_recent_page < maxPage) {
std::vector<int> nextPageHeights;
nextPageHeights.reserve(rowsPerPage);
int nextStartHeight = state.sync.blocks - (s_recent_page + 1) * rowsPerPage;
for (int i = 0; i < rowsPerPage; ++i) {
int height = nextStartHeight - i;
if (height < 1) break;
nextPageHeights.push_back(height);
}
std::map<int, BlockSummary> nextCachedBlocks;
if (!nextPageHeights.empty()) {
nextCachedBlocks = s_recent_block_cache.loadRange(nextPageHeights.back(), nextPageHeights.front());
}
for (int height : nextPageHeights) {
if (nextCachedBlocks.find(height) == nextCachedBlocks.end()) {
scheduledFetch = fetchRecentBlock(app, height) || scheduledFetch;
}
}
}
}
if (scheduledFetch) {
fetchMempoolInfo(app);
}
}
struct RecentBlockRow {
int height = 0;
const BlockSummary* block = nullptr;
};
std::vector<RecentBlockRow> blocks;
blocks.reserve(pageHeights.size());
for (int height : pageHeights) {
auto it = cachedBlocks.find(height);
blocks.push_back({height,
it != cachedBlocks.end() ? &it->second : nullptr});
}
// Responsive column layout — give height more room, use remaining for data
float innerW = availWidth - pad * 2;
float colHeight = pad;
@@ -598,7 +824,6 @@ static void renderRecentBlocks(App* app, float availWidth) {
float colHash = colHeight + innerW * 0.56f;
float colTime = colHeight + innerW * 0.82f;
float hdrY = cardMin.y + pad * 0.5f + ovFont->LegacySize + Layout::spacingSm();
dl->AddText(capFont, capFont->LegacySize, ImVec2(cardMin.x + colHeight, hdrY), OnSurfaceMedium(), TR("explorer_block_height"));
dl->AddText(capFont, capFont->LegacySize, ImVec2(cardMin.x + colTxs, hdrY), OnSurfaceMedium(), TR("explorer_block_txs"));
dl->AddText(capFont, capFont->LegacySize, ImVec2(cardMin.x + colSize, hdrY), OnSurfaceMedium(), TR("explorer_block_size"));
@@ -607,13 +832,11 @@ static void renderRecentBlocks(App* app, float availWidth) {
dl->AddText(capFont, capFont->LegacySize, ImVec2(cardMin.x + colTime, hdrY), OnSurfaceMedium(), TR("explorer_block_time"));
// Scrollable child region for rows
float rowAreaTop = hdrY + capFont->LegacySize + Layout::spacingXs();
float rowAreaH = cardMax.y - rowAreaTop - pad * 0.5f;
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, rowAreaTop));
int parentVtx = dl->VtxBuffer.Size;
ImGui::BeginChild("##BlockRows", ImVec2(availWidth, rowAreaH), false,
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar);
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollWithMouse);
ApplySmoothScroll();
ImDrawList* childDL = ImGui::GetWindowDrawList();
@@ -624,12 +847,14 @@ static void renderRecentBlocks(App* app, float availWidth) {
char buf[128];
float rowInset = 2 * dp;
if (blocks.empty() && s_pending_block_fetches > 0) {
if (blocks.empty()) {
ImGui::SetCursorPosY(Layout::spacingMd());
ImGui::TextDisabled("%s", TR("loading"));
} else {
for (size_t i = 0; i < blocks.size(); i++) {
const auto* bs = blocks[i];
const auto& row = blocks[i];
const auto* bs = row.block;
int blockHeight = bs ? bs->height : row.height;
ImVec2 rowPos = ImGui::GetCursorScreenPos();
float rowW = ImGui::GetContentRegionAvail().x - rowInset * 2;
@@ -651,19 +876,19 @@ static void renderRecentBlocks(App* app, float availWidth) {
}
// Hover highlight — clear clickable feedback
if (hovered) {
if (hovered && bs) {
childDL->AddRectFilled(rowMin, rowMax, WithAlpha(Primary(), 20), rowRound);
childDL->AddRect(rowMin, rowMax, WithAlpha(Primary(), 40), rowRound);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
}
// Selected highlight
if (s_show_detail_modal && s_detail_height == bs->height) {
if (bs && s_show_detail_modal && s_detail_height == bs->height) {
childDL->AddRectFilled(rowMin, rowMax, WithAlpha(Primary(), 25), rowRound);
}
// Click opens block detail modal
if (clicked && app->rpc() && app->rpc()->isConnected()) {
if (bs && clicked && app->rpc() && app->rpc()->isConnected()) {
fetchBlockDetail(app, bs->height);
}
@@ -671,30 +896,32 @@ static void renderRecentBlocks(App* app, float availWidth) {
float textY2 = rowPos.y + (rowH - body2->LegacySize) * 0.5f;
// Height — emphasized with larger font and primary color
snprintf(buf, sizeof(buf), "#%d", bs->height);
snprintf(buf, sizeof(buf), "#%d", blockHeight);
childDL->AddText(sub1, sub1->LegacySize, ImVec2(cardMin.x + colHeight, textY),
hovered ? Primary() : schema::UI().resolveColor("var(--primary-light)"), buf);
(hovered && bs) ? Primary() : schema::UI().resolveColor("var(--primary-light)"), buf);
// Tx count
snprintf(buf, sizeof(buf), "%d tx", bs->tx_count);
if (bs) snprintf(buf, sizeof(buf), "%d tx", bs->tx_count);
else snprintf(buf, sizeof(buf), "...");
childDL->AddText(body2, body2->LegacySize, ImVec2(cardMin.x + colTxs, textY2), OnSurface(), buf);
// Size
std::string sizeStr = formatSize(bs->size);
std::string sizeStr = bs ? formatSize(bs->size) : "...";
childDL->AddText(body2, body2->LegacySize, ImVec2(cardMin.x + colSize, textY2), OnSurface(), sizeStr.c_str());
// Difficulty
snprintf(buf, sizeof(buf), "%.2f", bs->difficulty);
if (bs) snprintf(buf, sizeof(buf), "%.2f", bs->difficulty);
else snprintf(buf, sizeof(buf), "...");
childDL->AddText(body2, body2->LegacySize, ImVec2(cardMin.x + colDiff, textY2), OnSurface(), buf);
// Hash — adaptive to available column width
float hashMaxW = (colTime - colHash) - Layout::spacingSm();
std::string hashDisp = truncateHashToFit(bs->hash, body2, hashMaxW);
std::string hashDisp = bs ? truncateHashToFit(bs->hash, body2, hashMaxW) : "...";
childDL->AddText(body2, body2->LegacySize, ImVec2(cardMin.x + colHash, textY2),
OnSurfaceMedium(), hashDisp.c_str());
// Time
const char* timeStr = relativeTime(bs->time);
const char* timeStr = bs ? relativeTime(bs->time) : "...";
childDL->AddText(body2, body2->LegacySize, ImVec2(cardMin.x + colTime, textY2), OnSurfaceMedium(), timeStr);
}
}
@@ -1023,30 +1250,14 @@ static void renderBlockDetailModal(App* app) {
void RenderExplorerTab(App* app)
{
const auto& state = app->getWalletState();
auto* rpc = app->rpc();
ImVec2 avail = ImGui::GetContentRegionAvail();
ImGui::BeginChild("##ExplorerScroll", avail, false,
ImGuiWindowFlags_NoBackground);
ApplySmoothScroll();
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
float availWidth = ImGui::GetContentRegionAvail().x;
// Auto-refresh recent blocks when chain height changes, but avoid
// starting expensive block fetches while the user is viewing details.
if (state.sync.blocks > 0 && state.sync.blocks != s_last_known_height &&
!s_show_detail_modal && !s_detail_loading && !s_tx_loading) {
if (rpc && rpc->isConnected()) {
if (fetchRecentBlocks(app, state.sync.blocks)) {
s_last_known_height = state.sync.blocks;
fetchMempoolInfo(app);
}
}
}
renderSearchBar(app, availWidth);
renderChainStats(app, availWidth);
renderSearchBar(app, availWidth);
renderRecentBlocks(app, availWidth);
ImGui::EndChild();

View File

@@ -170,6 +170,7 @@ void ExportAllKeysDialog::render(App* app)
keys += "# === Z-Addresses (Shielded) ===\n\n";
for (const auto& addr : z_addrs) {
try {
rpc::RPCClient::TraceScope trace("Settings / Export all keys");
auto result = rpc->call("z_exportkey", {addr});
if (result.is_string()) {
keys += "# Address: " + addr + "\n";
@@ -185,6 +186,7 @@ void ExportAllKeysDialog::render(App* app)
keys += "# === T-Addresses (Transparent) ===\n\n";
for (const auto& addr : t_addrs) {
try {
rpc::RPCClient::TraceScope trace("Settings / Export all keys");
auto result = rpc->call("dumpprivkey", {addr});
if (result.is_string()) {
keys += "# Address: " + addr + "\n";

View File

@@ -309,7 +309,7 @@ void ImportKeyDialog::render(App* app)
bool rescan = s_rescan;
int rescanHeight = s_rescan_height;
if (app->worker()) {
app->worker()->post([rpc = app->rpc(), keys, rescan, rescanHeight]() -> rpc::RPCWorker::MainCb {
app->worker()->post([app, rpc = app->rpc(), keys, rescan, rescanHeight]() -> rpc::RPCWorker::MainCb {
int imported = 0;
int failed = 0;
@@ -317,6 +317,7 @@ void ImportKeyDialog::render(App* app)
std::string keyType = detectKeyType(key);
try {
rpc::RPCClient::TraceScope trace("Settings / Import private keys");
if (keyType == "z-spending") {
// z_importkey "key" "yes"|"no" startheight
if (rescan && rescanHeight > 0) {
@@ -348,13 +349,14 @@ void ImportKeyDialog::render(App* app)
// single rescanblockchain from that height now.
if (rescan && rescanHeight > 0 && imported > 0) {
try {
rpc::RPCClient::TraceScope trace("Settings / Import private keys");
rpc->call("rescanblockchain", {rescanHeight});
} catch (...) {
// rescan failure is non-fatal; user can retry
}
}
return [imported, failed]() {
return [app, imported, failed]() {
s_imported_keys = imported;
s_failed_keys = failed;
s_importing = false;
@@ -363,6 +365,8 @@ void ImportKeyDialog::render(App* app)
imported, failed);
s_status = buf;
if (imported > 0) {
app->invalidateAddressValidationCache();
app->refreshNow();
Notifications::instance().success(TR("import_key_success"), 5.0f);
}
};

View File

@@ -127,6 +127,7 @@ void KeyExportDialog::render(App* app)
std::string key;
std::string error;
try {
rpc::RPCClient::TraceScope trace("Settings / Export key");
auto result = rpc->call(method, {addr});
key = result.get<std::string>();
} catch (const std::exception& e) {
@@ -152,6 +153,7 @@ void KeyExportDialog::render(App* app)
std::string key;
std::string error;
try {
rpc::RPCClient::TraceScope trace("Settings / Export viewing key");
auto result = rpc->call("z_exportviewingkey", {addr});
key = result.get<std::string>();
} catch (const std::exception& e) {

View File

@@ -162,15 +162,6 @@ void RenderMarketTab(App* app)
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + cardH);
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
// Accent stripe — clipped to card rounded corners
{
float sw = S.drawElement("tabs.market", "accent-stripe-width").size;
dl->PushClipRect(cardMin, ImVec2(cardMin.x + sw, cardMax.y), true);
dl->AddRectFilled(cardMin, cardMax, WithAlpha(S.resolveColor("var(--accent-market)", Success()), 200),
glassSpec.rounding, ImDrawFlags_RoundCornersLeft);
dl->PopClipRect();
}
float cx = cardMin.x + Layout::spacingLg();
float cy = cardMin.y + Layout::spacingLg();
@@ -797,15 +788,6 @@ void RenderMarketTab(App* app)
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + cardH);
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
// Accent stripe
{
float sw = S.drawElement("tabs.market", "accent-stripe-width").size;
dl->PushClipRect(cardMin, ImVec2(cardMin.x + sw, cardMax.y), true);
dl->AddRectFilled(cardMin, cardMax, WithAlpha(S.resolveColor("var(--accent-portfolio)", Secondary()), 200),
glassSpec.rounding, ImDrawFlags_RoundCornersLeft);
dl->PopClipRect();
}
float cx = cardMin.x + Layout::spacingLg();
float cy = cardMin.y + Layout::spacingLg();

View File

@@ -347,6 +347,7 @@ void RenderPeersTab(App* app)
// Click to copy full hash
ImVec2 hashSz = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, truncHash.c_str());
ImVec2 savedCursor = ImGui::GetCursorScreenPos();
ImGui::SetCursorScreenPos(ImVec2(cx, valY));
ImGui::InvisibleButton("##BestBlockCopy", ImVec2(hashSz.x + Layout::spacingSm(), sub1->LegacySize + 2 * dp));
if (ImGui::IsItemHovered()) {
@@ -360,6 +361,7 @@ void RenderPeersTab(App* app)
ImGui::SetClipboardText(hash.c_str());
ui::Notifications::instance().info(TR("peers_hash_copied"));
}
ImGui::SetCursorScreenPos(savedCursor);
} else {
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, valY), OnSurfaceDisabled(), "\xE2\x80\x94");
}
@@ -482,6 +484,7 @@ void RenderPeersTab(App* app)
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, valY), bannedCol, buf); }
}
ImGui::SetCursorScreenPos(basePos);
ImGui::Dummy(ImVec2(availWidth, infoCardsH));
ImGui::Dummy(ImVec2(0, gap));
}

View File

@@ -9,7 +9,6 @@
// - Recent received at bottom
#include "receive_tab.h"
#include "send_tab.h"
#include "../../app.h"
#include "../../config/settings.h"
#include "../../util/i18n.h"
@@ -360,145 +359,99 @@ static void DrawRecvIcon(ImDrawList* dl, float cx, float cy, float s, ImU32 col)
// ============================================================================
// Recent received transactions — styled to match transactions list
// ============================================================================
static void RenderRecentReceived(ImDrawList* dl, const AddressInfo& /* addr */,
static void RenderRecentReceived(const AddressInfo& /* addr */,
const WalletState& state, float width,
ImFont* capFont, App* app) {
auto& S = schema::UI();
ImGui::Dummy(ImVec2(0, Layout::spacingLg()));
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("recent_received"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
float hs = Layout::hScale(width);
float glassRound = Layout::glassRounding();
ImFont* body2 = Type().body2();
float rowH = body2->LegacySize + capFont->LegacySize + Layout::spacingLg() + Layout::spacingMd();
float iconSz = std::max(schema::UI().drawElement("tabs.receive", "recent-icon-min-size").size, schema::UI().drawElement("tabs.receive", "recent-icon-size").size * hs);
float vs = Layout::vScale(std::max(1.0f, ImGui::GetContentRegionAvail().y));
float dp = Layout::dpiScale();
float rowH = std::max(18.0f * dp, S.drawElement("tabs.balance", "recent-tx-row-height").sizeOr(22.0f) * vs);
float iconSz = std::max(S.drawElement("tabs.balance", "recent-tx-icon-min-size").size,
S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs);
ImU32 recvCol = Success();
ImU32 greenCol = WithAlpha(Success(), (int)schema::UI().drawElement("tabs.receive", "recent-green-alpha").size);
float rowPadLeft = Layout::spacingLg();
// Collect matching transactions
std::vector<const TransactionInfo*> recvs;
for (const auto& tx : state.transactions) {
if (tx.type != "receive") continue;
if (tx.type != "receive" && tx.type != "mined") continue;
recvs.push_back(&tx);
if (recvs.size() >= (size_t)schema::UI().drawElement("tabs.receive", "max-recent-receives").size) break;
}
float listH = std::max(rowH, ImGui::GetContentRegionAvail().y);
ImGui::BeginChild("##RecentReceivedRows", ImVec2(width, listH), false,
ImGuiWindowFlags_NoBackground);
ImDrawList* rowDL = ImGui::GetWindowDrawList();
char buf[64];
if (recvs.empty()) {
ImGui::SetCursorPosY(Layout::spacingMd());
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_recent_receives"));
ImGui::EndChild();
return;
}
// Outer glass panel wrapping all rows
float itemSpacingY = ImGui::GetStyle().ItemSpacing.y;
float listH = rowH * (float)recvs.size() + itemSpacingY * (float)(recvs.size() - 1);
ImVec2 listPanelMin = ImGui::GetCursorScreenPos();
ImVec2 listPanelMax(listPanelMin.x + width, listPanelMin.y + listH);
GlassPanelSpec glassSpec;
glassSpec.rounding = glassRound;
DrawGlassPanel(dl, listPanelMin, listPanelMax, glassSpec);
// Clip draw commands to panel bounds to prevent overflow
dl->PushClipRect(listPanelMin, listPanelMax, true);
char buf[64];
for (size_t ri = 0; ri < recvs.size(); ri++) {
const auto& tx = *recvs[ri];
ImVec2 rowPos = ImGui::GetCursorScreenPos();
ImVec2 rowEnd(rowPos.x + width, rowPos.y + rowH);
float rowY = rowPos.y + rowH * 0.5f;
// Hover glow
bool hovered = material::IsRectHovered(rowPos, rowEnd);
if (hovered) {
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, (int)schema::UI().drawElement("tabs.receive", "row-hover-alpha").size), schema::UI().drawElement("tabs.receive", "row-hover-rounding").size);
// Icon
DrawRecvIcon(rowDL, rowPos.x + Layout::spacingMd(), rowY, iconSz * 0.5f, recvCol);
// Type label (first line)
float txX = rowPos.x + Layout::spacingMd() + iconSz * 2.0f + Layout::spacingSm();
const char* typeText = tx.type == "mined" ? TR("mined_type") : TR("received_label");
rowDL->AddText(capFont, capFont->LegacySize,
ImVec2(txX, rowPos.y + 2.0f * dp), OnSurfaceMedium(), typeText);
// Address (second line)
float addrX = txX + S.drawElement("tabs.balance", "recent-tx-addr-offset").sizeOr(65.0f);
std::string addrDisplay = TruncateAddress(tx.address,
(size_t)S.drawElement("tabs.balance", "recent-tx-addr-trunc").sizeOr(20.0f));
rowDL->AddText(capFont, capFont->LegacySize,
ImVec2(addrX, rowPos.y + 2.0f * dp), OnSurfaceDisabled(), addrDisplay.c_str());
// Amount (right-aligned, first line)
snprintf(buf, sizeof(buf), "+%.4f %s", std::abs(tx.amount), DRAGONX_TICKER);
ImVec2 amtSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000.0f, 0.0f, buf);
float rightEdge = rowPos.x + ImGui::GetContentRegionAvail().x;
float amtX = rightEdge - amtSz.x - std::max(S.drawElement("tabs.balance", "amount-right-min-margin").size,
S.drawElement("tabs.balance", "amount-right-margin").size * hs);
rowDL->AddText(capFont, capFont->LegacySize,
ImVec2(amtX, rowPos.y + 2.0f * dp), recvCol, buf);
// Time ago
std::string ago = recvTimeAgo(tx.timestamp);
ImVec2 agoSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000.0f, 0.0f, ago.c_str());
rowDL->AddText(capFont, capFont->LegacySize,
ImVec2(rightEdge - agoSz.x - S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f),
rowPos.y + 2.0f * dp),
OnSurfaceDisabled(), ago.c_str());
// Clickable row — hover highlight + navigate to History
float rowW = ImGui::GetContentRegionAvail().x;
ImVec2 rowEnd(rowPos.x + rowW, rowPos.y + rowH);
if (material::IsRectHovered(rowPos, rowEnd)) {
rowDL->AddRectFilled(rowPos, rowEnd,
IM_COL32(255, 255, 255, 15),
S.drawElement("tabs.balance", "row-hover-rounding").sizeOr(4.0f));
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(0)) {
app->setCurrentPage(ui::NavPage::History);
}
}
float cx = rowPos.x + rowPadLeft;
float cy = rowPos.y + Layout::spacingMd();
// Icon
DrawRecvIcon(dl, cx + iconSz, cy + body2->LegacySize * 0.5f, iconSz, recvCol);
// Type label (first line)
float labelX = cx + iconSz * 2.0f + Layout::spacingSm();
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), recvCol, TR("received_label"));
// Time (next to type)
std::string ago = recvTimeAgo(tx.timestamp);
float typeW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, TR("received_label")).x;
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX + typeW + Layout::spacingLg(), cy),
OnSurfaceDisabled(), ago.c_str());
// Address (second line)
std::string addr_display = TruncateAddress(tx.address, (int)schema::UI().drawElement("tabs.receive", "recent-addr-trunc-len").size);
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy + body2->LegacySize + Layout::spacingXs()),
OnSurfaceMedium(), addr_display.c_str());
// Amount (right-aligned, first line)
snprintf(buf, sizeof(buf), "+%.8f", tx.amount);
ImVec2 amtSz = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, buf);
float amtX = rowPos.x + width - amtSz.x - Layout::spacingLg();
DrawTextShadow(dl, body2, body2->LegacySize, ImVec2(amtX, cy), recvCol, buf,
schema::UI().drawElement("tabs.receive", "text-shadow-offset-x").size, schema::UI().drawElement("tabs.receive", "text-shadow-offset-y").size, IM_COL32(0, 0, 0, (int)schema::UI().drawElement("tabs.receive", "text-shadow-alpha").size));
// USD equivalent (right-aligned, second line)
double priceUsd = state.market.price_usd;
if (priceUsd > 0.0) {
double usdVal = tx.amount * priceUsd;
if (usdVal >= 1.0)
snprintf(buf, sizeof(buf), "$%.2f", usdVal);
else if (usdVal >= 0.01)
snprintf(buf, sizeof(buf), "$%.4f", usdVal);
else
snprintf(buf, sizeof(buf), "$%.6f", usdVal);
ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(rowPos.x + width - usdSz.x - Layout::spacingLg(), cy + body2->LegacySize + Layout::spacingXs()),
OnSurfaceDisabled(), buf);
}
// Status badge
{
const char* statusStr;
ImU32 statusCol;
if (tx.confirmations == 0) {
statusStr = TR("pending"); statusCol = Warning();
} else if (tx.confirmations < (int)schema::UI().drawElement("tabs.receive", "confirmed-threshold").size) {
snprintf(buf, sizeof(buf), TR("conf_count"), tx.confirmations);
statusStr = buf; statusCol = Warning();
} else {
statusStr = TR("confirmed"); statusCol = greenCol;
}
ImVec2 sSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, statusStr);
float statusX = amtX - sSz.x - Layout::spacingXxl();
float minStatusX = cx + width * schema::UI().drawElement("tabs.receive", "status-min-x-ratio").size;
if (statusX < minStatusX) statusX = minStatusX;
ImU32 pillBg = (statusCol & 0x00FFFFFFu) | (static_cast<ImU32>((int)schema::UI().drawElement("tabs.receive", "status-pill-bg-alpha").size) << 24);
ImVec2 pillMin(statusX - Layout::spacingSm(), cy + body2->LegacySize + (int)schema::UI().drawElement("tabs.receive", "status-pill-y-offset").size);
ImVec2 pillMax(statusX + sSz.x + Layout::spacingSm(), pillMin.y + capFont->LegacySize + Layout::spacingXs());
dl->AddRectFilled(pillMin, pillMax, pillBg, schema::UI().drawElement("tabs.receive", "status-pill-rounding").size);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(statusX, cy + body2->LegacySize + Layout::spacingXs()), statusCol, statusStr);
}
ImGui::Dummy(ImVec2(0, rowH));
// Subtle divider between rows
if (ri < recvs.size() - 1) {
ImVec2 divStart = ImGui::GetCursorScreenPos();
dl->AddLine(ImVec2(divStart.x + rowPadLeft + iconSz * 2.0f, divStart.y),
ImVec2(divStart.x + width - Layout::spacingLg(), divStart.y),
IM_COL32(255, 255, 255, (int)schema::UI().drawElement("tabs.receive", "row-divider-alpha").size));
}
}
dl->PopClipRect();
ImGui::EndChild();
}
// ============================================================================
@@ -843,23 +796,6 @@ void RenderReceiveTab(App* app)
ImGui::Dummy(ImVec2(addrColW, capFont->LegacySize + Layout::spacingSm()));
}
// Clear button
{
bool hasData = (s_request_amount > 0 || s_request_memo[0]);
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered,
ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, (int)S.drawElement("tabs.receive", "clear-btn-hover-alpha").size)));
ImGui::PushStyleColor(ImGuiCol_Text,
ImGui::ColorConvertU32ToFloat4(hasData ? OnSurfaceMedium() : OnSurfaceDisabled()));
ImGui::BeginDisabled(!hasData);
if (TactileSmallButton(TrId("clear_request", "recv").c_str(), S.resolveFont("button"))) {
s_request_amount = 0.0;
s_request_usd_amount = 0.0;
s_request_memo[0] = '\0';
}
ImGui::EndDisabled();
ImGui::PopStyleColor(3);
}
}
float leftBottom = ImGui::GetCursorScreenPos().y;
@@ -869,9 +805,14 @@ void RenderReceiveTab(App* app)
float rx = qrColX;
float ry = sectionTop.y;
float maxQrSize = std::min(qrAvailW - Layout::spacingMd() * 2, S.drawElement("tabs.receive", "qr-max-size").size);
float qrSize = std::max(S.drawElement("tabs.receive", "qr-min-size").size, maxQrSize);
float qrPadding = Layout::spacingMd();
float qrWidthBound = std::max(0.0f, qrAvailW - qrPadding * 2.0f);
float qrHeightBound = std::max(0.0f, leftBottom - sectionTop.y - qrPadding * 2.0f);
float qrMaxSize = qrHeightBound > 0.0f
? std::min(qrWidthBound, qrHeightBound)
: qrWidthBound;
float qrMinSize = std::min(S.drawElement("tabs.receive", "qr-min-size").size, qrWidthBound);
float qrSize = std::max(qrMinSize, qrMaxSize);
float totalQrSize = qrSize + qrPadding * 2;
float qrOffsetX = std::max(0.0f, (qrAvailW - totalQrSize) * 0.5f);
ImVec2 qrPanelMin(rx + qrOffsetX, ry);
@@ -917,6 +858,18 @@ void RenderReceiveTab(App* app)
}
// Divider before action buttons
{
float actionBtnH = std::max(S.drawElement("tabs.receive", "action-btn-min-height").size,
S.drawElement("tabs.receive", "action-btn-height").size * vScale);
float footerH = innerGap + actionBtnH + pad;
float currentCardH = ImGui::GetCursorScreenPos().y - containerMin.y;
float targetCardH = Layout::mainCardTargetH(formW, vScale);
float footerTopH = targetCardH - footerH;
if (currentCardH < footerTopH) {
ImGui::Dummy(ImVec2(0, footerTopH - currentCardH));
}
}
ImGui::Dummy(ImVec2(0, innerGap * 0.5f));
{
ImVec2 divPos = ImGui::GetCursorScreenPos();
@@ -964,6 +917,25 @@ void RenderReceiveTab(App* app)
ImGui::PopStyleColor(3);
}
{
bool hasData = (s_request_amount > 0 || s_request_memo[0]);
if (!firstBtn) ImGui::SameLine(0, btnGap);
firstBtn = false;
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered,
ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, (int)S.drawElement("tabs.receive", "clear-btn-hover-alpha").size)));
ImGui::PushStyleColor(ImGuiCol_Text,
ImGui::ColorConvertU32ToFloat4(hasData ? OnSurfaceMedium() : OnSurfaceDisabled()));
ImGui::BeginDisabled(!hasData);
if (TactileButton(TrId("clear_request", "recv").c_str(), ImVec2(otherBtnW, btnH), S.resolveFont("button"))) {
s_request_amount = 0.0;
s_request_usd_amount = 0.0;
s_request_memo[0] = '\0';
}
ImGui::EndDisabled();
ImGui::PopStyleColor(3);
}
if (!firstBtn) ImGui::SameLine(0, btnGap);
firstBtn = false;
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0));
@@ -978,20 +950,6 @@ void RenderReceiveTab(App* app)
ImGui::PopStyleVar(); // FrameBorderSize
ImGui::PopStyleColor(4);
if (selected.balance > 0) {
if (!firstBtn) ImGui::SameLine(0, btnGap);
firstBtn = false;
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered,
ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, (int)S.drawElement("tabs.receive", "btn-hover-alpha").size)));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()));
if (TactileButton(TrId("send", "recv").c_str(), ImVec2(otherBtnW, btnH), S.resolveFont("button"))) {
SetSendFromAddress(selected.address);
app->setCurrentPage(NavPage::Send);
}
ImGui::PopStyleColor(3);
}
}
// Bottom padding
@@ -1020,7 +978,7 @@ void RenderReceiveTab(App* app)
// ================================================================
// RECENT RECEIVED
// ================================================================
RenderRecentReceived(dl, selected, state, formW, capFont, app);
RenderRecentReceived(selected, state, formW, capFont, app);
ImGui::EndGroup();
float measuredH = ImGui::GetCursorPosY() - contentStartY;

View File

@@ -971,8 +971,7 @@ static void RenderActionButtons(App* app, float width, float vScale,
// ============================================================================
// Recent Sends section — styled to match transactions list
// ============================================================================
static void RenderRecentSends(ImDrawList* dl, const WalletState& state,
float width, ImFont* capFont, App* app) {
static void RenderRecentSends(const WalletState& state, float width, ImFont* capFont, App* app) {
auto& S = schema::UI();
ImGui::Dummy(ImVec2(0, Layout::spacingLg()));
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("send_recent_sends"));
@@ -980,139 +979,89 @@ static void RenderRecentSends(ImDrawList* dl, const WalletState& state,
ImVec2 avail = ImGui::GetContentRegionAvail();
float hs = Layout::hScale(avail.x);
float glassRound = Layout::glassRounding();
ImFont* body2 = Type().body2();
float rowH = body2->LegacySize + capFont->LegacySize + Layout::spacingLg() + Layout::spacingMd();
float iconSz = std::max(schema::UI().drawElement("tabs.send", "recent-icon-min-size").size, schema::UI().drawElement("tabs.send", "recent-icon-size").size * hs);
float vs = Layout::vScale(std::max(1.0f, avail.y));
float dp = Layout::dpiScale();
float rowH = std::max(18.0f * dp, S.drawElement("tabs.balance", "recent-tx-row-height").sizeOr(22.0f) * vs);
float iconSz = std::max(S.drawElement("tabs.balance", "recent-tx-icon-min-size").size,
S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs);
ImU32 sendCol = Error();
ImU32 greenCol = WithAlpha(Success(), (int)S.drawElement("tabs.send", "recent-green-alpha").size);
float rowPadLeft = Layout::spacingLg();
// Collect matching transactions
std::vector<const TransactionInfo*> sends;
for (const auto& tx : state.transactions) {
if (tx.type != "send") continue;
if (tx.type != "send" && tx.type != "shield") continue;
sends.push_back(&tx);
if (sends.size() >= (size_t)S.drawElement("tabs.send", "max-recent-sends").size) break;
}
float listH = std::max(rowH, ImGui::GetContentRegionAvail().y);
ImGui::BeginChild("##RecentSendRows", ImVec2(width, listH), false,
ImGuiWindowFlags_NoBackground);
ImDrawList* rowDL = ImGui::GetWindowDrawList();
char buf[64];
if (sends.empty()) {
ImGui::SetCursorPosY(Layout::spacingMd());
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("send_no_recent"));
ImGui::EndChild();
return;
}
// Outer glass panel wrapping all rows
float itemSpacingY = ImGui::GetStyle().ItemSpacing.y;
float listH = rowH * (float)sends.size() + itemSpacingY * (float)(sends.size() - 1);
ImVec2 listPanelMin = ImGui::GetCursorScreenPos();
float listW = width;
ImVec2 listPanelMax(listPanelMin.x + listW, listPanelMin.y + listH);
GlassPanelSpec glassSpec;
glassSpec.rounding = glassRound;
DrawGlassPanel(dl, listPanelMin, listPanelMax, glassSpec);
// Clip draw commands to panel bounds to prevent overflow
dl->PushClipRect(listPanelMin, listPanelMax, true);
char buf[64];
for (size_t si = 0; si < sends.size(); si++) {
const auto& tx = *sends[si];
ImVec2 rowPos = ImGui::GetCursorScreenPos();
float innerW = listW;
ImVec2 rowEnd(rowPos.x + innerW, rowPos.y + rowH);
float rowY = rowPos.y + rowH * 0.5f;
// Hover glow
bool hovered = material::IsRectHovered(rowPos, rowEnd);
if (hovered) {
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.send", "row-hover-alpha").size), schema::UI().drawElement("tabs.send", "row-hover-rounding").size);
// Icon
DrawTxIcon(rowDL, "send", rowPos.x + Layout::spacingMd(), rowY, iconSz, sendCol);
// Type label (first line)
float txX = rowPos.x + Layout::spacingMd() + iconSz * 2.0f + Layout::spacingSm();
rowDL->AddText(capFont, capFont->LegacySize,
ImVec2(txX, rowPos.y + 2.0f * dp), OnSurfaceMedium(), TR("sent_type"));
// Address (second line)
float addrX = txX + S.drawElement("tabs.balance", "recent-tx-addr-offset").sizeOr(65.0f);
std::string addrDisplay = TruncateAddress(tx.address,
(size_t)S.drawElement("tabs.balance", "recent-tx-addr-trunc").sizeOr(20.0f));
rowDL->AddText(capFont, capFont->LegacySize,
ImVec2(addrX, rowPos.y + 2.0f * dp), OnSurfaceDisabled(), addrDisplay.c_str());
// Amount (right-aligned, first line)
snprintf(buf, sizeof(buf), "-%.4f %s", std::abs(tx.amount), DRAGONX_TICKER);
ImVec2 amtSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000.0f, 0.0f, buf);
float rightEdge = rowPos.x + ImGui::GetContentRegionAvail().x;
float amtX = rightEdge - amtSz.x - std::max(S.drawElement("tabs.balance", "amount-right-min-margin").size,
S.drawElement("tabs.balance", "amount-right-margin").size * hs);
rowDL->AddText(capFont, capFont->LegacySize,
ImVec2(amtX, rowPos.y + 2.0f * dp), sendCol, buf);
// Time ago
std::string ago = timeAgo(tx.timestamp);
ImVec2 agoSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000.0f, 0.0f, ago.c_str());
rowDL->AddText(capFont, capFont->LegacySize,
ImVec2(rightEdge - agoSz.x - S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f),
rowPos.y + 2.0f * dp),
OnSurfaceDisabled(), ago.c_str());
// Clickable row — hover highlight + navigate to History
float rowW = ImGui::GetContentRegionAvail().x;
ImVec2 rowEnd(rowPos.x + rowW, rowPos.y + rowH);
if (material::IsRectHovered(rowPos, rowEnd)) {
rowDL->AddRectFilled(rowPos, rowEnd,
IM_COL32(255, 255, 255, 15),
S.drawElement("tabs.balance", "row-hover-rounding").sizeOr(4.0f));
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(0)) {
app->setCurrentPage(ui::NavPage::History);
}
}
float cx = rowPos.x + rowPadLeft;
float cy = rowPos.y + Layout::spacingMd();
// Icon
DrawTxIcon(dl, tx.type, cx + iconSz, cy + body2->LegacySize * 0.5f, iconSz, sendCol);
// Type label (first line)
float labelX = cx + iconSz * 2.0f + Layout::spacingSm();
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), sendCol, TR("sent_upper"));
// Time (next to type)
std::string ago = timeAgo(tx.timestamp);
float typeW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, TR("sent_upper")).x;
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX + typeW + Layout::spacingLg(), cy),
OnSurfaceDisabled(), ago.c_str());
// Address (second line)
std::string addr_display = TruncateAddress(tx.address, (int)S.drawElement("tabs.send", "recent-addr-trunc-len").size);
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy + body2->LegacySize + Layout::spacingXs()),
OnSurfaceMedium(), addr_display.c_str());
// Amount (right-aligned, first line)
snprintf(buf, sizeof(buf), "-%.8f", std::abs(tx.amount));
ImVec2 amtSz = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, buf);
float amtX = rowPos.x + innerW - amtSz.x - Layout::spacingLg();
DrawTextShadow(dl, body2, body2->LegacySize, ImVec2(amtX, cy), sendCol, buf,
S.drawElement("tabs.send", "text-shadow-offset-x").size, S.drawElement("tabs.send", "text-shadow-offset-y").size, IM_COL32(0, 0, 0, (int)S.drawElement("tabs.send", "text-shadow-alpha").size));
// USD equivalent (right-aligned, second line)
double priceUsd = state.market.price_usd;
if (priceUsd > 0.0) {
double usdVal = std::abs(tx.amount) * priceUsd;
if (usdVal >= 1.0)
snprintf(buf, sizeof(buf), "$%.2f", usdVal);
else if (usdVal >= 0.01)
snprintf(buf, sizeof(buf), "$%.4f", usdVal);
else
snprintf(buf, sizeof(buf), "$%.6f", usdVal);
ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(rowPos.x + innerW - usdSz.x - Layout::spacingLg(), cy + body2->LegacySize + Layout::spacingXs()),
OnSurfaceDisabled(), buf);
}
// Status badge
{
const char* statusStr;
ImU32 statusCol;
if (tx.confirmations == 0) {
statusStr = TR("pending"); statusCol = Warning();
} else if (tx.confirmations < (int)S.drawElement("tabs.send", "confirmed-threshold").size) {
snprintf(buf, sizeof(buf), TR("conf_count"), tx.confirmations);
statusStr = buf; statusCol = Warning();
} else {
statusStr = TR("confirmed"); statusCol = greenCol;
}
ImVec2 sSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, statusStr);
float statusX = amtX - sSz.x - Layout::spacingXxl();
float minStatusX = cx + innerW * S.drawElement("tabs.send", "status-min-x-ratio").size;
if (statusX < minStatusX) statusX = minStatusX;
ImU32 pillBg = (statusCol & 0x00FFFFFFu) | (static_cast<ImU32>((int)S.drawElement("tabs.send", "status-pill-bg-alpha").size) << 24);
ImVec2 pillMin(statusX - Layout::spacingSm(), cy + body2->LegacySize + (int)S.drawElement("tabs.send", "status-pill-y-offset").size);
ImVec2 pillMax(statusX + sSz.x + Layout::spacingSm(), pillMin.y + capFont->LegacySize + Layout::spacingXs());
dl->AddRectFilled(pillMin, pillMax, pillBg, schema::UI().drawElement("tabs.send", "status-pill-rounding").size);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(statusX, cy + body2->LegacySize + Layout::spacingXs()), statusCol, statusStr);
}
ImGui::Dummy(ImVec2(0, rowH));
// Subtle divider between rows
if (si < sends.size() - 1) {
ImVec2 divStart = ImGui::GetCursorScreenPos();
dl->AddLine(ImVec2(divStart.x + rowPadLeft + iconSz * 2.0f, divStart.y),
ImVec2(divStart.x + innerW - Layout::spacingLg(), divStart.y),
IM_COL32(255, 255, 255, (int)S.drawElement("tabs.send", "row-divider-alpha").size));
}
}
dl->PopClipRect();
ImGui::EndChild();
}
// ============================================================================
@@ -1456,6 +1405,18 @@ void RenderSendTab(App* app)
}
// Divider before action buttons
{
float actionBtnH = std::max(S.drawElement("tabs.send", "action-btn-min-height").size,
S.drawElement("tabs.send", "action-btn-height").size * vScale);
float footerH = innerGap + actionBtnH + pad * vScale;
float currentCardH = ImGui::GetCursorScreenPos().y - containerMin.y;
float targetCardH = Layout::mainCardTargetH(formW, vScale);
float footerTopH = targetCardH - footerH;
if (currentCardH < footerTopH) {
ImGui::Dummy(ImVec2(0, footerTopH - currentCardH));
}
}
ImGui::Dummy(ImVec2(0, innerGap * 0.5f));
{
ImVec2 divPos = ImGui::GetCursorScreenPos();
@@ -1507,7 +1468,7 @@ void RenderSendTab(App* app)
}
// ---- RECENT SENDS ----
RenderRecentSends(dl, state, formW, capFont, app);
RenderRecentSends(state, formW, capFont, app);
ImGui::EndGroup();
ImGui::EndDisabled(); // sendSyncing guard

View File

@@ -429,6 +429,7 @@ void RenderSettingsWindow(App* app, bool* p_open)
if (material::StyledButton(TR("test_connection"), ImVec2(0,0), S.resolveFont("button"))) {
if (app->rpc()) {
rpc::RPCClient::TraceScope trace("Settings / Test connection");
app->rpc()->getInfo([](const nlohmann::json& result, const std::string& error) {
if (error.empty()) {
std::string version = result.value("version", "unknown");

View File

@@ -185,6 +185,7 @@ void ShieldDialog::render(App* app)
nlohmann::json result;
std::string error;
try {
rpc::RPCClient::TraceScope trace("Send tab / Shield coinbase");
result = rpc->call("z_shieldcoinbase", {from, to, fee, limit});
} catch (const std::exception& e) {
error = e.what();
@@ -215,6 +216,7 @@ void ShieldDialog::render(App* app)
nlohmann::json result;
std::string error;
try {
rpc::RPCClient::TraceScope trace("Send tab / Merge funds");
result = rpc->call("z_mergetoaddress", {addrs, to, fee, 0, limit});
} catch (const std::exception& e) {
error = e.what();
@@ -258,6 +260,7 @@ void ShieldDialog::render(App* app)
nlohmann::json result;
std::string error;
try {
rpc::RPCClient::TraceScope trace("Send tab / Shield operation status");
nlohmann::json ids = nlohmann::json::array();
ids.push_back(opid);
result = rpc->call("z_getoperationstatus", {ids});

View File

@@ -104,7 +104,7 @@ static void DrawTxIcon(ImDrawList* dl, const std::string& type,
} else if (type == "receive") {
icon = ICON_MD_CALL_RECEIVED;
} else if (type == "shield") {
icon = ICON_MD_SHIELD;
icon = ICON_MD_CALL_MADE;
} else {
icon = ICON_MD_CONSTRUCTION;
}
@@ -147,6 +147,8 @@ void RenderTransactionsTab(App* app)
ImU32 greenCol = Success();
ImU32 redCol = Error();
ImU32 goldCol = Warning();
std::string txLoadingText = app->transactionRefreshProgressText();
bool txLoading = !txLoadingText.empty();
// Expanded row index for inline detail
static int s_expanded_row = -1;
@@ -319,6 +321,18 @@ void RenderTransactionsTab(App* app)
ExportTransactionsDialog::show();
}
if (txLoading) {
ImGui::SameLine(0, filterGap);
ImGui::PushFont(Type().iconSmall());
float pulse = 0.55f + 0.45f * std::sin((float)ImGui::GetTime() * 3.0f);
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, pulse), ICON_MD_HOURGLASS_EMPTY);
ImGui::PopFont();
ImGui::SameLine(0, Layout::spacingXs());
int dots = ((int)(ImGui::GetTime() * 3.0f)) % 4;
const char* dotStr[] = {"", ".", "..", "..."};
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "%s%s", txLoadingText.c_str(), dotStr[dots]);
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm() + Layout::spacingXs()));
// ================================================================
@@ -415,8 +429,8 @@ void RenderTransactionsTab(App* app)
for (size_t i = 0; i < display_txns.size(); i++) {
const auto& dtx = display_txns[i];
if (type_filter != 0) {
if (type_filter == 1 && dtx.display_type != "send") continue;
if (type_filter == 2 && dtx.display_type != "receive" && dtx.display_type != "shield") continue;
if (type_filter == 1 && dtx.display_type != "send" && dtx.display_type != "shield") continue;
if (type_filter == 2 && dtx.display_type != "receive") continue;
if (type_filter == 3 && dtx.display_type != "generate" && dtx.display_type != "immature" && dtx.display_type != "mined") continue;
}
if (!search_str.empty()) {
@@ -567,7 +581,14 @@ void RenderTransactionsTab(App* app)
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("not_connected"));
} else if (state.transactions.empty()) {
ImGui::Dummy(ImVec2(0, 20));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_transactions"));
if (txLoading) {
int dots = ((int)(ImGui::GetTime() * 3.0f)) % 4;
const char* dotStr[] = {"", ".", "..", "..."};
snprintf(buf, sizeof(buf), "%s%s", txLoadingText.c_str(), dotStr[dots]);
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
} else {
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_transactions"));
}
} else if (filtered_indices.empty()) {
ImGui::Dummy(ImVec2(0, 20));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_matching"));
@@ -596,10 +617,11 @@ void RenderTransactionsTab(App* app)
ImVec2 rowEnd(rowPos.x + innerW, rowPos.y + rowH);
// Determine type info
bool shieldedDisplay = tx.display_type == "shield";
ImU32 iconCol;
const char* typeStr;
if (tx.display_type == "shield") {
iconCol = Primary(); typeStr = TR("shielded_type");
if (shieldedDisplay) {
iconCol = redCol; typeStr = TR("sent_type");
} else if (tx.display_type == "receive") {
iconCol = greenCol; typeStr = TR("recv_type");
} else if (tx.display_type == "send") {
@@ -627,7 +649,8 @@ void RenderTransactionsTab(App* app)
float cy = rowPos.y + Layout::spacingMd();
// Icon
DrawTxIcon(dl, tx.display_type, cx + rowIconSz, cy + body2->LegacySize * 0.5f, rowIconSz, iconCol);
DrawTxIcon(dl, shieldedDisplay ? "send" : tx.display_type,
cx + rowIconSz, cy + body2->LegacySize * 0.5f, rowIconSz, iconCol);
// Type label
float labelX = cx + rowIconSz * 2.0f + Layout::spacingSm();
@@ -687,16 +710,35 @@ void RenderTransactionsTab(App* app)
}
// Position status badge in the middle-right area
ImVec2 sSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, statusStr);
float statusX = amtX - sSz.x - Layout::spacingXxl();
const char* shieldedStr = TR("shielded_type");
ImVec2 shieldSz = shieldedDisplay
? capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, shieldedStr)
: ImVec2(0, 0);
float shieldPillW = shieldSz.x + Layout::spacingSm() * 2.0f;
float stackW = shieldedDisplay ? std::max(sSz.x, shieldPillW) : sSz.x;
float statusX = amtX - stackW - Layout::spacingXxl();
float minStatusX = cx + innerW * 0.25f; // don't overlap address
if (statusX < minStatusX) statusX = minStatusX;
float statusTextX = statusX + (stackW - sSz.x) * 0.5f;
if (shieldedDisplay) {
float shieldX = statusX + (stackW - shieldSz.x) * 0.5f;
ImU32 shieldCol = Primary();
ImU32 shieldBg = (shieldCol & 0x00FFFFFFu) | (static_cast<ImU32>(30) << 24);
ImVec2 shieldPillMin(shieldX - Layout::spacingSm(), cy - 1.0f);
ImVec2 shieldPillMax(shieldX + shieldSz.x + Layout::spacingSm(),
shieldPillMin.y + capFont->LegacySize + Layout::spacingXs());
dl->AddRectFilled(shieldPillMin, shieldPillMax, shieldBg,
schema::UI().drawElement("tabs.transactions", "status-pill-rounding").size);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(shieldX, cy), shieldCol, shieldedStr);
}
// Background pill
ImU32 pillBg = (statusCol & 0x00FFFFFFu) | (static_cast<ImU32>(30) << 24);
ImVec2 pillMin(statusX - Layout::spacingSm(), cy + body2->LegacySize + 1);
ImVec2 pillMax(statusX + sSz.x + Layout::spacingSm(), pillMin.y + capFont->LegacySize + Layout::spacingXs());
ImVec2 pillMin(statusTextX - Layout::spacingSm(), cy + body2->LegacySize + 1);
ImVec2 pillMax(statusTextX + sSz.x + Layout::spacingSm(), pillMin.y + capFont->LegacySize + Layout::spacingXs());
dl->AddRectFilled(pillMin, pillMax, pillBg, schema::UI().drawElement("tabs.transactions", "status-pill-rounding").size);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(statusX, cy + body2->LegacySize + Layout::spacingXs()), statusCol, statusStr);
ImVec2(statusTextX, cy + body2->LegacySize + Layout::spacingXs()), statusCol, statusStr);
}
// Click to expand/collapse + invisible button for interaction

View File

@@ -91,6 +91,7 @@ void ValidateAddressDialog::render(App* app)
bool valid = false, mine = false;
std::string error;
try {
rpc::RPCClient::TraceScope trace("Receive tab / Validate address");
auto result = rpc->call("validateaddress", {address});
valid = result.value("isvalid", false);
mine = result.value("ismine", false);
@@ -117,6 +118,7 @@ void ValidateAddressDialog::render(App* app)
bool valid = false, mine = false;
std::string error;
try {
rpc::RPCClient::TraceScope trace("Receive tab / Validate address");
auto result = rpc->call("validateaddress", {address});
valid = result.value("isvalid", false);
mine = result.value("ismine", false);

View File

@@ -482,6 +482,12 @@ void I18n::loadBuiltinEnglish()
strings_["fee_normal"] = "Normal";
strings_["fee_high"] = "High";
strings_["submitting_transaction"] = "Submitting transaction...";
strings_["processing_transaction"] = "Processing transaction...";
strings_["tx_progress_submitting"] = "Submitting transaction to daemon";
strings_["tx_progress_waiting_ops"] = "Waiting for operation (%d)";
strings_["tx_progress_balances"] = "Refreshing balances";
strings_["tx_progress_history"] = "Refreshing history";
strings_["tx_progress_finalizing"] = "Finalizing transaction";
strings_["transaction_sent_msg"] = "Transaction sent!";
strings_["copy_error"] = "Copy Error";
strings_["dismiss"] = "Dismiss";
@@ -509,6 +515,12 @@ void I18n::loadBuiltinEnglish()
// Transactions tab
strings_["no_transactions"] = "No transactions found";
strings_["loading_transactions"] = "Loading transactions";
strings_["tx_loading_queued"] = "Queued transaction refresh";
strings_["tx_loading_enriching_sends"] = "Checking sent transaction details (%d)";
strings_["tx_loading_scanning_shielded"] = "Scanning shielded history (%d addresses)";
strings_["tx_loading_refreshing_cached"] = "Refreshing wallet history (%d cached)";
strings_["tx_loading_fetching_transparent"] = "Fetching transparent history";
strings_["no_matching"] = "No matching transactions";
strings_["transaction_id"] = "TRANSACTION ID";
strings_["search_placeholder"] = "Search...";
@@ -539,6 +551,9 @@ void I18n::loadBuiltinEnglish()
strings_["txs_count"] = "%d txs";
strings_["conf_count"] = "%d conf";
strings_["confirmations_display"] = "%d confirmations | %s";
strings_["mark_mining_address"] = "Mark as mining address";
strings_["unmark_mining_address"] = "Unmark mining address";
strings_["mining_tag"] = " · Mining";
// Balance Tab
strings_["summary"] = "Summary";
@@ -846,11 +861,13 @@ void I18n::loadBuiltinEnglish()
strings_["console_no_daemon"] = "No daemon";
strings_["console_not_connected"] = "Error: Not connected to daemon";
strings_["console_rpc_reference"] = "RPC Command Reference";
strings_["console_rpc_trace"] = "RPC";
strings_["console_search_commands"] = "Search commands...";
strings_["console_select_all"] = "Select All";
strings_["console_show_daemon_output"] = "Show daemon output";
strings_["console_show_errors_only"] = "Show errors only";
strings_["console_show_rpc_ref"] = "Show RPC command reference";
strings_["console_show_rpc_trace"] = "Show app RPC calls";
strings_["console_showing_lines"] = "Showing %zu of %zu lines";
strings_["console_starting_node"] = "Starting node...";
strings_["console_status_error"] = "Error";

View File

@@ -1,4 +1,5 @@
#include "daemon/daemon_controller.h"
#include "data/transaction_history_cache.h"
#include "daemon/lifecycle_adapters.h"
#include "data/wallet_state.h"
#include "rpc/connection.h"
@@ -8,6 +9,7 @@
#include "services/wallet_security_controller.h"
#include "services/wallet_security_workflow.h"
#include "services/wallet_security_workflow_executor.h"
#include "ui/explorer/explorer_block_cache.h"
#include "ui/windows/balance_address_list.h"
#include "ui/windows/balance_recent_tx.h"
#include "ui/windows/console_input_model.h"
@@ -485,13 +487,23 @@ void testRefreshScheduler()
scheduler.markDue(Timer::Peers);
EXPECT_TRUE(scheduler.consumeDue(Timer::Peers));
scheduler.applyPage(dragonx::ui::NavPage::Console);
EXPECT_NEAR(scheduler.intervals().core, 10.0, 0.0001);
EXPECT_NEAR(scheduler.intervals().transactions, 30.0, 0.0001);
EXPECT_NEAR(scheduler.intervals().addresses, 30.0, 0.0001);
EXPECT_NEAR(scheduler.intervals().peers, 0.0, 0.0001);
EXPECT_FALSE(scheduler.isDue(Timer::Price));
scheduler.markDue(Timer::Price);
EXPECT_TRUE(scheduler.consumeDue(Timer::Price));
EXPECT_FALSE(scheduler.shouldRefreshTransactions(100, 100, false, false));
EXPECT_FALSE(scheduler.shouldRefreshTransactions(100, 100, false));
EXPECT_TRUE(scheduler.shouldRefreshTransactions(-1, 100, false));
EXPECT_TRUE(scheduler.shouldRefreshTransactions(99, 100, false));
EXPECT_TRUE(scheduler.shouldRefreshTransactions(100, 100, true));
scheduler.tick(RefreshScheduler::kTxMaxAge);
EXPECT_TRUE(scheduler.shouldRefreshTransactions(100, 100, false, false));
EXPECT_FALSE(scheduler.shouldRefreshTransactions(100, 100, false));
EXPECT_TRUE(scheduler.isDue(Timer::TxAge));
}
void testNetworkRefreshService()
@@ -808,6 +820,15 @@ void testNetworkRefreshRpcCollectors()
EXPECT_TRUE(prefetch.encryption.encrypted);
EXPECT_EQ(prefetch.encryption.unlockedUntil, static_cast<int64_t>(99));
MockRefreshRpc connectionReuseRpc;
connectionReuseRpc.addResponse("getwalletinfo", json{{"unlocked_until", 0}});
auto reusedInfo = Refresh::parseConnectionInfoResult(json{{"version", 120002}, {"blocks", 241}});
auto reusedConnection = Refresh::collectConnectionInitResult(connectionReuseRpc, reusedInfo);
EXPECT_TRUE(connectionReuseRpc.methodNames() == std::vector<std::string>({"getwalletinfo"}));
EXPECT_TRUE(reusedConnection.info.ok);
EXPECT_EQ(*reusedConnection.info.daemonVersion, 120002);
EXPECT_TRUE(reusedConnection.encryption.ok);
MockRefreshRpc coreRpc;
coreRpc.addResponse("z_gettotalbalance", json{
{"private", "3.00000000"},
@@ -817,6 +838,7 @@ void testNetworkRefreshRpcCollectors()
coreRpc.addResponse("getblockchaininfo", json{
{"blocks", 150},
{"headers", 155},
{"bestblockhash", "core-best-150"},
{"verificationprogress", 0.80},
{"longestchain", 160},
{"notarized", 145}
@@ -831,6 +853,7 @@ void testNetworkRefreshRpcCollectors()
EXPECT_TRUE(core.blockchainOk);
EXPECT_NEAR(*core.totalBalance, 4.25, 0.00000001);
EXPECT_EQ(*core.blocks, 150);
EXPECT_EQ(*core.bestBlockHash, std::string("core-best-150"));
EXPECT_EQ(*core.longestChain, 160);
MockRefreshRpc coreFallbackRpc;
@@ -918,6 +941,14 @@ void testNetworkRefreshRpcCollectors()
EXPECT_TRUE(miningPartial.miningOk);
EXPECT_FALSE(*miningPartial.generate);
MockRefreshRpc miningSlowOnlyRpc;
miningSlowOnlyRpc.addResponse("getmininginfo", json{{"generate", false}, {"networkhashps", 222.0}});
auto miningSlowOnly = Refresh::collectMiningRefreshResult(miningSlowOnlyRpc, 7.0, true, false);
EXPECT_TRUE(miningSlowOnlyRpc.methodNames() == std::vector<std::string>({"getmininginfo"}));
EXPECT_FALSE(miningSlowOnly.localHashrate.has_value());
EXPECT_TRUE(miningSlowOnly.miningOk);
EXPECT_NEAR(*miningSlowOnly.networkHashrate, 222.0, 0.00000001);
MockRefreshRpc addressRpc;
addressRpc.addResponse("z_listaddresses", json::array({"zs-one", "zs-two"}));
addressRpc.addResponse("z_validateaddress", json{{"ismine", true}});
@@ -961,6 +992,23 @@ void testNetworkRefreshRpcCollectors()
EXPECT_TRUE(fallbackAddresses.shieldedAddresses[0].has_spending_key);
EXPECT_NEAR(fallbackAddresses.shieldedAddresses[0].balance, 4.75, 0.00000001);
dragonx::WalletState cachedAddressState;
cachedAddressState.z_addresses.push_back({"zs-cached", 0.0, "shielded", false});
auto addressSnapshot = Refresh::buildAddressRefreshSnapshot(cachedAddressState);
MockRefreshRpc cachedAddressRpc;
cachedAddressRpc.addResponse("z_listaddresses", json::array({"zs-cached", "zs-new"}));
cachedAddressRpc.addResponse("z_validateaddress", json{{"ismine", true}});
cachedAddressRpc.addResponse("z_listunspent", json::array());
cachedAddressRpc.addResponse("getaddressesbyaccount", json::array());
cachedAddressRpc.addResponse("listunspent", json::array());
auto cachedAddresses = Refresh::collectAddressRefreshResult(cachedAddressRpc, addressSnapshot);
EXPECT_TRUE(cachedAddressRpc.methodNames() == std::vector<std::string>({
"z_listaddresses", "z_validateaddress", "z_listunspent",
"getaddressesbyaccount", "listunspent"
}));
EXPECT_FALSE(cachedAddresses.shieldedAddresses[0].has_spending_key);
EXPECT_TRUE(cachedAddresses.shieldedAddresses[1].has_spending_key);
Refresh::TransactionRefreshSnapshot snapshot;
snapshot.shieldedAddresses = {"zs-one", "zs-two"};
snapshot.sendTxids = {"cached-send", "pending-send"};
@@ -1075,6 +1123,227 @@ void testNetworkRefreshRpcCollectors()
EXPECT_EQ(placeholderResult.transactions[0].confirmations, 0);
EXPECT_EQ(placeholderResult.transactions[0].address, std::string("zs-placeholder-dest"));
EXPECT_EQ(placeholderResult.newViewTxEntries.count("placeholder-send"), static_cast<size_t>(0));
Refresh::TransactionRefreshSnapshot missingAddressesSnapshot;
missingAddressesSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
"shielded-fallback", "receive", 1.25, 150, 2, "zs-one", "", "memo"
});
MockRefreshRpc missingAddressesRpc;
missingAddressesRpc.addResponse("listtransactions", json::array());
auto missingAddressesResult = Refresh::collectTransactionRefreshResult(
missingAddressesRpc, missingAddressesSnapshot, 325, 4);
EXPECT_TRUE(missingAddressesRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
EXPECT_EQ(missingAddressesResult.transactions.size(), static_cast<size_t>(1));
EXPECT_EQ(missingAddressesResult.transactions[0].txid, std::string("shielded-fallback"));
EXPECT_EQ(missingAddressesResult.transactions[0].type, std::string("receive"));
Refresh::TransactionRefreshSnapshot pendingOpidSnapshot;
pendingOpidSnapshot.pendingOpids = {"opid-visible-send"};
pendingOpidSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
"opid-visible-send", "send", -4.25, 170, 0, "R-destination", "R-source", ""
});
MockRefreshRpc pendingOpidRpc;
pendingOpidRpc.addResponse("listtransactions", json::array());
auto pendingOpidResult = Refresh::collectTransactionRefreshResult(
pendingOpidRpc, pendingOpidSnapshot, 326, 4);
EXPECT_TRUE(pendingOpidRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
EXPECT_EQ(pendingOpidResult.transactions.size(), static_cast<size_t>(1));
EXPECT_EQ(pendingOpidResult.transactions[0].txid, std::string("opid-visible-send"));
EXPECT_EQ(pendingOpidResult.transactions[0].type, std::string("send"));
EXPECT_NEAR(pendingOpidResult.transactions[0].amount, -4.25, 0.00000001);
Refresh::TransactionRefreshSnapshot partialFailureSnapshot;
partialFailureSnapshot.shieldedAddresses = {"zs-one"};
partialFailureSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
"old-receive", "receive", 2.50, 140, 8, "zs-one", "", "old memo"
});
MockRefreshRpc partialFailureRpc;
partialFailureRpc.addResponse("listtransactions", json::array({
json{{"txid", "transparent-b"}, {"category", "receive"}, {"amount", 0.75},
{"time", 160}, {"confirmations", 2}, {"address", "R-two"}}
}));
partialFailureRpc.addFailure("z_listreceivedbyaddress", "temporary receive failure");
auto partialFailureResult = Refresh::collectTransactionRefreshResult(
partialFailureRpc, partialFailureSnapshot, 326, 4);
EXPECT_EQ(partialFailureResult.transactions.size(), static_cast<size_t>(2));
EXPECT_EQ(partialFailureResult.transactions[0].txid, std::string("transparent-b"));
EXPECT_EQ(partialFailureResult.transactions[1].txid, std::string("old-receive"));
Refresh::TransactionRefreshSnapshot pagedSnapshot;
MockRefreshRpc pagedRpc;
json firstPage = json::array();
for (int i = 0; i < 1000; ++i) {
firstPage.push_back(json{{"txid", "paged-" + std::to_string(i)}, {"category", "receive"},
{"amount", 0.01}, {"time", i}, {"confirmations", 10},
{"address", "R-page"}});
}
pagedRpc.addResponse("listtransactions", firstPage);
pagedRpc.addResponse("listtransactions", json::array({
json{{"txid", "paged-1000"}, {"category", "receive"}, {"amount", 0.02},
{"time", 2000}, {"confirmations", 11}, {"address", "R-page"}}
}));
auto pagedResult = Refresh::collectTransactionRefreshResult(pagedRpc, pagedSnapshot, 327, 0);
EXPECT_TRUE(pagedRpc.methodNames() == std::vector<std::string>({"listtransactions", "listtransactions"}));
EXPECT_EQ(pagedRpc.calls[0].params, json::array({"", 1000, 0}));
EXPECT_EQ(pagedRpc.calls[1].params, json::array({"", 1000, 1000}));
EXPECT_EQ(pagedResult.transactions.size(), static_cast<size_t>(1001));
EXPECT_EQ(pagedResult.transactions[0].txid, std::string("paged-1000"));
Refresh::TransactionRefreshSnapshot recentSnapshot;
dragonx::TransactionInfo previousShielded;
previousShielded.txid = "shielded-old";
previousShielded.type = "receive";
previousShielded.address = "zs-one";
previousShielded.amount = 3.0;
previousShielded.timestamp = 10;
dragonx::TransactionInfo previousTransparent;
previousTransparent.txid = "recent-one";
previousTransparent.type = "receive";
previousTransparent.address = "R-one";
previousTransparent.amount = 1.0;
previousTransparent.timestamp = 20;
recentSnapshot.previousTransactions = {previousShielded, previousTransparent};
MockRefreshRpc recentRpc;
recentRpc.addResponse("listtransactions", json::array({
json{{"txid", "recent-one"}, {"category", "receive"}, {"address", "R-one"}, {"amount", 2.0}, {"time", 30}, {"confirmations", 0}},
json{{"txid", "recent-two"}, {"category", "send"}, {"address", "R-two"}, {"amount", -0.5}, {"time", 40}, {"confirmations", 0}}
}));
auto recent = Refresh::collectRecentTransactionRefreshResult(recentRpc, recentSnapshot, 123);
EXPECT_TRUE(recentRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
EXPECT_EQ(recentRpc.calls[0].params, json::array({"", 100, 0}));
EXPECT_EQ(recent.blockHeight, 123);
EXPECT_EQ(recent.transactions.size(), static_cast<size_t>(3));
EXPECT_EQ(recent.transactions[0].txid, std::string("recent-two"));
EXPECT_EQ(recent.transactions[1].txid, std::string("recent-one"));
EXPECT_NEAR(recent.transactions[1].amount, 2.0, 0.00000001);
EXPECT_EQ(recent.transactions[2].txid, std::string("shielded-old"));
Refresh::TransactionRefreshSnapshot recentShieldedProbeSnapshot;
recentShieldedProbeSnapshot.shieldedAddresses = {"zs-probe-a", "zs-probe-b"};
recentShieldedProbeSnapshot.shieldedScanStartIndex = 1;
recentShieldedProbeSnapshot.maxShieldedReceiveScans = 1;
recentShieldedProbeSnapshot.shieldedScanHeights = {{"zs-probe-a", 600}, {"zs-probe-b", 600}};
MockRefreshRpc recentShieldedProbeRpc;
recentShieldedProbeRpc.addResponse("listtransactions", json::array());
recentShieldedProbeRpc.addResponse("z_listreceivedbyaddress", json::array({
json{{"txid", "same-tip-shielded"}, {"amount", 1.75}, {"confirmations", 0}, {"time", 170}}
}));
auto recentShieldedProbe = Refresh::collectRecentTransactionRefreshResult(
recentShieldedProbeRpc, recentShieldedProbeSnapshot, 600);
EXPECT_TRUE(recentShieldedProbeRpc.methodNames() == std::vector<std::string>({
"listtransactions", "z_listreceivedbyaddress"
}));
EXPECT_EQ(recentShieldedProbeRpc.calls[1].params, json::array({"zs-probe-b", 0}));
EXPECT_EQ(recentShieldedProbe.nextShieldedScanStartIndex, static_cast<size_t>(0));
EXPECT_EQ(recentShieldedProbe.shieldedAddressesScanned, static_cast<size_t>(1));
EXPECT_EQ(recentShieldedProbe.shieldedScanHeights.at("zs-probe-b"), 600);
EXPECT_EQ(recentShieldedProbe.transactions.size(), static_cast<size_t>(1));
EXPECT_EQ(recentShieldedProbe.transactions[0].txid, std::string("same-tip-shielded"));
EXPECT_EQ(recentShieldedProbe.transactions[0].confirmations, 0);
Refresh::TransactionRefreshSnapshot partialShieldedSnapshot;
partialShieldedSnapshot.shieldedAddresses = {"zs-zero", "zs-one", "zs-two"};
partialShieldedSnapshot.maxShieldedReceiveScans = 2;
partialShieldedSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
"old-zs-two", "receive", 1.0, 90, 10, "zs-two", "", "memo"
});
MockRefreshRpc partialShieldedRpc;
partialShieldedRpc.addResponse("listtransactions", json::array());
partialShieldedRpc.addResponse("z_listreceivedbyaddress", json::array({
json{{"txid", "new-zs-zero"}, {"amount", 0.5}, {"confirmations", 1}, {"time", 100}}
}));
partialShieldedRpc.addResponse("z_listreceivedbyaddress", json::array());
auto partialShielded = Refresh::collectTransactionRefreshResult(
partialShieldedRpc, partialShieldedSnapshot, 400, 0);
EXPECT_TRUE(partialShieldedRpc.methodNames() == std::vector<std::string>({
"listtransactions", "z_listreceivedbyaddress", "z_listreceivedbyaddress"
}));
EXPECT_EQ(partialShieldedRpc.calls[1].params, json::array({"zs-zero", 0}));
EXPECT_EQ(partialShieldedRpc.calls[2].params, json::array({"zs-one", 0}));
EXPECT_FALSE(partialShielded.shieldedScanComplete);
EXPECT_EQ(partialShielded.nextShieldedScanStartIndex, static_cast<size_t>(2));
EXPECT_EQ(partialShielded.shieldedAddressesScanned, static_cast<size_t>(2));
EXPECT_EQ(partialShielded.transactions.size(), static_cast<size_t>(2));
EXPECT_EQ(partialShielded.transactions[0].txid, std::string("new-zs-zero"));
EXPECT_EQ(partialShielded.transactions[1].txid, std::string("old-zs-two"));
partialShieldedSnapshot.shieldedScanStartIndex = partialShielded.nextShieldedScanStartIndex;
partialShieldedSnapshot.shieldedScanHeights = partialShielded.shieldedScanHeights;
MockRefreshRpc finalShieldedRpc;
finalShieldedRpc.addResponse("listtransactions", json::array());
finalShieldedRpc.addResponse("z_listreceivedbyaddress", json::array());
auto finalShielded = Refresh::collectTransactionRefreshResult(
finalShieldedRpc, partialShieldedSnapshot, 400, 0);
EXPECT_TRUE(finalShieldedRpc.methodNames() == std::vector<std::string>({
"listtransactions", "z_listreceivedbyaddress"
}));
EXPECT_EQ(finalShieldedRpc.calls[1].params, json::array({"zs-two", 0}));
EXPECT_TRUE(finalShielded.shieldedScanComplete);
EXPECT_EQ(finalShielded.nextShieldedScanStartIndex, static_cast<size_t>(0));
EXPECT_EQ(finalShielded.shieldedScanHeights.at("zs-zero"), 400);
EXPECT_EQ(finalShielded.shieldedScanHeights.at("zs-one"), 400);
EXPECT_EQ(finalShielded.shieldedScanHeights.at("zs-two"), 400);
Refresh::TransactionRefreshSnapshot cachedShieldedSnapshot;
cachedShieldedSnapshot.shieldedAddresses = {"zs-cached"};
cachedShieldedSnapshot.shieldedScanHeights = {{"zs-cached", 500}};
cachedShieldedSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
"cached-shielded", "receive", 1.5, 120, 20, "zs-cached", "", "cached memo"
});
MockRefreshRpc cachedShieldedRpc;
cachedShieldedRpc.addResponse("listtransactions", json::array());
auto cachedShielded = Refresh::collectTransactionRefreshResult(
cachedShieldedRpc, cachedShieldedSnapshot, 500, 0);
EXPECT_TRUE(cachedShieldedRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
EXPECT_TRUE(cachedShielded.shieldedScanComplete);
EXPECT_EQ(cachedShielded.shieldedAddressesScanned, static_cast<size_t>(0));
EXPECT_EQ(cachedShielded.transactions.size(), static_cast<size_t>(1));
EXPECT_EQ(cachedShielded.transactions[0].txid, std::string("cached-shielded"));
Refresh::TransactionRefreshSnapshot staleProgressSnapshot;
staleProgressSnapshot.shieldedAddresses = {"zs-current", "zs-stale", "zs-missing"};
staleProgressSnapshot.shieldedScanHeights = {{"zs-current", 500}, {"zs-stale", 499}};
staleProgressSnapshot.maxShieldedReceiveScans = 1;
staleProgressSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
"current-shielded", "receive", 0.75, 110, 12, "zs-current", "", ""
});
MockRefreshRpc staleProgressRpc;
staleProgressRpc.addResponse("listtransactions", json::array());
staleProgressRpc.addResponse("z_listreceivedbyaddress", json::array({
json{{"txid", "stale-shielded"}, {"amount", 2.0}, {"confirmations", 1}, {"time", 130}}
}));
auto staleProgress = Refresh::collectTransactionRefreshResult(
staleProgressRpc, staleProgressSnapshot, 500, 0);
EXPECT_TRUE(staleProgressRpc.methodNames() == std::vector<std::string>({
"listtransactions", "z_listreceivedbyaddress"
}));
EXPECT_EQ(staleProgressRpc.calls[1].params, json::array({"zs-stale", 0}));
EXPECT_FALSE(staleProgress.shieldedScanComplete);
EXPECT_EQ(staleProgress.nextShieldedScanStartIndex, static_cast<size_t>(2));
EXPECT_EQ(staleProgress.shieldedAddressesScanned, static_cast<size_t>(1));
EXPECT_EQ(staleProgress.shieldedScanHeights.at("zs-current"), 500);
EXPECT_EQ(staleProgress.shieldedScanHeights.at("zs-stale"), 500);
EXPECT_TRUE(staleProgress.shieldedScanHeights.find("zs-missing") == staleProgress.shieldedScanHeights.end());
EXPECT_EQ(staleProgress.transactions.size(), static_cast<size_t>(2));
EXPECT_EQ(staleProgress.transactions[0].txid, std::string("stale-shielded"));
EXPECT_EQ(staleProgress.transactions[1].txid, std::string("current-shielded"));
Refresh::TransactionRefreshSnapshot miningSnapshot;
miningSnapshot.shieldedAddresses = {"zs-mine"};
miningSnapshot.miningAddresses = {"R-mine", "zs-mine"};
MockRefreshRpc miningTxRpc;
miningTxRpc.addResponse("listtransactions", json::array({
json{{"txid", "transparent-mined"}, {"category", "receive"}, {"amount", 3.0},
{"time", 220}, {"confirmations", 101}, {"address", "R-mine"}}
}));
miningTxRpc.addResponse("z_listreceivedbyaddress", json::array({
json{{"txid", "shielded-mined"}, {"amount", 4.0}, {"confirmations", 102},
{"time", 210}, {"memoStr", "pool"}}
}));
auto miningTxResult = Refresh::collectTransactionRefreshResult(miningTxRpc, miningSnapshot, 328, 0);
EXPECT_EQ(miningTxResult.transactions.size(), static_cast<size_t>(2));
EXPECT_EQ(miningTxResult.transactions[0].type, std::string("mined"));
EXPECT_EQ(miningTxResult.transactions[1].type, std::string("mined"));
}
void testNetworkRefreshResultModels()
@@ -1087,7 +1356,7 @@ void testNetworkRefreshResultModels()
auto core = Refresh::parseCoreRefreshResult(
json{{"private", "1.25000000"}, {"transparent", "0.50000000"}, {"total", "1.75000000"}},
true,
json{{"blocks", 100}, {"headers", 105}, {"verificationprogress", 0.75},
json{{"blocks", 100}, {"headers", 105}, {"bestblockhash", "apply-best-100"}, {"verificationprogress", 0.75},
{"longestchain", 110}, {"notarized", 90}},
true);
Refresh::applyCoreRefreshResult(state, core, 1234);
@@ -1096,6 +1365,7 @@ void testNetworkRefreshResultModels()
EXPECT_NEAR(state.total_balance, 1.75, 0.00000001);
EXPECT_EQ(state.sync.blocks, 100);
EXPECT_EQ(state.sync.headers, 105);
EXPECT_EQ(state.sync.best_blockhash, std::string("apply-best-100"));
EXPECT_TRUE(state.sync.syncing);
EXPECT_EQ(state.longestchain, 110);
EXPECT_EQ(state.notarized, 90);
@@ -1117,7 +1387,6 @@ void testNetworkRefreshResultModels()
EXPECT_TRUE(state.encrypted);
EXPECT_TRUE(state.locked);
EXPECT_TRUE(state.encryption_state_known);
auto unencrypted = Refresh::parseWalletEncryptionResult(json::object());
Refresh::applyWalletEncryptionResult(state, unencrypted);
EXPECT_FALSE(state.encrypted);
@@ -1297,6 +1566,36 @@ void testNetworkRefreshResultModels()
EXPECT_EQ(confirmedBlock, 222);
}
void testOperationStatusPollParsing()
{
using Refresh = dragonx::services::NetworkRefreshService;
using nlohmann::json;
auto parsed = Refresh::parseOperationStatusPoll(json::array({
json{{"id", "op-success"}, {"status", "success"}, {"result", json{{"txid", "tx-success"}}}},
json{{"id", "op-failed"}, {"status", "failed"}, {"error", json{{"message", "bad memo"}}}},
json{{"id", "op-running"}, {"status", "executing"}}
}), {"op-success", "op-failed", "op-running", "op-stale"});
EXPECT_TRUE(parsed.anySuccess);
EXPECT_EQ(parsed.doneOpids.size(), static_cast<size_t>(2));
EXPECT_EQ(parsed.doneOpids[0], std::string("op-success"));
EXPECT_EQ(parsed.doneOpids[1], std::string("op-failed"));
EXPECT_EQ(parsed.successTxids.size(), static_cast<size_t>(1));
EXPECT_EQ(parsed.successTxids[0], std::string("tx-success"));
EXPECT_EQ(parsed.successTxidsByOpid.size(), static_cast<size_t>(1));
EXPECT_EQ(parsed.successTxidsByOpid.at("op-success"), std::string("tx-success"));
EXPECT_EQ(parsed.failureMessages.size(), static_cast<size_t>(1));
EXPECT_EQ(parsed.failureMessages[0], std::string("bad memo"));
EXPECT_EQ(parsed.staleOpids.size(), static_cast<size_t>(1));
EXPECT_EQ(parsed.staleOpids[0], std::string("op-stale"));
auto malformed = Refresh::parseOperationStatusPoll(json{{"status", "success"}}, {"op-keep"});
EXPECT_FALSE(malformed.anySuccess);
EXPECT_TRUE(malformed.doneOpids.empty());
EXPECT_TRUE(malformed.staleOpids.empty());
}
void testWalletSecurityController()
{
using dragonx::services::WalletSecurityController;
@@ -1677,9 +1976,13 @@ void testRendererHelpers()
EXPECT_FALSE(dragonx::ui::IsPoolMiningActive(true, false, true));
EXPECT_TRUE(dragonx::ui::IsPoolMiningActive(false, false, true));
dragonx::ui::ConsoleOutputFilter filter{"error", false, false, 10, 20};
dragonx::ui::ConsoleOutputFilter filter{"error", false, false, false, 10, 20, 30};
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("RPC Error", 20, filter));
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("daemon line", 10, filter));
filter.text.clear();
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("[rpc] History -> listtransactions", 30, filter));
filter.rpcTraceEnabled = true;
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("[rpc] History -> listtransactions", 30, filter));
filter.daemonMessagesEnabled = true;
filter.errorsOnly = true;
filter.text.clear();
@@ -1838,9 +2141,9 @@ void testBalanceAddressListModel()
addresses.push_back(tFav);
std::vector<dragonx::ui::AddressListInput> inputs = {
{&addresses[0], true, false, false, "Main Vault", "", -1},
{&addresses[1], false, false, false, "Empty", "", -1},
{&addresses[2], false, false, true, "Favorite", "", -1}
{&addresses[0], true, false, false, false, "Main Vault", "", -1},
{&addresses[1], false, false, false, false, "Empty", "", -1},
{&addresses[2], false, false, true, false, "Favorite", "", -1}
};
auto rows = dragonx::ui::BuildAddressListRows(inputs, "", true, false);
@@ -1869,6 +2172,255 @@ void testBalanceAddressListModel()
EXPECT_EQ(dragonx::ui::FormatAddressUsdValue(0.0, 2.0), std::string(""));
}
void testExplorerBlockCache()
{
using dragonx::ui::ExplorerBlockCache;
using dragonx::ui::ExplorerBlockSummary;
using nlohmann::json;
fs::path dir = makeTempDir();
fs::path databasePath = dir / "explorer_blocks.sqlite";
fs::path legacyPath = dir / "explorer_blocks_cache.json";
json legacy = {
{"version", 1},
{"tip_height", 10},
{"tip_hash", "hash-10"},
{"blocks", json::array({
json{{"height", 10}, {"hash", "hash-10"}, {"tx_count", 3}, {"size", 1000}, {"time", 5000}, {"difficulty", 1.25}},
json{{"height", 9}, {"hash", "hash-9"}, {"tx_count", 2}, {"size", 900}, {"time", 4900}, {"difficulty", 1.20}}
})}
};
{
std::ofstream file(legacyPath);
file << legacy.dump(2);
}
ExplorerBlockCache cache(databasePath.string(), legacyPath.string());
EXPECT_TRUE(cache.ensureOpen());
EXPECT_EQ(cache.cachedBlockCount(), 2);
auto range = cache.loadRange(9, 10);
EXPECT_EQ(range.size(), static_cast<size_t>(2));
EXPECT_EQ(range[10].hash, std::string("hash-10"));
EXPECT_EQ(range[9].tx_count, 2);
auto sameTipValidation = cache.prepareValidation(10, "hash-10");
EXPECT_FALSE(sameTipValidation.needed);
auto advancedValidation = cache.prepareValidation(12, "hash-12");
EXPECT_TRUE(advancedValidation.needed);
EXPECT_EQ(advancedValidation.height, 10);
EXPECT_EQ(advancedValidation.expectedHash, std::string("hash-10"));
cache.applySavedTipValidation(advancedValidation, "hash-10", 12, "hash-12");
ExplorerBlockSummary block12;
block12.height = 12;
block12.hash = "hash-12";
block12.tx_count = 5;
block12.size = 1200;
block12.time = 5200;
block12.difficulty = 1.40;
EXPECT_TRUE(cache.storeBlock(block12));
EXPECT_EQ(cache.loadRange(12, 12)[12].tx_count, 5);
{
ExplorerBlockCache reopened(databasePath.string(), legacyPath.string());
EXPECT_TRUE(reopened.ensureOpen());
EXPECT_EQ(reopened.cachedBlockCount(), 3);
auto noRepeatValidation = reopened.prepareValidation(12, "hash-12");
EXPECT_FALSE(noRepeatValidation.needed);
}
cache.prepareValidation(12, "different-tip");
EXPECT_EQ(cache.cachedBlockCount(), 0);
{
ExplorerBlockCache reopened(databasePath.string(), legacyPath.string());
EXPECT_TRUE(reopened.ensureOpen());
EXPECT_EQ(reopened.cachedBlockCount(), 0);
}
fs::remove_all(dir);
}
void testTransactionHistoryCache()
{
using dragonx::TransactionInfo;
using dragonx::data::TransactionHistoryCache;
std::string identityA = TransactionHistoryCache::walletIdentityFromAddresses(
{"zs-beta", "zs-alpha"}, {"R-one"});
std::string identityB = TransactionHistoryCache::walletIdentityFromAddresses(
{"zs-alpha", "zs-beta"}, {"R-one"});
std::string identityWithSameText = TransactionHistoryCache::walletIdentityFromAddresses(
{"same-address-text"}, {"same-address-text"});
EXPECT_EQ(identityA, identityB);
EXPECT_TRUE(identityA.find("protocol=") == std::string::npos);
EXPECT_TRUE(identityA.find("p2p_port=") == std::string::npos);
EXPECT_TRUE(identityA.find("z:zs-alpha") != std::string::npos);
EXPECT_TRUE(identityA.find("t:R-one") != std::string::npos);
EXPECT_TRUE(identityWithSameText.find("z:same-address-text") != std::string::npos);
EXPECT_TRUE(identityWithSameText.find("t:same-address-text") != std::string::npos);
fs::path dir = makeTempDir();
fs::path databasePath = dir / "transaction_history.sqlite";
std::string walletIdentity = "mainnet|R-alpha|zs-beta";
std::string passphrase = "correct horse battery staple";
std::vector<TransactionInfo> transactions;
transactions.push_back(TransactionInfo{
"tx-sensitive-send", "send", -1.25, 1700000100, 12,
"zs-destination-sensitive", "R-source-sensitive", "private memo"
});
transactions.push_back(TransactionInfo{
"tx-mined", "mined", 3.5, 1700000000, 104,
"R-mining-address", "", ""
});
std::unordered_map<std::string, int> shieldedScanHeights{
{"zs-beta", 120},
{"zs-archive", 118}
};
{
TransactionHistoryCache cache(databasePath.string());
EXPECT_TRUE(cache.ensureOpen());
EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
EXPECT_TRUE(cache.replace(walletIdentity, 120, "tip-120", transactions, 1700000200, shieldedScanHeights));
EXPECT_EQ(cache.snapshotCount(), 1);
}
{
std::ifstream database(databasePath, std::ios::binary);
std::string bytes((std::istreambuf_iterator<char>(database)), std::istreambuf_iterator<char>());
EXPECT_TRUE(bytes.find("tx-sensitive-send") == std::string::npos);
EXPECT_TRUE(bytes.find("zs-destination-sensitive") == std::string::npos);
EXPECT_TRUE(bytes.find("zs-archive") == std::string::npos);
EXPECT_TRUE(bytes.find("private memo") == std::string::npos);
}
{
TransactionHistoryCache wrongKey(databasePath.string());
EXPECT_TRUE(wrongKey.unlockWithPassphrase(walletIdentity, "wrong passphrase"));
auto wrongLoad = wrongKey.load(walletIdentity, 120, "tip-120");
EXPECT_FALSE(wrongLoad.loaded);
}
{
TransactionHistoryCache reopened(databasePath.string());
EXPECT_TRUE(reopened.unlockWithPassphrase(walletIdentity, passphrase));
auto loaded = reopened.load(walletIdentity, 120, "tip-120");
EXPECT_TRUE(loaded.loaded);
EXPECT_FALSE(loaded.invalidated);
EXPECT_EQ(loaded.tipHeight, 120);
EXPECT_EQ(loaded.tipHash, std::string("tip-120"));
EXPECT_EQ(loaded.updatedAt, static_cast<std::time_t>(1700000200));
EXPECT_EQ(loaded.transactions.size(), static_cast<size_t>(2));
EXPECT_EQ(loaded.transactions[0].txid, std::string("tx-sensitive-send"));
EXPECT_EQ(loaded.transactions[0].memo, std::string("private memo"));
EXPECT_EQ(loaded.transactions[1].type, std::string("mined"));
EXPECT_EQ(loaded.shieldedScanHeights.size(), static_cast<size_t>(2));
EXPECT_EQ(loaded.shieldedScanHeights.at("zs-beta"), 120);
EXPECT_EQ(loaded.shieldedScanHeights.at("zs-archive"), 118);
}
{
TransactionHistoryCache cache(databasePath.string());
EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
EXPECT_TRUE(cache.replace(walletIdentity, 120, "tip-120", transactions, 1700000250));
auto loaded = cache.load(walletIdentity, 120, "tip-120");
EXPECT_TRUE(loaded.loaded);
EXPECT_EQ(loaded.shieldedScanHeights.size(), static_cast<size_t>(0));
}
{
TransactionHistoryCache staleTip(databasePath.string());
EXPECT_TRUE(staleTip.unlockWithPassphrase(walletIdentity, passphrase));
auto invalidated = staleTip.load(walletIdentity, 119, "tip-119");
EXPECT_FALSE(invalidated.loaded);
EXPECT_TRUE(invalidated.invalidated);
EXPECT_EQ(staleTip.snapshotCount(), 0);
}
{
TransactionHistoryCache cache(databasePath.string());
EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
EXPECT_TRUE(cache.replace(walletIdentity, 120, "tip-120", transactions, 1700000300));
auto invalidated = cache.load(walletIdentity, 120, "different-tip");
EXPECT_FALSE(invalidated.loaded);
EXPECT_TRUE(invalidated.invalidated);
EXPECT_EQ(cache.snapshotCount(), 0);
}
fs::remove_all(dir);
}
void testTransactionHistoryCacheRefreshApply()
{
using Refresh = dragonx::services::NetworkRefreshService;
using dragonx::TransactionInfo;
using dragonx::data::TransactionHistoryCache;
fs::path dir = makeTempDir();
fs::path databasePath = dir / "transaction_history_refresh.sqlite";
std::string walletIdentity = "mainnet|R-refresh-cache|zs-refresh-cache";
std::string passphrase = "refresh cache passphrase";
dragonx::WalletState state;
state.sync.blocks = 333;
state.sync.best_blockhash = "tip-333";
Refresh::TransactionRefreshResult refreshResult;
refreshResult.blockHeight = 333;
TransactionInfo refreshedTx;
refreshedTx.txid = "rpc-refreshed-send";
refreshedTx.type = "send";
refreshedTx.amount = -2.75;
refreshedTx.timestamp = 1700000400;
refreshedTx.confirmations = 14;
refreshedTx.address = "zs-rpc-destination";
refreshedTx.from_address = "R-refresh-cache";
refreshedTx.memo = "rpc refreshed memo";
refreshResult.transactions = {refreshedTx};
Refresh::TransactionViewCache viewCache;
std::unordered_set<std::string> sendTxids;
std::vector<TransactionInfo> confirmedCache;
std::unordered_set<std::string> confirmedIds;
int confirmedBlock = -1;
int lastTxBlock = -1;
Refresh::TransactionCacheUpdate cacheUpdate{
viewCache,
sendTxids,
confirmedCache,
confirmedIds,
confirmedBlock,
lastTxBlock
};
Refresh::applyTransactionRefreshResult(state, cacheUpdate, std::move(refreshResult), 1700000500);
TransactionHistoryCache cache(databasePath.string());
EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
EXPECT_TRUE(cache.replace(walletIdentity,
state.sync.blocks,
state.sync.best_blockhash,
state.transactions,
state.last_tx_update));
TransactionHistoryCache reopened(databasePath.string());
EXPECT_TRUE(reopened.unlockWithPassphrase(walletIdentity, passphrase));
auto loaded = reopened.load(walletIdentity, 333, "tip-333");
EXPECT_TRUE(loaded.loaded);
EXPECT_EQ(loaded.transactions.size(), static_cast<size_t>(1));
EXPECT_EQ(loaded.transactions[0].txid, std::string("rpc-refreshed-send"));
EXPECT_EQ(loaded.transactions[0].memo, std::string("rpc refreshed memo"));
EXPECT_EQ(loaded.updatedAt, static_cast<std::time_t>(1700000500));
EXPECT_EQ(lastTxBlock, 333);
EXPECT_EQ(confirmedIds.count("rpc-refreshed-send"), static_cast<size_t>(1));
fs::remove_all(dir);
}
void testGeneratedResourceBehavior()
{
const auto* themes = dragonx::resources::getEmbeddedThemes();
@@ -1893,6 +2445,7 @@ int main()
testNetworkRefreshSnapshotHelpers();
testNetworkRefreshRpcCollectors();
testNetworkRefreshResultModels();
testOperationStatusPollParsing();
testWalletSecurityController();
testWalletSecurityWorkflow();
testWalletSecurityWorkflowExecutor();
@@ -1903,6 +2456,9 @@ int main()
testConsoleInputModel();
testMiningBenchmarkModel();
testBalanceAddressListModel();
testExplorerBlockCache();
testTransactionHistoryCache();
testTransactionHistoryCacheRefreshApply();
testGeneratedResourceBehavior();
if (g_failures != 0) {