diff --git a/CMakeLists.txt b/CMakeLists.txt index a5958ef..7cb43c7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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} ) diff --git a/src/app.cpp b/src/app.cpp index b8d06b7..90b00f2 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -80,6 +80,7 @@ #include #include #include +#include #include #include #include @@ -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 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(); - // 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 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()); - } - } - return [this, done, anySuccess, - successTxids = std::move(successTxids)]() { - for (const auto& id : done) { + std::vector 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 successfulOpids; + for (const auto& [opid, txid] : parsed.successTxidsByOpid) { + successfulOpids.insert(opid); + markPendingSendTransactionSucceeded(opid, txid); send_txids_.insert(txid); } + std::vector successOpids; + std::vector 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( diff --git a/src/app.h b/src/app.h index 58cb771..a3444e0 100644 --- a/src/app.h +++ b/src/app.h @@ -12,6 +12,7 @@ #include #include #include +#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 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& 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_; @@ -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 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 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 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 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(); }; diff --git a/src/app_network.cpp b/src/app_network.cpp index 9714d5b..ea27c42 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -43,9 +43,13 @@ #include "util/platform.h" #include "util/perf_log.h" #include "util/i18n.h" +#include "util/secure_vault.h" #include #include +#include +#include +#include #include #include @@ -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::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& 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::time(nullptr)); + } + state_.last_tx_update = std::time(nullptr); +} + +void App::removePendingSendTransactions(const std::vector& opids, + bool restoreBalances) +{ + if (opids.empty()) return; + std::unordered_set 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& 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& 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 shieldedAddresses; + std::vector 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 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 pendingOpids(pending_opids_.begin(), pending_opids_.end()); + std::vector 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(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 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(); } catch (const std::exception& e) { @@ -1122,6 +1624,7 @@ void App::createNewTAddress(std::function 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(); } 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::functionz_exportKey(address, [callback](const json& result) { if (callback) callback(result.get()); }, [callback](const std::string& error) { @@ -1270,6 +1798,7 @@ void App::exportPrivateKey(const std::string& address, std::functiondumpPrivKey(address, [callback](const json& result) { if (callback) callback(result.get()); }, [callback](const std::string& error) { @@ -1339,7 +1868,9 @@ void App::importPrivateKey(const std::string& key, std::functionz_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::functionimportPrivKey(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(); - 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(); + 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 diff --git a/src/app_security.cpp b/src/app_security.cpp index a56431e..8f85959 100644 --- a/src/app_security.cpp +++ b/src/app_security.cpp @@ -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; - 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(); 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 (...) {} diff --git a/src/config/settings.cpp b/src/config/settings.cpp index e9296e6..f030bcd 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -136,6 +136,8 @@ bool Settings::load(const std::string& path) m.icon = meta["icon"].get(); if (meta.contains("order") && meta["order"].is_number_integer()) m.sortOrder = meta["order"].get(); + if (meta.contains("mining") && meta["mining"].is_boolean()) + m.mining = meta["mining"].get(); 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; diff --git a/src/config/settings.h b/src/config/settings.h index 73130dd..a2132aa 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -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 getMiningAddresses() const { + std::set 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_) diff --git a/src/data/transaction_history_cache.cpp b/src/data/transaction_history_cache.cpp new file mode 100644 index 0000000..20f7579 --- /dev/null +++ b/src/data/transaction_history_cache.cpp @@ -0,0 +1,510 @@ +#include "transaction_history_cache.h" + +#include "../util/logger.h" +#include "../util/platform.h" + +#include +#include +#include + +#include +#include +#include +#include + +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& value) +{ + return sqlite3_bind_blob(statement, index, value.data(), static_cast(value.size()), SQLITE_TRANSIENT) == SQLITE_OK; +} + +std::vector 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(data); + return std::vector(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(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& shieldedAddresses, + const std::vector& transparentAddresses) +{ + std::vector 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(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 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 nonce; + std::vector 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(); + } + } + } + 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& transactions, + std::time_t updatedAt, + const std::unordered_map& 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(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 nonce; + std::vector 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(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 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 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& 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& nonce, + std::vector& 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(plainText.data()), plainText.size(), + reinterpret_cast(associatedData.data()), associatedData.size(), + nullptr, nonce.data(), key_.data()); + if (result != 0) return false; + cipherText.resize(static_cast(cipherLength)); + return true; +} + +bool TransactionHistoryCache::decryptPayload(const std::string& walletHash, + const std::vector& nonce, + const std::vector& 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 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(associatedData.data()), associatedData.size(), + nonce.data(), key_.data()); + if (result != 0) return false; + + plainText.assign(reinterpret_cast(plain.data()), static_cast(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& nonce, + std::vector& 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(tipHashText) : std::string(); + updatedAt = static_cast(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 diff --git a/src/data/transaction_history_cache.h b/src/data/transaction_history_cache.h new file mode 100644 index 0000000..bc486e1 --- /dev/null +++ b/src/data/transaction_history_cache.h @@ -0,0 +1,90 @@ +#pragma once + +#include "wallet_state.h" + +#include +#include +#include +#include +#include +#include + +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 transactions; + std::unordered_map 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& shieldedAddresses, + const std::vector& 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& transactions, + std::time_t updatedAt, + const std::unordered_map& shieldedScanHeights = {}); + void clearWallet(const std::string& walletIdentity); + int snapshotCount(); + +private: + bool exec(const char* sql); + bool createSchema(); + std::vector getOrCreateSalt(const std::string& walletHash); + bool deriveKey(const std::string& passphrase, + const std::vector& salt); + bool encryptPayload(const std::string& walletHash, + const std::string& plainText, + std::vector& nonce, + std::vector& cipherText) const; + bool decryptPayload(const std::string& walletHash, + const std::vector& nonce, + const std::vector& cipherText, + std::string& plainText) const; + bool readSnapshot(const std::string& walletHash, + int& tipHeight, + std::string& tipHash, + std::time_t& updatedAt, + std::vector& nonce, + std::vector& cipherText); + void clearWalletByHash(const std::string& walletHash); + void close(); + + sqlite3* db_ = nullptr; + std::string database_path_; + std::array key_{}; + bool key_ready_ = false; + std::string unlocked_wallet_hash_; +}; + +} // namespace data +} // namespace dragonx diff --git a/src/main.cpp b/src/main.cpp index 95a2254..55b4a31 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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(); diff --git a/src/rpc/rpc_client.cpp b/src/rpc/rpc_client.cpp index 2c53c56..1f18433 100644 --- a/src/rpc/rpc_client.cpp +++ b/src/rpc/rpc_client.cpp @@ -10,13 +10,76 @@ #include "../util/base64.h" #include +#include #include #include +#include #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 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 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 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()); 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 lk(curl_mutex_); + return last_connect_info_; +} + void RPCClient::disconnect() { std::lock_guard 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; diff --git a/src/rpc/rpc_client.h b/src/rpc/rpc_client.h index 36f186a..69baaff 100644 --- a/src/rpc/rpc_client.h +++ b/src/rpc/rpc_client.h @@ -25,6 +25,20 @@ using ErrorCallback = std::function; */ class RPCClient { public: + using TraceCallback = std::function; + + 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) diff --git a/src/services/network_refresh_service.cpp b/src/services/network_refresh_service.cpp index 2421cb6..da8806a 100644 --- a/src/services/network_refresh_service.cpp +++ b/src/services/network_refresh_service.cpp @@ -118,6 +118,24 @@ void appendTrackedSendPlaceholder(std::vector& transactions, transactions.push_back(std::move(info)); } +void appendMissingPreviousTransactions(std::vector& transactions, + const std::vector& previousTransactions, + bool includeAll, + const std::unordered_set& 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& 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(blockInfo, "blocks"); result.headers = readOptional(blockInfo, "headers"); + result.bestBlockHash = readOptional(blockInfo, "bestblockhash"); result.verificationProgress = readOptional(blockInfo, "verificationprogress"); result.longestChain = readOptional(blockInfo, "longestchain"); result.notarized = readOptional(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::vectorsecond; + 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& transactions, std::set& knownTxids, - const json& result) + const json& result, + const std::set& miningAddresses) { if (!result.is_array()) return; @@ -552,6 +602,9 @@ void NetworkRefreshService::appendTransparentTransactions(std::vector(transactionJson, "confirmations")) info.confirmations = *value; if (auto value = readOptional(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& transactions, std::set& knownTxids, const std::string& address, - const json& received) + const json& received, + const std::set& miningAddresses) { if (received.is_null() || !received.is_array()) return; @@ -582,7 +636,7 @@ void NetworkRefreshService::appendShieldedReceivedTransactions(std::vector(note, "amount")) info.amount = *value; if (auto value = readOptional(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 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(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 { + 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 recentTxids; + std::vector 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 scannedTransactions; + std::set 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& requestedOpids) +{ + OperationStatusPollResult parsed; + if (!result.is_array()) return parsed; + + std::set 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(); + 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(); + } 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); } diff --git a/src/services/network_refresh_service.h b/src/services/network_refresh_service.h index 62cead8..38d7337 100644 --- a/src/services/network_refresh_service.h +++ b/src/services/network_refresh_service.h @@ -103,6 +103,7 @@ public: bool blockchainOk = false; std::optional blocks; std::optional headers; + std::optional bestBlockHash; std::optional verificationProgress; std::optional longestChain; std::optional notarized; @@ -146,6 +147,10 @@ public: std::vector transparentAddresses; }; + struct AddressRefreshSnapshot { + std::unordered_map shieldedSpendingKeys; + }; + struct TransactionViewCacheEntry { std::string from_address; std::int64_t timestamp = 0; @@ -165,12 +170,32 @@ public: std::unordered_set fullyEnrichedTxids; TransactionViewCache viewTxCache; std::unordered_set sendTxids; + std::unordered_set pendingOpids; + std::vector previousTransactions; + std::set miningAddresses; + std::unordered_map shieldedScanHeights; + std::size_t shieldedScanStartIndex = 0; + std::size_t maxShieldedReceiveScans = 0; }; struct TransactionRefreshResult { std::vector transactions; int blockHeight = -1; TransactionViewCache newViewTxEntries; + std::size_t nextShieldedScanStartIndex = 0; + std::size_t shieldedAddressesScanned = 0; + std::size_t shieldedAddressCount = 0; + std::unordered_map shieldedScanHeights; + bool shieldedScanComplete = true; + }; + + struct OperationStatusPollResult { + std::vector doneOpids; + std::vector staleOpids; + std::vector successTxids; + std::unordered_map successTxidsByOpid; + std::vector 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& 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& 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& sendTxids); static void appendTransparentTransactions(std::vector& transactions, std::set& knownTxids, - const nlohmann::json& result); + const nlohmann::json& result, + const std::set& miningAddresses = {}); static void appendShieldedReceivedTransactions(std::vector& transactions, std::set& knownTxids, const std::string& address, - const nlohmann::json& received); + const nlohmann::json& received, + const std::set& miningAddresses = {}); static TransactionViewCacheEntry parseViewTransactionCacheEntry(const nlohmann::json& viewTransaction); static void appendViewTransactionOutputs(std::vector& 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& 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(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(); diff --git a/src/services/refresh_scheduler.cpp b/src/services/refresh_scheduler.cpp index b19ebfd..b7ad46d 100644 --- a/src/services/refresh_scheduler.cpp +++ b/src/services/refresh_scheduler.cpp @@ -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) diff --git a/src/services/refresh_scheduler.h b/src/services/refresh_scheduler.h index e222f51..28367a7 100644 --- a/src/services/refresh_scheduler.h +++ b/src/services/refresh_scheduler.h @@ -55,7 +55,6 @@ public: void resetTxAge(); bool shouldRefreshTransactions(int lastTxBlockHeight, int currentBlockHeight, - bool transactionsEmpty, bool transactionsDirty) const; private: diff --git a/src/ui/explorer/explorer_block_cache.cpp b/src/ui/explorer/explorer_block_cache.cpp new file mode 100644 index 0000000..1333fa5 --- /dev/null +++ b/src/ui/explorer/explorer_block_cache.cpp @@ -0,0 +1,362 @@ +#include "explorer_block_cache.h" + +#include "../../util/logger.h" +#include "../../util/platform.h" + +#include +#include + +#include +#include +#include +#include + +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(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 ExplorerBlockCache::loadRange(int minHeight, int maxHeight) +{ + std::map 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(hashText); + block.tx_count = sqlite3_column_int(statement.handle, 2); + block.size = sqlite3_column_int(statement.handle, 3); + block.time = static_cast(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(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(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 diff --git a/src/ui/explorer/explorer_block_cache.h b/src/ui/explorer/explorer_block_cache.h new file mode 100644 index 0000000..8354af1 --- /dev/null +++ b/src/ui/explorer/explorer_block_cache.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include + +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 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 diff --git a/src/ui/layout.h b/src/ui/layout.h index d40f379..4c0169e 100644 --- a/src/ui/layout.h +++ b/src/ui/layout.h @@ -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(); diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index 64a8e5f..3ce7c83 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -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"); diff --git a/src/ui/windows/address_transfer_dialog.h b/src/ui/windows/address_transfer_dialog.h index ea7eaf4..3094de7 100644 --- a/src/ui/windows/address_transfer_dialog.h +++ b/src/ui/windows/address_transfer_dialog.h @@ -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(); diff --git a/src/ui/windows/backup_wallet_dialog.cpp b/src/ui/windows/backup_wallet_dialog.cpp index 47d6a51..e2470a6 100644 --- a/src/ui/windows/backup_wallet_dialog.cpp +++ b/src/ui/windows/backup_wallet_dialog.cpp @@ -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)) { diff --git a/src/ui/windows/balance_address_list.cpp b/src/ui/windows/balance_address_list.cpp index 953e9c5..c10c1f1 100644 --- a/src/ui/windows/balance_address_list.cpp +++ b/src/ui/windows/balance_address_list.cpp @@ -42,7 +42,7 @@ std::vector BuildAddressListRows(const std::vectorbalance < 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}); } diff --git a/src/ui/windows/balance_address_list.h b/src/ui/windows/balance_address_list.h index 8d91a67..4234e96 100644 --- a/src/ui/windows/balance_address_list.h +++ b/src/ui/windows/balance_address_list.h @@ -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; diff --git a/src/ui/windows/balance_tab.cpp b/src/ui/windows/balance_tab.cpp index d360965..2453968 100644 --- a/src/ui/windows/balance_tab.cpp +++ b/src/ui/windows/balance_tab.cpp @@ -652,6 +652,7 @@ static void RenderBalanceClassic(App* app) bool isZ; bool hidden; bool favorite; + bool mining; }; std::vector 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) diff --git a/src/ui/windows/console_output_model.cpp b/src/ui/windows/console_output_model.cpp index 94bbab3..ba1175f 100644 --- a/src/ui/windows/console_output_model.cpp +++ b/src/ui/windows/console_output_model.cpp @@ -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); diff --git a/src/ui/windows/console_output_model.h b/src/ui/windows/console_output_model.h index 5425432..cd98880 100644 --- a/src/ui/windows/console_output_model.h +++ b/src/ui/windows/console_output_model.h @@ -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, diff --git a/src/ui/windows/console_tab.cpp b/src/ui/windows/console_tab.cpp index 774254c..b29749b 100644 --- a/src/ui/windows/console_tab.cpp +++ b/src/ui/windows/console_tab.cpp @@ -27,6 +27,8 @@ #include #include #include +#include +#include #include 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 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 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 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 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(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(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); diff --git a/src/ui/windows/console_tab.h b/src/ui/windows/console_tab.h index ca65aed..8360719 100644 --- a/src/ui/windows/console_tab.h +++ b/src/ui/windows/console_tab.h @@ -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 scanline_rows_; std::mutex lines_mutex_; diff --git a/src/ui/windows/explorer_tab.cpp b/src/ui/windows/explorer_tab.cpp index f5ccb0d..e22a472 100644 --- a/src/ui/windows/explorer_tab.cpp +++ b/src/ui/windows/explorer_tab.cpp @@ -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 #include #include +#include +#include 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 s_recent_blocks; -static int s_last_known_height = 0; +using BlockSummary = ExplorerBlockSummary; +static ExplorerBlockCache s_recent_block_cache; +static std::set s_pending_block_heights; +static int s_recent_page = 0; +static int s_recent_max_page = 0; static std::atomic 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(); + } 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(); 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 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(); - 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(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(); + 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(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(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(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(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 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 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 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 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 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 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(); diff --git a/src/ui/windows/export_all_keys_dialog.cpp b/src/ui/windows/export_all_keys_dialog.cpp index aefc5c7..aa72dac 100644 --- a/src/ui/windows/export_all_keys_dialog.cpp +++ b/src/ui/windows/export_all_keys_dialog.cpp @@ -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"; diff --git a/src/ui/windows/import_key_dialog.cpp b/src/ui/windows/import_key_dialog.cpp index 7b37701..6e197b0 100644 --- a/src/ui/windows/import_key_dialog.cpp +++ b/src/ui/windows/import_key_dialog.cpp @@ -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); } }; diff --git a/src/ui/windows/key_export_dialog.cpp b/src/ui/windows/key_export_dialog.cpp index a05ee9a..339c469 100644 --- a/src/ui/windows/key_export_dialog.cpp +++ b/src/ui/windows/key_export_dialog.cpp @@ -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(); } 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(); } catch (const std::exception& e) { diff --git a/src/ui/windows/market_tab.cpp b/src/ui/windows/market_tab.cpp index 78d5133..6363543 100644 --- a/src/ui/windows/market_tab.cpp +++ b/src/ui/windows/market_tab.cpp @@ -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(); diff --git a/src/ui/windows/peers_tab.cpp b/src/ui/windows/peers_tab.cpp index 39596d0..406ab7e 100644 --- a/src/ui/windows/peers_tab.cpp +++ b/src/ui/windows/peers_tab.cpp @@ -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)); } diff --git a/src/ui/windows/receive_tab.cpp b/src/ui/windows/receive_tab.cpp index 72c3b46..f0a3dd2 100644 --- a/src/ui/windows/receive_tab.cpp +++ b/src/ui/windows/receive_tab.cpp @@ -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 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((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; diff --git a/src/ui/windows/send_tab.cpp b/src/ui/windows/send_tab.cpp index c308d09..6088053 100644 --- a/src/ui/windows/send_tab.cpp +++ b/src/ui/windows/send_tab.cpp @@ -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 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((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 diff --git a/src/ui/windows/settings_window.cpp b/src/ui/windows/settings_window.cpp index 4b76979..884c007 100644 --- a/src/ui/windows/settings_window.cpp +++ b/src/ui/windows/settings_window.cpp @@ -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"); diff --git a/src/ui/windows/shield_dialog.cpp b/src/ui/windows/shield_dialog.cpp index e743f99..003bb19 100644 --- a/src/ui/windows/shield_dialog.cpp +++ b/src/ui/windows/shield_dialog.cpp @@ -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}); diff --git a/src/ui/windows/transactions_tab.cpp b/src/ui/windows/transactions_tab.cpp index e03c80a..3624edd 100644 --- a/src/ui/windows/transactions_tab.cpp +++ b/src/ui/windows/transactions_tab.cpp @@ -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(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(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 diff --git a/src/ui/windows/validate_address_dialog.cpp b/src/ui/windows/validate_address_dialog.cpp index bb96a10..65f4eaf 100644 --- a/src/ui/windows/validate_address_dialog.cpp +++ b/src/ui/windows/validate_address_dialog.cpp @@ -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); diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index e3a13ea..7d83e18 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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"; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 8723e1d..fd13bf6 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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(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({"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({"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({ + "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(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({"listtransactions"})); + EXPECT_EQ(missingAddressesResult.transactions.size(), static_cast(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({"listtransactions"})); + EXPECT_EQ(pendingOpidResult.transactions.size(), static_cast(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(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({"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(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({"listtransactions"})); + EXPECT_EQ(recentRpc.calls[0].params, json::array({"", 100, 0})); + EXPECT_EQ(recent.blockHeight, 123); + EXPECT_EQ(recent.transactions.size(), static_cast(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({ + "listtransactions", "z_listreceivedbyaddress" + })); + EXPECT_EQ(recentShieldedProbeRpc.calls[1].params, json::array({"zs-probe-b", 0})); + EXPECT_EQ(recentShieldedProbe.nextShieldedScanStartIndex, static_cast(0)); + EXPECT_EQ(recentShieldedProbe.shieldedAddressesScanned, static_cast(1)); + EXPECT_EQ(recentShieldedProbe.shieldedScanHeights.at("zs-probe-b"), 600); + EXPECT_EQ(recentShieldedProbe.transactions.size(), static_cast(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({ + "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(2)); + EXPECT_EQ(partialShielded.shieldedAddressesScanned, static_cast(2)); + EXPECT_EQ(partialShielded.transactions.size(), static_cast(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({ + "listtransactions", "z_listreceivedbyaddress" + })); + EXPECT_EQ(finalShieldedRpc.calls[1].params, json::array({"zs-two", 0})); + EXPECT_TRUE(finalShielded.shieldedScanComplete); + EXPECT_EQ(finalShielded.nextShieldedScanStartIndex, static_cast(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({"listtransactions"})); + EXPECT_TRUE(cachedShielded.shieldedScanComplete); + EXPECT_EQ(cachedShielded.shieldedAddressesScanned, static_cast(0)); + EXPECT_EQ(cachedShielded.transactions.size(), static_cast(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({ + "listtransactions", "z_listreceivedbyaddress" + })); + EXPECT_EQ(staleProgressRpc.calls[1].params, json::array({"zs-stale", 0})); + EXPECT_FALSE(staleProgress.shieldedScanComplete); + EXPECT_EQ(staleProgress.nextShieldedScanStartIndex, static_cast(2)); + EXPECT_EQ(staleProgress.shieldedAddressesScanned, static_cast(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(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(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(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(1)); + EXPECT_EQ(parsed.successTxids[0], std::string("tx-success")); + EXPECT_EQ(parsed.successTxidsByOpid.size(), static_cast(1)); + EXPECT_EQ(parsed.successTxidsByOpid.at("op-success"), std::string("tx-success")); + EXPECT_EQ(parsed.failureMessages.size(), static_cast(1)); + EXPECT_EQ(parsed.failureMessages[0], std::string("bad memo")); + EXPECT_EQ(parsed.staleOpids.size(), static_cast(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 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(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 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 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(database)), std::istreambuf_iterator()); + 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(1700000200)); + EXPECT_EQ(loaded.transactions.size(), static_cast(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(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(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 sendTxids; + std::vector confirmedCache; + std::unordered_set 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(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(1700000500)); + EXPECT_EQ(lastTxBlock, 333); + EXPECT_EQ(confirmedIds.count("rpc-refreshed-send"), static_cast(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) {